mirror of
https://github.com/home-assistant/core.git
synced 2026-06-18 09:52:57 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb0440b290 | |||
| c1409baf89 | |||
| 0088f1f071 | |||
| 7aba1daa16 | |||
| 12397cc4c1 | |||
| 73cdf7e067 | |||
| 4e2cfecd96 | |||
| 4625f7de27 | |||
| 53a1db405c | |||
| ff7262d36f | |||
| 54feb95b76 | |||
| d9e2b49c0c | |||
| 4f9051464d | |||
| 87894fd623 | |||
| 34a70a9210 | |||
| c9fb6a13fb | |||
| 1601b5151c | |||
| da0e23093d | |||
| 7863468a34 | |||
| 4ff5ee0520 | |||
| 6d8e3ab0c9 | |||
| faa3a4ddef | |||
| 9cd7ea97e9 | |||
| 6012ec97b3 | |||
| c58b281eda | |||
| 05001e581a | |||
| 20dbfd19e2 | |||
| 179cb6e385 | |||
| 163fe9f20c | |||
| f7d8bb112f | |||
| c973bd90b2 | |||
| 92e947ac28 | |||
| a514683efa | |||
| 41fe4f4f69 | |||
| e613f2b1e7 | |||
| 5c4f48a069 | |||
| 219455ab4b | |||
| 75815fbc15 | |||
| 33d9249d34 | |||
| 7cefe94467 | |||
| c95ea00479 | |||
| 730b6065ff | |||
| 1589ad2c6a | |||
| d0df0de267 | |||
| aec09fadd4 | |||
| e2d68fcf58 |
@@ -642,6 +642,7 @@ homeassistant.components.xbox.*
|
||||
homeassistant.components.xiaomi_ble.*
|
||||
homeassistant.components.yale_smart_alarm.*
|
||||
homeassistant.components.yalexs_ble.*
|
||||
homeassistant.components.yoto.*
|
||||
homeassistant.components.youtube.*
|
||||
homeassistant.components.zeroconf.*
|
||||
homeassistant.components.zinvolt.*
|
||||
|
||||
@@ -30,7 +30,7 @@ from homeassistant.exceptions import (
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADDITIONAL_SETTINGS
|
||||
from .coordinator import (
|
||||
AirOSConfigEntry,
|
||||
AirOSDataUpdateCoordinator,
|
||||
@@ -55,14 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
# By default airOS 8 comes with self-signed SSL certificates,
|
||||
# with no option in the web UI to change or upload a custom certificate.
|
||||
session = async_get_clientsession(
|
||||
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
|
||||
hass, verify_ssl=entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
conn_data = {
|
||||
CONF_HOST: entry.data[CONF_HOST],
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry.data[CONF_PASSWORD],
|
||||
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
"use_ssl": entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
|
||||
"session": session,
|
||||
}
|
||||
|
||||
@@ -116,15 +116,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
|
||||
"""Migrate old config entry."""
|
||||
|
||||
# 1.1 Migrate config_entry to add advanced ssl settings
|
||||
# 1.1 Migrate config_entry to add additional ssl settings
|
||||
if entry.version == 1 and entry.minor_version == 1:
|
||||
new_minor_version = 2
|
||||
new_data = {**entry.data}
|
||||
advanced_data = {
|
||||
additional_data = {
|
||||
CONF_SSL: DEFAULT_SSL,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
}
|
||||
new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
|
||||
new_data[SECTION_ADDITIONAL_SETTINGS] = additional_data
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -52,7 +52,7 @@ from .const import (
|
||||
HOSTNAME,
|
||||
IP_ADDRESS,
|
||||
MAC_ADDRESS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -66,7 +66,7 @@ STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
|
||||
@@ -134,7 +134,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# with no option in the web UI to change or upload a custom certificate.
|
||||
session = async_get_clientsession(
|
||||
self.hass,
|
||||
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
|
||||
verify_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -143,7 +143,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
username=config_data[CONF_USERNAME],
|
||||
password=config_data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
use_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
|
||||
)
|
||||
|
||||
except (
|
||||
@@ -234,18 +234,18 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_SSL,
|
||||
default=current_data[SECTION_ADVANCED_SETTINGS][
|
||||
default=current_data[SECTION_ADDITIONAL_SETTINGS][
|
||||
CONF_SSL
|
||||
],
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_VERIFY_SSL,
|
||||
default=current_data[SECTION_ADVANCED_SETTINGS][
|
||||
default=current_data[SECTION_ADDITIONAL_SETTINGS][
|
||||
CONF_VERIFY_SSL
|
||||
],
|
||||
): bool,
|
||||
|
||||
@@ -12,7 +12,7 @@ MANUFACTURER = "Ubiquiti"
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
DEFAULT_SSL = True
|
||||
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
|
||||
|
||||
# Discovery related
|
||||
DEFAULT_USERNAME = "ubnt"
|
||||
|
||||
@@ -4,7 +4,7 @@ from homeassistant.const import CONF_HOST, CONF_SSL
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS
|
||||
from .const import DOMAIN, MANUFACTURER, SECTION_ADDITIONAL_SETTINGS
|
||||
from .coordinator import AirOSDataUpdateCoordinator
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
|
||||
airos_data = self.coordinator.data
|
||||
url_schema = (
|
||||
"https"
|
||||
if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
|
||||
if coordinator.config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
|
||||
else "http"
|
||||
)
|
||||
|
||||
|
||||
@@ -33,16 +33,16 @@
|
||||
},
|
||||
"description": "Enter the username and password for {device_name}",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": {
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
|
||||
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
|
||||
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
|
||||
},
|
||||
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
|
||||
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -58,7 +58,7 @@
|
||||
"username": "Administrator username for the airOS device, normally 'ubnt'"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": {
|
||||
"ssl": "Use HTTPS",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
@@ -67,7 +67,7 @@
|
||||
"ssl": "Whether the connection should be encrypted (required for most devices)",
|
||||
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
|
||||
},
|
||||
"name": "Advanced settings"
|
||||
"name": "Additional settings"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -87,16 +87,16 @@
|
||||
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"additional_settings": {
|
||||
"data": {
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
|
||||
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
|
||||
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
|
||||
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
|
||||
},
|
||||
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
|
||||
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
|
||||
"jid_options_description": "Additional grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity.",
|
||||
"jid_options_name": "JID options",
|
||||
"key_press": "Press",
|
||||
"key_release": "Release",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import Param, Parameter
|
||||
from compit_inext_api import Parameter
|
||||
from compit_inext_api.consts import (
|
||||
CompitFanMode,
|
||||
CompitHVACMode,
|
||||
@@ -150,7 +150,7 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value.value)
|
||||
return float(value)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
@@ -158,7 +158,7 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value.value)
|
||||
return float(value)
|
||||
|
||||
@cached_property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
@@ -195,27 +195,24 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
"""Return the current preset mode."""
|
||||
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
|
||||
|
||||
if preset_mode:
|
||||
compit_preset_mode = CompitPresetMode(preset_mode.value)
|
||||
return COMPIT_PRESET_MAP.get(compit_preset_mode)
|
||||
if preset_mode is not None:
|
||||
return COMPIT_PRESET_MAP.get(CompitPresetMode(preset_mode))
|
||||
return None
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
|
||||
if fan_mode:
|
||||
compit_fan_mode = CompitFanMode(fan_mode.value)
|
||||
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
|
||||
if fan_mode is not None:
|
||||
return COMPIT_FANSPEED_MAP.get(CompitFanMode(fan_mode))
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
|
||||
if hvac_mode:
|
||||
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
|
||||
return COMPIT_MODE_MAP.get(compit_hvac_mode)
|
||||
if hvac_mode is not None:
|
||||
return COMPIT_MODE_MAP.get(CompitHVACMode(hvac_mode))
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
@@ -258,8 +255,6 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> str | float | None:
|
||||
"""Get the parameter value from the device state."""
|
||||
return self.coordinator.connector.get_device_parameter(
|
||||
self.device_id, parameter
|
||||
)
|
||||
return self.coordinator.connector.get_current_value(self.device_id, parameter)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.8.0"]
|
||||
"requirements": ["compit-inext-api==0.9.1"]
|
||||
}
|
||||
|
||||
@@ -852,7 +852,7 @@ class DefaultAgent(ConversationEntity):
|
||||
)
|
||||
|
||||
# Build filtered slot list
|
||||
text_lower = text.strip().lower()
|
||||
text_lower = remove_punctuation(text).strip().lower()
|
||||
return TextSlotList(
|
||||
name="name",
|
||||
values=[
|
||||
@@ -889,7 +889,8 @@ class DefaultAgent(ConversationEntity):
|
||||
for name in intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state
|
||||
):
|
||||
yield (name, name, context)
|
||||
# Strip punctuation so aliases match the cleaned input text.
|
||||
yield (remove_punctuation(name).strip(), name, context)
|
||||
|
||||
def _recognize_strict(
|
||||
self,
|
||||
@@ -1162,7 +1163,7 @@ class DefaultAgent(ConversationEntity):
|
||||
areas = ar.async_get(self.hass)
|
||||
area_names = []
|
||||
for area in areas.async_list_areas():
|
||||
area_names.append((area.name, area.name))
|
||||
area_names.append((remove_punctuation(area.name).strip(), area.name))
|
||||
if not area.aliases:
|
||||
continue
|
||||
|
||||
@@ -1171,13 +1172,13 @@ class DefaultAgent(ConversationEntity):
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
area_names.append((alias, alias))
|
||||
area_names.append((remove_punctuation(alias).strip(), alias))
|
||||
|
||||
# Expose all floors.
|
||||
floors = fr.async_get(self.hass)
|
||||
floor_names = []
|
||||
for floor in floors.async_list_floors():
|
||||
floor_names.append((floor.name, floor.name))
|
||||
floor_names.append((remove_punctuation(floor.name).strip(), floor.name))
|
||||
if not floor.aliases:
|
||||
continue
|
||||
|
||||
@@ -1186,7 +1187,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
floor_names.append((alias, floor.name))
|
||||
floor_names.append((remove_punctuation(alias).strip(), floor.name))
|
||||
|
||||
# Build trie
|
||||
self._exposed_names_trie = Trie()
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ElectraSmartConfigEntry
|
||||
@@ -145,6 +145,7 @@ class ElectraClimateEntity(ClimateEntity):
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._electra_ac_device.mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self._electra_ac_device.mac)},
|
||||
name=device.name,
|
||||
model=self._electra_ac_device.model,
|
||||
manufacturer=self._electra_ac_device.manufactor,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"search": "IMAP search",
|
||||
"server": "Server",
|
||||
"ssl_cipher_list": "SSL cipher list (Advanced)",
|
||||
"ssl_cipher_list": "SSL cipher list",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from enum import Enum
|
||||
import logging
|
||||
@@ -49,6 +49,7 @@ from homeassistant.helpers.event import (
|
||||
async_track_state_report_event,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_MAX_SUB_INTERVAL,
|
||||
@@ -339,8 +340,7 @@ class IntegrationSensor(RestoreSensor):
|
||||
else max_sub_interval
|
||||
)
|
||||
self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time: datetime = datetime.now(tz=UTC)
|
||||
self._last_integration_time: datetime = dt_util.utcnow()
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
self._attr_suggested_display_precision = round_digits or 2
|
||||
|
||||
@@ -499,8 +499,7 @@ class IntegrationSensor(RestoreSensor):
|
||||
old_timestamp, new_timestamp, old_state, new_state
|
||||
)
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
self._last_integration_time = dt_util.utcnow()
|
||||
finally:
|
||||
# When max_sub_interval exceeds without state change the source is assumed
|
||||
# constant with the last known state (new_state).
|
||||
@@ -608,8 +607,7 @@ class IntegrationSensor(RestoreSensor):
|
||||
self._update_integral(area)
|
||||
self.async_write_ha_state()
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
self._last_integration_time = dt_util.utcnow()
|
||||
self._last_integration_trigger = _IntegrationTrigger.TimeElapsed
|
||||
|
||||
self._schedule_max_sub_interval_exceeded_if_state_is_numeric(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""LG IR Remote integration for Home Assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up LG IR from a config entry."""
|
||||
@@ -16,3 +20,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a LG IR config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entry."""
|
||||
if entry.version == 1:
|
||||
# v1 used the infrared entity_id in the entry's unique_id, which is
|
||||
# not stable and was removed in v2.
|
||||
_LOGGER.debug("Migrating config entry from version 1 to 2")
|
||||
hass.config_entries.async_update_entry(entry, unique_id=None, version=2)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Config flow for LG IR integration."""
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -35,7 +35,7 @@ DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
|
||||
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for LG IR."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -49,24 +49,39 @@ class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
if entity_id := user_input.get(CONF_INFRARED_ENTITY_ID) or user_input.get(
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID
|
||||
):
|
||||
emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID)
|
||||
receiver_id = user_input.get(CONF_INFRARED_RECEIVER_ENTITY_ID)
|
||||
if emitter_id or receiver_id:
|
||||
device_type = user_input[CONF_DEVICE_TYPE]
|
||||
|
||||
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
|
||||
self._abort_if_unique_id_configured()
|
||||
if emitter_id:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_DEVICE_TYPE: device_type,
|
||||
CONF_INFRARED_ENTITY_ID: emitter_id,
|
||||
}
|
||||
)
|
||||
if receiver_id:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_DEVICE_TYPE: device_type,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: receiver_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Get entity name for the title
|
||||
title_entity_id = emitter_id or receiver_id
|
||||
if TYPE_CHECKING:
|
||||
assert title_entity_id is not None
|
||||
ent_reg = er.async_get(self.hass)
|
||||
entry = ent_reg.async_get(entity_id)
|
||||
entity_name = (
|
||||
entry.name or entry.original_name or entity_id
|
||||
entry = ent_reg.async_get(title_entity_id)
|
||||
title_entity_name = (
|
||||
entry.name or entry.original_name or title_entity_id
|
||||
if entry
|
||||
else entity_id
|
||||
else title_entity_id
|
||||
)
|
||||
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
|
||||
title = f"LG {device_type_name} via {entity_name}"
|
||||
title = f"LG {device_type_name} via {title_entity_name}"
|
||||
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 sensor’s state expires, if it’s not updated. After expiry, the sensor’s 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 sensor’s 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 sensor’s state expires, if it’s not updated. After expiry, the sensor’s 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 sensor’s 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": {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.18.4"]
|
||||
"requirements": ["opower==0.18.5"]
|
||||
}
|
||||
|
||||
@@ -308,17 +308,17 @@ class Events(Base):
|
||||
def from_event(event: Event) -> Events:
|
||||
"""Create an event database object from a native event."""
|
||||
context = event.context
|
||||
# The unused legacy columns (event_type, event_data, time_fired,
|
||||
# context_id, context_user_id, context_parent_id) are nullable with no
|
||||
# default, so they are intentionally left unset here. Assigning them
|
||||
# None would still insert NULL, but each assignment goes through
|
||||
# SQLAlchemy's instrumented attribute machinery, which is a measurable
|
||||
# cost when run for every recorded event.
|
||||
return Events(
|
||||
event_type=None,
|
||||
event_data=None,
|
||||
origin_idx=event.origin.idx,
|
||||
time_fired=None,
|
||||
time_fired_ts=event.time_fired_timestamp,
|
||||
context_id=None,
|
||||
context_id_bin=ulid_to_bytes_or_none(context.id),
|
||||
context_user_id=None,
|
||||
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
|
||||
context_parent_id=None,
|
||||
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
|
||||
)
|
||||
|
||||
@@ -491,19 +491,18 @@ class States(Base):
|
||||
else:
|
||||
last_reported_ts = state.last_reported_timestamp
|
||||
context = event.context
|
||||
# The unused legacy columns (entity_id, attributes, context_id,
|
||||
# context_user_id, context_parent_id, last_updated, last_changed) are
|
||||
# nullable with no default, so they are intentionally left unset here.
|
||||
# Assigning them None would still insert NULL, but each assignment goes
|
||||
# through SQLAlchemy's instrumented attribute machinery, which is a
|
||||
# measurable cost when run for every recorded state change.
|
||||
return States(
|
||||
state=state_value,
|
||||
entity_id=None,
|
||||
attributes=None,
|
||||
context_id=None,
|
||||
context_id_bin=ulid_to_bytes_or_none(context.id),
|
||||
context_user_id=None,
|
||||
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
|
||||
context_parent_id=None,
|
||||
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
|
||||
origin_idx=event.origin.idx,
|
||||
last_updated=None,
|
||||
last_changed=None,
|
||||
last_updated_ts=last_updated_ts,
|
||||
last_changed_ts=last_changed_ts,
|
||||
last_reported_ts=last_reported_ts,
|
||||
@@ -560,8 +559,13 @@ class StateAttributes(Base):
|
||||
# None state means the state was removed from the state machine
|
||||
if (state := event.data["new_state"]) is None:
|
||||
return b"{}"
|
||||
if state_info := state.state_info:
|
||||
unrecorded_attributes = state_info["unrecorded_attributes"]
|
||||
if (state_info := state.state_info) and (
|
||||
unrecorded_attributes := state_info["unrecorded_attributes"]
|
||||
):
|
||||
# The entity has unrecorded attributes, so a combined exclude set
|
||||
# has to be built. The common case (no unrecorded attributes) falls
|
||||
# through to the shared constant below without allocating a set per
|
||||
# recorded state change.
|
||||
exclude_attrs = {
|
||||
*ALL_DOMAIN_EXCLUDE_ATTRS,
|
||||
*unrecorded_attributes,
|
||||
|
||||
@@ -8,7 +8,7 @@ from renson_endura_delta.field_enum import (
|
||||
)
|
||||
from renson_endura_delta.renson import RensonVentilation
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -24,10 +24,11 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
|
||||
"""Initialize the Renson entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
mac = api.get_field_value(coordinator.data, MAC_ADDRESS.name)
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name))
|
||||
},
|
||||
identifiers={(DOMAIN, mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
manufacturer="Renson",
|
||||
model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name),
|
||||
name="Ventilation",
|
||||
@@ -41,6 +42,4 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
|
||||
|
||||
self.api = api
|
||||
|
||||
self._attr_unique_id = (
|
||||
api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}"
|
||||
)
|
||||
self._attr_unique_id = f"{mac}{name}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Reolink integration for HomeAssistant."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from random import uniform
|
||||
from time import time
|
||||
@@ -26,6 +26,7 @@ from homeassistant.helpers import (
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
|
||||
@@ -192,7 +193,7 @@ async def async_setup_entry(
|
||||
hass.config_entries.async_update_entry(config_entry, data=data)
|
||||
|
||||
# If camera WAN blocked, firmware check fails and takes long, do not prevent setup
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
now = dt_util.utcnow()
|
||||
check_time = timedelta(seconds=check_time_sec)
|
||||
delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
firmware_check_delay = check_time - delta_midnight
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Sensoterra devices."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from enum import StrEnum, auto
|
||||
|
||||
from sensoterra.probe import Probe, Sensor
|
||||
@@ -22,6 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS
|
||||
from .coordinator import SensoterraConfigEntry, SensoterraCoordinator
|
||||
@@ -165,5 +166,5 @@ class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity):
|
||||
return False
|
||||
|
||||
# Expire sensor if no update within the last few days.
|
||||
expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) # pylint: disable=home-assistant-enforce-utcnow
|
||||
expiration = dt_util.utcnow() - timedelta(days=SENSOR_EXPIRATION_DAYS)
|
||||
return sensor.timestamp >= expiration
|
||||
|
||||
@@ -247,7 +247,7 @@ def _async_register_base_station(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, str(system.system_id))},
|
||||
manufacturer="SimpliSafe",
|
||||
model=system.version,
|
||||
model=str(system.version),
|
||||
name=system.address,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from sonos_websocket.exception import SonosWebsocketError
|
||||
|
||||
from homeassistant.components import media_source, spotify
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_MEDIA_ALBUM_NAME,
|
||||
ATTR_MEDIA_ANNOUNCE,
|
||||
ATTR_MEDIA_ARTIST,
|
||||
@@ -779,9 +778,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
||||
if self.media.queue_size:
|
||||
attributes["queue_size"] = self.media.queue_size
|
||||
|
||||
if self.source:
|
||||
attributes[ATTR_INPUT_SOURCE] = self.source
|
||||
|
||||
return attributes
|
||||
|
||||
async def async_get_browse_image(
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
|
||||
from .entity import StarlinkEntity
|
||||
@@ -63,8 +64,7 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
|
||||
hour -= 24
|
||||
minute = utc_minutes % 60
|
||||
try:
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
utc = datetime.now(UTC).replace(
|
||||
utc = dt_util.utcnow().replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Provides triggers for timers."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast, override
|
||||
|
||||
@@ -19,6 +20,7 @@ from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
TriggerNotTriggeredReporter,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -65,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] = {}
|
||||
@@ -128,13 +132,17 @@ class TimeRemainingTrigger(Trigger):
|
||||
schedule_for_state(entity_id, to_state, event.context)
|
||||
|
||||
@callback
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
"""Handle changes to the tracked entity set."""
|
||||
for entity_id in removed:
|
||||
if entity_id in scheduled:
|
||||
scheduled.pop(entity_id)()
|
||||
for entity_id in added:
|
||||
state = self._hass.states.get(entity_id)
|
||||
state = entity_states[entity_id]
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VilfoConfigEntry
|
||||
@@ -72,12 +72,20 @@ class VilfoRouterSensor(SensorEntity):
|
||||
self.entity_description = description
|
||||
self.api = api
|
||||
self._attr_device_info = DeviceInfo(
|
||||
# This identifier is a non-standard 3-tuple kept as-is to avoid
|
||||
# migrating existing devices; only the connection is added here.
|
||||
identifiers={(DOMAIN, api.host, api.mac_address)}, # type: ignore[arg-type]
|
||||
name=ROUTER_DEFAULT_NAME,
|
||||
manufacturer=ROUTER_MANUFACTURER,
|
||||
model=ROUTER_DEFAULT_MODEL,
|
||||
sw_version=api.firmware_version,
|
||||
)
|
||||
# The router does not always report a MAC address (e.g. when set up by
|
||||
# host), so only attach the connection when one is available.
|
||||
if api.mac_address:
|
||||
self._attr_device_info["connections"] = {
|
||||
(CONNECTION_NETWORK_MAC, api.mac_address)
|
||||
}
|
||||
self._attr_unique_id = f"{api.unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import logging
|
||||
|
||||
from aiowebdav2.client import Client
|
||||
from aiowebdav2.exceptions import UnauthorizedError
|
||||
from aiowebdav2.exceptions import (
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
@@ -35,6 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_username_password",
|
||||
) from err
|
||||
except (ConnectionExceptionError, NoConnectionError, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
|
||||
# Check if we can connect to the WebDAV server
|
||||
# and access the root directory
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_DEVICE_STATE, ATTR_LORA_INFO, DOMAIN, YOLINK_OFFLINE_TIME
|
||||
|
||||
@@ -72,8 +73,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]):
|
||||
device_reporttime = device_state_resp.data.get("reportAt")
|
||||
if device_reporttime is not None:
|
||||
rpt_time_delta = (
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
datetime.now(tz=UTC).replace(tzinfo=None)
|
||||
dt_util.utcnow().replace(tzinfo=None)
|
||||
- datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
).total_seconds()
|
||||
self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME
|
||||
|
||||
@@ -97,10 +97,6 @@ class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, YotoPlayer]:
|
||||
"""Fetch fresh data from the Yoto cloud."""
|
||||
# _async_setup already populated the client; skip the duplicate first fetch.
|
||||
if self.data is None:
|
||||
return self.client.players
|
||||
|
||||
try:
|
||||
await self._session.async_ensure_token_valid()
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Diagnostics support for the Yoto integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import YotoConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"mac",
|
||||
"network_ssid",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: YotoConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"players": async_redact_data(
|
||||
{
|
||||
player_id: asdict(player)
|
||||
for player_id, player in coordinator.data.items()
|
||||
},
|
||||
TO_REDACT,
|
||||
),
|
||||
}
|
||||
@@ -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
|
||||
@@ -45,10 +45,10 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
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,10 +70,10 @@ 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
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
strict-typing: done
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-4
@@ -760,11 +760,29 @@ class UnitOfPrecipitationDepth(StrEnum):
|
||||
"""Derived from cm³/cm²"""
|
||||
|
||||
|
||||
class UnitOfDensity(StrEnum):
|
||||
"""Density units.
|
||||
|
||||
Ratio of a substance's mass to its volume.
|
||||
"""
|
||||
|
||||
GRAMS_PER_CUBIC_METER = "g/m³"
|
||||
MILLIGRAMS_PER_CUBIC_METER = "mg/m³"
|
||||
MICROGRAMS_PER_CUBIC_METER = "μg/m³"
|
||||
MICROGRAMS_PER_CUBIC_FOOT = "μg/ft³"
|
||||
|
||||
|
||||
# Concentration units
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³"
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³"
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³"
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = UnitOfDensity.GRAMS_PER_CUBIC_METER.value
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = (
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER.value
|
||||
)
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER.value
|
||||
)
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_FOOT.value
|
||||
)
|
||||
_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant(
|
||||
"p/m³", "p/m³", "2027.7"
|
||||
)
|
||||
|
||||
@@ -490,8 +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.
|
||||
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:
|
||||
@@ -499,6 +502,7 @@ class _HistoryPrimingManager:
|
||||
self._hass = hass
|
||||
self._flush_condition = asyncio.Condition()
|
||||
self._flushing = False
|
||||
self._flush_ok = False
|
||||
self._query_lock = asyncio.Lock()
|
||||
|
||||
async def async_prime[_T](
|
||||
@@ -512,37 +516,41 @@ 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()
|
||||
|
||||
do_flush = False
|
||||
while True:
|
||||
async with self._flush_condition:
|
||||
if not self._flushing:
|
||||
# First past the lobby this generation: we run the flush.
|
||||
self._flushing = True
|
||||
do_flush = True
|
||||
break
|
||||
# A peer began a fresh flush after we cleared the lobby; it
|
||||
# covers us too, so wait for it and ride it.
|
||||
# A peer began a fresh flush after we cleared the lobby; wait for
|
||||
# it.
|
||||
await self._flush_condition.wait()
|
||||
break
|
||||
|
||||
if not do_flush:
|
||||
return
|
||||
if self._flush_ok:
|
||||
return
|
||||
# The flush we waited for was cancelled before completing (its owner
|
||||
# timed out): loop and start or wait for a fresh one rather than read
|
||||
# against a queue that was never flushed.
|
||||
|
||||
instance = get_instance(self._hass)
|
||||
flushed = False
|
||||
try:
|
||||
if (commit_future := instance.async_get_commit_future()) is not None:
|
||||
await commit_future
|
||||
flushed = True
|
||||
finally:
|
||||
async with self._flush_condition:
|
||||
self._flushing = False
|
||||
self._flush_ok = flushed
|
||||
self._flush_condition.notify_all()
|
||||
|
||||
|
||||
@@ -670,7 +678,10 @@ class EntityConditionBase(Condition):
|
||||
self._on_unload.append(unsub)
|
||||
|
||||
async def _async_on_entities_update(
|
||||
self, added: set[str], removed: set[str]
|
||||
self,
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
_entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
"""Handle changes to the tracked entity set.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine, Mapping
|
||||
import dataclasses
|
||||
import logging
|
||||
from logging import Logger
|
||||
@@ -21,6 +21,7 @@ from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -43,10 +44,19 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class TargetStateChangedData:
|
||||
"""Data for state change events related to targets."""
|
||||
"""Data for state change events related to targets.
|
||||
|
||||
`targeted_entity_states` holds the states of all targeted entities as of
|
||||
the state change event. State change events are dispatched one event loop
|
||||
iteration after the state machine is updated, so the live state machine
|
||||
may already contain later changes; this mapping does not. It is only
|
||||
valid during the synchronous callback: it is updated in place as
|
||||
subsequent events are dispatched.
|
||||
"""
|
||||
|
||||
state_change_event: Event[EventStateChangedData]
|
||||
targeted_entity_ids: set[str]
|
||||
targeted_entity_states: Mapping[str, State | None]
|
||||
|
||||
|
||||
def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]:
|
||||
@@ -360,7 +370,8 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]],
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str]], Coroutine[Any, Any, None] | None
|
||||
[set[str], set[str], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
]
|
||||
| None = None,
|
||||
*,
|
||||
@@ -371,7 +382,10 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
`on_entities_update` may be a plain callback or a coroutine function.
|
||||
A coroutine is awaited for the initial entity set (so setup is
|
||||
deterministic) and scheduled as a background task for later
|
||||
registry-driven changes.
|
||||
registry-driven changes. It is called with the added and removed
|
||||
entity ids and the states of all currently targeted entities; the
|
||||
states mapping is only valid during the synchronous call, so a
|
||||
coroutine must copy what it needs before awaiting.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -383,6 +397,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
self._on_entities_update = on_entities_update
|
||||
self._state_change_unsub: CALLBACK_TYPE | None = None
|
||||
self._tracked_entities: set[str] = set()
|
||||
self._tracked_entity_states: dict[str, State | None] = {}
|
||||
self._update_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
async def async_setup(self) -> Callable[[], None]:
|
||||
@@ -418,25 +433,49 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
previous_entities = self._tracked_entities
|
||||
self._tracked_entities = tracked_entities
|
||||
|
||||
# Carry over the tracked states of still-tracked entities: they are
|
||||
# consistent with the already-dispatched event stream, while the live
|
||||
# state machine may be ahead of it. Only entities new to the view are
|
||||
# read from the live state machine.
|
||||
previous_states = self._tracked_entity_states
|
||||
tracked_entity_states = {
|
||||
entity_id: (
|
||||
previous_states[entity_id]
|
||||
if entity_id in previous_states
|
||||
else self._hass.states.get(entity_id)
|
||||
)
|
||||
for entity_id in tracked_entities
|
||||
}
|
||||
self._tracked_entity_states = tracked_entity_states
|
||||
|
||||
result: Coroutine[Any, Any, None] | None = None
|
||||
if self._on_entities_update is not None:
|
||||
added = tracked_entities - previous_entities
|
||||
removed = previous_entities - tracked_entities
|
||||
if added or removed:
|
||||
result = self._on_entities_update(added, removed)
|
||||
result = self._on_entities_update(added, removed, tracked_entity_states)
|
||||
|
||||
@callback
|
||||
def state_change_listener(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state change events."""
|
||||
if event.data["entity_id"] in tracked_entities:
|
||||
self._action(TargetStateChangedData(event, tracked_entities))
|
||||
if (entity_id := event.data["entity_id"]) not in tracked_entities:
|
||||
return
|
||||
tracked_entity_states[entity_id] = event.data["new_state"]
|
||||
self._action(
|
||||
TargetStateChangedData(event, tracked_entities, tracked_entity_states)
|
||||
)
|
||||
|
||||
_LOGGER.debug("Tracking state changes for entities: %s", tracked_entities)
|
||||
if self._state_change_unsub:
|
||||
self._state_change_unsub()
|
||||
# Subscribe before unsubscribing the previous listener: if this
|
||||
# tracker is the only subscriber, unsubscribing first tears down the
|
||||
# shared state change tracker, dropping events which have been fired
|
||||
# but not yet dispatched.
|
||||
previous_unsub = self._state_change_unsub
|
||||
self._state_change_unsub = async_track_state_change_event(
|
||||
self._hass, tracked_entities, state_change_listener
|
||||
)
|
||||
if previous_unsub:
|
||||
previous_unsub()
|
||||
return result
|
||||
|
||||
def _unsubscribe(self) -> None:
|
||||
@@ -455,7 +494,10 @@ async def async_track_target_selector_state_change_event(
|
||||
target_selector_config: ConfigType,
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]] = lambda x: x,
|
||||
on_entities_update: Callable[[set[str], set[str]], Coroutine[Any, Any, None] | None]
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
@@ -467,9 +509,11 @@ async def async_track_target_selector_state_change_event(
|
||||
expansion (via device, area, and floor) skips entities
|
||||
with an `entity_category` (config or diagnostic entities).
|
||||
|
||||
`on_entities_update` may be a coroutine function; it is awaited for the
|
||||
initial entity set and scheduled as a task for later registry-driven
|
||||
changes, so this function must itself be awaited.
|
||||
`on_entities_update` is called with the added and removed entity ids and
|
||||
the states of all currently targeted entities. It may be a coroutine
|
||||
function; it is awaited for the initial entity set and scheduled as a
|
||||
task for later registry-driven changes, so this function must itself be
|
||||
awaited. The states mapping is only valid during the synchronous call.
|
||||
"""
|
||||
target_selection = TargetSelection(target_selector_config)
|
||||
if not target_selection.has_any_target:
|
||||
|
||||
@@ -4,8 +4,9 @@ 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 timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
@@ -75,7 +76,7 @@ from .automation import (
|
||||
get_relative_description_key,
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .event import async_track_same_state
|
||||
from .event import async_call_later
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .selector import (
|
||||
NumericThresholdMode,
|
||||
@@ -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."""
|
||||
|
||||
@@ -438,7 +448,11 @@ class EntityTriggerBase(Trigger):
|
||||
"""
|
||||
return state.state not in self._excluded_states
|
||||
|
||||
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
|
||||
def count_matches(
|
||||
self,
|
||||
entity_ids: Iterable[str],
|
||||
states: Mapping[str, State | None] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Return (matches, included) for the entity set.
|
||||
|
||||
`matches` is the number of entities that pass `_should_include` AND
|
||||
@@ -447,11 +461,19 @@ class EntityTriggerBase(Trigger):
|
||||
Callers can use the pair to distinguish vacuous truth
|
||||
(`included == 0`) from a genuine all-match
|
||||
(`matches == included > 0`).
|
||||
|
||||
Entity states are read from `states` when provided, otherwise from
|
||||
the live state machine. Pass the targeted entity states received
|
||||
with a state change event to evaluate the event against the states
|
||||
as they were when the event fired.
|
||||
"""
|
||||
matches = 0
|
||||
included = 0
|
||||
for entity_id in entity_ids:
|
||||
state = self._hass.states.get(entity_id)
|
||||
if states is not None:
|
||||
state = states[entity_id]
|
||||
else:
|
||||
state = self._hass.states.get(entity_id)
|
||||
if state is None or not self._should_include(state):
|
||||
continue
|
||||
included += 1
|
||||
@@ -459,14 +481,95 @@ class EntityTriggerBase(Trigger):
|
||||
matches += 1
|
||||
return matches, included
|
||||
|
||||
@callback
|
||||
def _cancel_invalidated_timers(
|
||||
self,
|
||||
behavior: str,
|
||||
pending_timers: dict[str, CALLBACK_TYPE],
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Cancel pending duration timers invalidated by a state change.
|
||||
|
||||
Runs on every delivered state change, before the trigger's own
|
||||
validity checks: an event which cannot fire the trigger, e.g. an
|
||||
entity becoming unavailable, may still invalidate a pending timer.
|
||||
The targeted entity states have already been updated with this
|
||||
event, so the first/all check can simply recount.
|
||||
"""
|
||||
event = target_state_change_data.state_change_event
|
||||
if behavior == BEHAVIOR_EACH:
|
||||
entity_id = event.data["entity_id"]
|
||||
if entity_id not in pending_timers:
|
||||
return
|
||||
to_state = event.data["new_state"]
|
||||
if (
|
||||
to_state is None
|
||||
or to_state.state in self._excluded_states
|
||||
or not self.is_valid_state(to_state)
|
||||
):
|
||||
pending_timers.pop(entity_id)()
|
||||
return
|
||||
if behavior not in pending_timers:
|
||||
return
|
||||
if not self._combined_state_still_valid(
|
||||
behavior,
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
):
|
||||
pending_timers.pop(behavior)()
|
||||
|
||||
def _combined_state_still_valid(
|
||||
self,
|
||||
behavior: str,
|
||||
entity_ids: Iterable[str],
|
||||
states: Mapping[str, State | None],
|
||||
) -> bool:
|
||||
"""Check the combined first/all state for a pending duration timer."""
|
||||
matches, included = self.count_matches(entity_ids, states)
|
||||
if behavior == BEHAVIOR_FIRST:
|
||||
return matches >= 1
|
||||
# Require at least one included entity to avoid keeping the timer
|
||||
# alive when every targeted entity has been filtered out since it
|
||||
# started — a vacuous all-match (`included == 0`) would otherwise
|
||||
# let the action fire after `for:` even though no entity still
|
||||
# matches.
|
||||
return included > 0 and matches == included
|
||||
|
||||
@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."""
|
||||
|
||||
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
|
||||
unsub_track_same: dict[str, Callable[[], None]] = {}
|
||||
# Pending `for:` duration timers, keyed by entity_id for behavior
|
||||
# each and by the behavior for first/all.
|
||||
pending_timers: dict[str, CALLBACK_TYPE] = {}
|
||||
|
||||
@callback
|
||||
def handle_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
"""Re-validate pending duration timers on target changes.
|
||||
|
||||
Timers of entities no longer targeted are cancelled, and the
|
||||
combined first/all condition is recounted over the updated
|
||||
target: e.g. a non-matching entity added to the target breaks a
|
||||
pending all-match.
|
||||
"""
|
||||
for entity_id in removed:
|
||||
if (cancel := pending_timers.pop(entity_id, None)) is not None:
|
||||
cancel()
|
||||
if behavior not in pending_timers:
|
||||
return
|
||||
if not self._combined_state_still_valid(
|
||||
behavior, entity_states.keys(), entity_states
|
||||
):
|
||||
pending_timers.pop(behavior)()
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
@@ -478,44 +581,33 @@ class EntityTriggerBase(Trigger):
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
def state_still_valid(
|
||||
_: str, from_state: State | None, to_state: State | None
|
||||
) -> bool:
|
||||
"""Check if the state is still valid during the duration wait.
|
||||
|
||||
Called by async_track_same_state on each state change to
|
||||
determine whether to cancel the timer.
|
||||
For behavior each, checks the individual entity's state.
|
||||
For behavior first/all, checks the combined state.
|
||||
"""
|
||||
if behavior == BEHAVIOR_ALL:
|
||||
matches, included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
# Require at least one included entity to avoid keeping
|
||||
# the timer alive when every targeted entity has been
|
||||
# filtered out since it started — a vacuous all-match
|
||||
# (`included == 0`) would otherwise let the action fire
|
||||
# after `for:` even though no entity still matches.
|
||||
return included > 0 and matches == included
|
||||
if behavior == BEHAVIOR_FIRST:
|
||||
matches, _included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
return matches >= 1
|
||||
# Behavior each: check the individual entity's state
|
||||
if not to_state or to_state.state in self._excluded_states:
|
||||
return False
|
||||
return self.is_valid_state(to_state)
|
||||
if pending_timers:
|
||||
self._cancel_invalidated_timers(
|
||||
behavior, pending_timers, target_state_change_data
|
||||
)
|
||||
|
||||
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
|
||||
@@ -524,22 +616,41 @@ 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
|
||||
# the live state machine: state change events are dispatched one
|
||||
# event loop iteration after the state machine is updated, so the
|
||||
# state machine may already contain later changes to other
|
||||
# targeted entities.
|
||||
if behavior == BEHAVIOR_ALL:
|
||||
matches, included = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
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
|
||||
# were previously 2 matches the transition would not be valid and we
|
||||
# would have returned already.
|
||||
matches, _ = self.count_matches(
|
||||
target_state_change_data.targeted_entity_ids
|
||||
target_state_change_data.targeted_entity_ids,
|
||||
target_state_change_data.targeted_entity_states,
|
||||
)
|
||||
if matches != 1:
|
||||
report_not_triggered(
|
||||
"behavior_first_not_satisfied", matches=matches
|
||||
)
|
||||
return
|
||||
|
||||
@callback
|
||||
@@ -565,18 +676,19 @@ class EntityTriggerBase(Trigger):
|
||||
return
|
||||
|
||||
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
|
||||
if subscription_key in unsub_track_same:
|
||||
unsub_track_same.pop(subscription_key)()
|
||||
unsub_track_same[subscription_key] = async_track_same_state(
|
||||
self._hass,
|
||||
self._duration,
|
||||
call_action,
|
||||
state_still_valid,
|
||||
entity_ids=(
|
||||
entity_id
|
||||
if behavior == BEHAVIOR_EACH
|
||||
else target_state_change_data.targeted_entity_ids
|
||||
),
|
||||
if (
|
||||
previous_timer := pending_timers.pop(subscription_key, None)
|
||||
) is not None:
|
||||
previous_timer()
|
||||
|
||||
@callback
|
||||
def fire_after_duration(_now: datetime) -> None:
|
||||
"""Fire the action once the state has held for the duration."""
|
||||
del pending_timers[subscription_key]
|
||||
call_action()
|
||||
|
||||
pending_timers[subscription_key] = async_call_later(
|
||||
self._hass, self._duration, fire_after_duration
|
||||
)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
@@ -584,6 +696,7 @@ class EntityTriggerBase(Trigger):
|
||||
self._target,
|
||||
state_change_listener,
|
||||
self.entity_filter,
|
||||
handle_entities_update if self._duration else None,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
|
||||
@@ -591,9 +704,9 @@ class EntityTriggerBase(Trigger):
|
||||
def async_remove() -> None:
|
||||
"""Remove state listeners async."""
|
||||
unsub()
|
||||
for async_remove in unsub_track_same.values():
|
||||
async_remove()
|
||||
unsub_track_same.clear()
|
||||
for cancel_timer in pending_timers.values():
|
||||
cancel_timer()
|
||||
pending_timers.clear()
|
||||
|
||||
return async_remove
|
||||
|
||||
@@ -1130,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."""
|
||||
|
||||
@@ -1147,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."""
|
||||
|
||||
@@ -1431,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."""
|
||||
|
||||
@@ -1451,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
|
||||
@@ -1489,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(
|
||||
@@ -1501,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
|
||||
@@ -1538,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,9 +5,6 @@ from functools import lru_cache
|
||||
from math import floor, log10
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
@@ -17,6 +14,7 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -248,18 +246,18 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: (
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3
|
||||
),
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -494,14 +492,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter):
|
||||
|
||||
UNIT_CLASS = "concentration"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: 1_000_000.0, # 1000 µg/m³ = 1 mg/m³
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER: 1.0,
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -512,14 +510,14 @@ class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -529,13 +527,13 @@ class NitrogenMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "nitrogen_monoxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -546,14 +544,14 @@ class OzoneConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -751,13 +749,13 @@ class SulphurDioxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "sulphur_dioxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
_SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6180,6 +6180,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.yoto.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.youtube.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -766,7 +766,7 @@ colorlog==6.10.1
|
||||
colorthief==0.2.1
|
||||
|
||||
# homeassistant.components.compit
|
||||
compit-inext-api==0.8.0
|
||||
compit-inext-api==0.9.1
|
||||
|
||||
# homeassistant.components.concord232
|
||||
concord232==0.15.1
|
||||
@@ -1788,7 +1788,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.3
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.18.4
|
||||
opower==0.18.5
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.1.0
|
||||
|
||||
@@ -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 . \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -639,7 +639,7 @@
|
||||
}),
|
||||
}),
|
||||
'entry_data': dict({
|
||||
'advanced_settings': dict({
|
||||
'additional_settings': dict({
|
||||
'ssl': True,
|
||||
'verify_ssl': False,
|
||||
}),
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.components.airos.const import (
|
||||
HOSTNAME,
|
||||
IP_ADDRESS,
|
||||
MAC_ADDRESS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_DHCP,
|
||||
@@ -48,7 +48,7 @@ NEW_PASSWORD = "new_password"
|
||||
REAUTH_STEP = "reauth_confirm"
|
||||
RECONFIGURE_STEP = "reconfigure"
|
||||
|
||||
MOCK_ADVANCED_SETTINGS = {
|
||||
MOCK_ADDITIONAL_SETTINGS = {
|
||||
CONF_SSL: True,
|
||||
CONF_VERIFY_SSL: False,
|
||||
}
|
||||
@@ -57,7 +57,7 @@ MOCK_CONFIG = {
|
||||
CONF_HOST: "1.1.1.1",
|
||||
CONF_USERNAME: DEFAULT_USERNAME,
|
||||
CONF_PASSWORD: "test-password",
|
||||
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
|
||||
}
|
||||
MOCK_CONFIG_REAUTH = {
|
||||
CONF_HOST: "1.1.1.1",
|
||||
@@ -410,7 +410,7 @@ async def test_successful_reconfigure(
|
||||
|
||||
user_input = {
|
||||
CONF_PASSWORD: NEW_PASSWORD,
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_SSL: True,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
@@ -426,8 +426,8 @@ async def test_successful_reconfigure(
|
||||
|
||||
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
|
||||
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
|
||||
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is True
|
||||
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
|
||||
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is True
|
||||
|
||||
assert updated_entry.data[CONF_HOST] == MOCK_CONFIG[CONF_HOST]
|
||||
assert updated_entry.data[CONF_USERNAME] == MOCK_CONFIG[CONF_USERNAME]
|
||||
@@ -468,7 +468,7 @@ async def test_reconfigure_flow_failure(
|
||||
|
||||
user_input = {
|
||||
CONF_PASSWORD: NEW_PASSWORD,
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_SSL: True,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
@@ -525,7 +525,7 @@ async def test_reconfigure_unique_id_mismatch(
|
||||
|
||||
user_input = {
|
||||
CONF_PASSWORD: NEW_PASSWORD,
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_SSL: True,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
@@ -546,8 +546,8 @@ async def test_reconfigure_unique_id_mismatch(
|
||||
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert updated_entry.data[CONF_PASSWORD] == MOCK_CONFIG[CONF_PASSWORD]
|
||||
assert (
|
||||
updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
|
||||
== MOCK_CONFIG[SECTION_ADVANCED_SETTINGS][CONF_SSL]
|
||||
updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
|
||||
== MOCK_CONFIG[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
|
||||
)
|
||||
|
||||
|
||||
@@ -611,7 +611,7 @@ async def test_discover_flow_one_device_found(
|
||||
{
|
||||
CONF_USERNAME: DEFAULT_USERNAME,
|
||||
CONF_PASSWORD: "test-password",
|
||||
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -687,7 +687,7 @@ async def test_discover_flow_multiple_devices_found(
|
||||
{
|
||||
CONF_USERNAME: DEFAULT_USERNAME,
|
||||
CONF_PASSWORD: "test-password",
|
||||
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -785,7 +785,7 @@ async def test_configure_device_flow_exceptions(
|
||||
{
|
||||
CONF_USERNAME: "wrong-user",
|
||||
CONF_PASSWORD: "wrong-password",
|
||||
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -801,7 +801,7 @@ async def test_configure_device_flow_exceptions(
|
||||
{
|
||||
CONF_USERNAME: DEFAULT_USERNAME,
|
||||
CONF_PASSWORD: "some-password",
|
||||
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.airos.const import (
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SECTION_ADDITIONAL_SETTINGS,
|
||||
)
|
||||
from homeassistant.components.airos.coordinator import async_fetch_airos_data
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
@@ -46,7 +46,7 @@ MOCK_CONFIG_PLAIN = {
|
||||
CONF_HOST: "1.1.1.1",
|
||||
CONF_USERNAME: "ubnt",
|
||||
CONF_PASSWORD: "test-password",
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_SSL: False,
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
@@ -56,7 +56,7 @@ MOCK_CONFIG_V1_2 = {
|
||||
CONF_HOST: "1.1.1.1",
|
||||
CONF_USERNAME: "ubnt",
|
||||
CONF_PASSWORD: "test-password",
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
SECTION_ADDITIONAL_SETTINGS: {
|
||||
CONF_SSL: DEFAULT_SSL,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
},
|
||||
@@ -86,8 +86,8 @@ async def test_setup_entry_with_default_ssl(
|
||||
use_ssl=DEFAULT_SSL,
|
||||
)
|
||||
|
||||
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
|
||||
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
|
||||
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
|
||||
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
|
||||
|
||||
|
||||
async def test_setup_entry_without_ssl(
|
||||
@@ -120,8 +120,8 @@ async def test_setup_entry_without_ssl(
|
||||
use_ssl=False,
|
||||
)
|
||||
|
||||
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False
|
||||
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
|
||||
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is False
|
||||
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
|
||||
|
||||
|
||||
async def test_ssl_migrate_entry(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -255,6 +255,88 @@ async def test_punctuation(hass: HomeAssistant) -> None:
|
||||
assert result.response.intent.slots["name"]["text"] == "test light"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence",
|
||||
[
|
||||
# STT may or may not insert the comma based on speech cadence
|
||||
"Turn off upstairs, hallway",
|
||||
"Turn off upstairs hallway",
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_punctuation_in_alias(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
sentence: str,
|
||||
) -> None:
|
||||
"""Test that an alias containing punctuation can still be matched.
|
||||
|
||||
The input is matched with punctuation removed, so the alias must be too.
|
||||
"""
|
||||
entity_registry.async_get_or_create(
|
||||
"light", "demo", "1234", suggested_object_id="test_light"
|
||||
)
|
||||
entity_registry.async_update_entity(
|
||||
"light.test_light", aliases=["Upstairs, hallway"]
|
||||
)
|
||||
hass.states.async_set(
|
||||
"light.test_light",
|
||||
"on",
|
||||
attributes={ATTR_FRIENDLY_NAME: "Test light"},
|
||||
)
|
||||
expose_entity(hass, "light.test_light", True)
|
||||
|
||||
calls = async_mock_service(hass, "light", "turn_off")
|
||||
result = await conversation.async_converse(hass, sentence, None, Context(), None)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data["entity_id"][0] == "light.test_light"
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sentence",
|
||||
[
|
||||
# STT may or may not insert the comma based on speech cadence
|
||||
"Turn on lights in second, floor",
|
||||
"Turn on lights in second floor",
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
async def test_punctuation_in_area_alias(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
sentence: str,
|
||||
) -> None:
|
||||
"""Test that an area alias containing punctuation can still be matched.
|
||||
|
||||
The input is matched with punctuation removed, so the alias must be too.
|
||||
"""
|
||||
area = area_registry.async_get_or_create("area_id")
|
||||
area = area_registry.async_update(area.id, aliases={"Second, floor"})
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"light", "demo", "1234", suggested_object_id="test_light"
|
||||
)
|
||||
entity_registry.async_update_entity("light.test_light", area_id=area.id)
|
||||
hass.states.async_set(
|
||||
"light.test_light",
|
||||
"off",
|
||||
attributes={ATTR_FRIENDLY_NAME: "Test light"},
|
||||
)
|
||||
expose_entity(hass, "light.test_light", True)
|
||||
|
||||
calls = async_mock_service(hass, "light", "turn_on")
|
||||
result = await conversation.async_converse(hass, sentence, None, Context(), None)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data["entity_id"][0] == "light.test_light"
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.intent is not None
|
||||
assert result.response.intent.slots["area"]["value"] == area.id
|
||||
|
||||
|
||||
async def test_expose_flag_automatically_set(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
|
||||
@@ -695,6 +695,8 @@ async def test_if_position(
|
||||
assert service_calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1"
|
||||
|
||||
for record in caplog.records:
|
||||
if record.name == "asyncio" and record.getMessage().startswith("Executing "):
|
||||
continue
|
||||
assert record.levelname in ("DEBUG", "INFO")
|
||||
|
||||
|
||||
@@ -857,4 +859,6 @@ async def test_if_tilt_position(
|
||||
assert service_calls[6].data["some"] == "is_pos_not_gt_45 - event - test_event1"
|
||||
|
||||
for record in caplog.records:
|
||||
if record.name == "asyncio" and record.getMessage().startswith("Executing "):
|
||||
continue
|
||||
assert record.levelname in ("DEBUG", "INFO")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'a8:03:2a:b1:23:45',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'electrasmart',
|
||||
'a8032ab12345',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Electra',
|
||||
'model': 'Electra A/C',
|
||||
'model_id': None,
|
||||
'name': 'Living Room',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests for the Electra Smart integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from electrasmart.device import OperationMode
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.electrasmart.const import (
|
||||
CONF_IMEI,
|
||||
CONF_PHONE_NUMBER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_device")
|
||||
def mock_device_fixture() -> Mock:
|
||||
"""Return a mocked Electra AC device."""
|
||||
device = Mock(
|
||||
mac="a8032ab12345",
|
||||
model="Electra A/C",
|
||||
manufactor="Electra",
|
||||
features=[],
|
||||
is_disconnected=Mock(return_value=False),
|
||||
is_on=Mock(return_value=False),
|
||||
is_horizontal_swing=Mock(return_value=False),
|
||||
is_vertical_swing=Mock(return_value=False),
|
||||
get_fan_speed=Mock(return_value=OperationMode.FAN_SPEED_AUTO),
|
||||
get_mode=Mock(return_value=OperationMode.MODE_COOL),
|
||||
get_sensor_temperature=Mock(return_value=24),
|
||||
get_temperature=Mock(return_value=22),
|
||||
get_shabat_mode=Mock(return_value=False),
|
||||
)
|
||||
# `name` is a reserved Mock kwarg, so it must be set after construction.
|
||||
device.name = "Living Room"
|
||||
return device
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_device: Mock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="0521234567",
|
||||
data={
|
||||
CONF_TOKEN: "token",
|
||||
CONF_IMEI: "2b950000024051000000000000000000",
|
||||
CONF_PHONE_NUMBER: "0521234567",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_api = Mock(devices=[mock_device], fetch_devices=AsyncMock())
|
||||
with patch(
|
||||
"homeassistant.components.electrasmart.ElectraAPI", return_value=mock_api
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "a8032ab12345")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -39,7 +39,6 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
},
|
||||
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,12 +22,11 @@ from tests.components.infrared import (
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_title", "unique_id_entity_id"),
|
||||
("config", "expected_title"),
|
||||
[
|
||||
(
|
||||
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
|
||||
"LG TV via Test IR emitter",
|
||||
mock_infrared_emitter_entity_id,
|
||||
),
|
||||
(
|
||||
{
|
||||
@@ -35,12 +34,10 @@ from tests.components.infrared import (
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
|
||||
},
|
||||
"LG TV via Test IR emitter",
|
||||
mock_infrared_emitter_entity_id,
|
||||
),
|
||||
(
|
||||
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
|
||||
"LG TV via Test IR receiver",
|
||||
mock_infrared_receiver_entity_id,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -51,7 +48,6 @@ async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, str],
|
||||
expected_title: str,
|
||||
unique_id_entity_id: str,
|
||||
) -> None:
|
||||
"""Test successful user config flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -69,7 +65,7 @@ async def test_user_flow_success(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == expected_title
|
||||
assert result["data"] == {CONF_DEVICE_TYPE: LGDeviceType.TV, **config}
|
||||
assert result["result"].unique_id == f"lg_ir_tv_{unique_id_entity_id}"
|
||||
assert result["result"].unique_id is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
@@ -90,9 +86,33 @@ async def test_user_flow_requires_emitter_or_receiver(
|
||||
assert result["errors"] == {"base": "missing_infrared_entity"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_infrared_emitter_entity", "mock_infrared_receiver_entity"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"user_input",
|
||||
[
|
||||
pytest.param(
|
||||
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
|
||||
id="emitter_conflict",
|
||||
),
|
||||
pytest.param(
|
||||
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
|
||||
id="receiver_conflict",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
|
||||
},
|
||||
id="both_conflict",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
user_input: dict[str, str],
|
||||
) -> None:
|
||||
"""Test user flow aborts when entry is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
@@ -105,10 +125,7 @@ async def test_user_flow_already_configured(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_DEVICE_TYPE: LGDeviceType.TV,
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
},
|
||||
user_input={CONF_DEVICE_TYPE: LGDeviceType.TV, **user_input},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -155,6 +172,5 @@ async def test_user_flow_title_from_entity_name(
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == expected_title
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
"""Tests for the LG Infrared integration setup."""
|
||||
|
||||
from homeassistant.components.lg_infrared.const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_INFRARED_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID,
|
||||
DOMAIN,
|
||||
LGDeviceType,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.infrared import (
|
||||
EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
)
|
||||
from tests.components.infrared.common import (
|
||||
MockInfraredEmitterEntity,
|
||||
MockInfraredReceiverEntity,
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_and_unload_entry(
|
||||
@@ -17,3 +32,30 @@ async def test_setup_and_unload_entry(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_migrate_v1_to_v2(
|
||||
hass: HomeAssistant,
|
||||
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
|
||||
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
|
||||
mock_lg_tv_code_to_command: None,
|
||||
) -> None:
|
||||
"""Test migration from v1 (legacy unique_id) to v2 (no unique_id)."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
unique_id=f"lg_ir_tv_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
|
||||
data={
|
||||
CONF_DEVICE_TYPE: LGDeviceType.TV,
|
||||
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.version == 2
|
||||
assert entry.unique_id is None
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.components.mcp.const import (
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -104,8 +105,7 @@ async def mock_credential(hass: HomeAssistant) -> None:
|
||||
@pytest.fixture(name="config_entry_token_expiration")
|
||||
def mock_config_entry_token_expiration() -> datetime.datetime:
|
||||
"""Fixture to mock the token expiration."""
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
return dt_util.utcnow() + datetime.timedelta(days=1)
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry_with_auth")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1622,8 +1622,7 @@ async def test_remove_stale_media(
|
||||
event_media = media_files[0]
|
||||
assert event_media.name.endswith(".mp4")
|
||||
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8)
|
||||
event_time1 = dt_util.utcnow() - datetime.timedelta(days=8)
|
||||
extra_media1 = (
|
||||
device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1634,8 +1633,7 @@ async def test_remove_stale_media(
|
||||
)
|
||||
extra_media2.write_bytes(mp4.getvalue())
|
||||
# This event will not be garbage collected because it is too recent
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3)
|
||||
event_time3 = dt_util.utcnow() - datetime.timedelta(days=3)
|
||||
extra_media3 = (
|
||||
device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1645,8 +1643,7 @@ async def test_remove_stale_media(
|
||||
|
||||
# Advance the clock to invoke the garbage collector. This will remove extra
|
||||
# files that are not valid events that are old enough.
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
point_in_time = dt_util.utcnow() + datetime.timedelta(days=1)
|
||||
with freeze_time(point_in_time):
|
||||
async_fire_time_changed(hass, point_in_time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'80:7d:3a:bd:1e:32',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': '8.0',
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'renson',
|
||||
'80:7d:3a:bd:1e:32',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Renson',
|
||||
'model': 'Endura Delta',
|
||||
'model_id': None,
|
||||
'name': 'Ventilation',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': 'Firmware version 4.9.1',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for the Renson integration setup."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.renson.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test the device registry entry, including the network MAC connection."""
|
||||
all_data = {
|
||||
"ModifiedItems": [
|
||||
{"Name": "MAC", "Value": "80:7d:3a:bd:1e:32"},
|
||||
{"Name": "Device name", "Value": "Endura Delta"},
|
||||
{"Name": "Firmware version", "Value": "Firmware version 4.9.1"},
|
||||
{"Name": "Hardware version", "Value": "8.0"},
|
||||
]
|
||||
}
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "1.1.1.1"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.connect.return_value = True
|
||||
mock_api.get_all_data.return_value = all_data
|
||||
|
||||
def _get_field_value(data: dict, fieldname: str) -> str:
|
||||
for item in data["ModifiedItems"]:
|
||||
if item["Name"] == fieldname:
|
||||
return item["Value"]
|
||||
return ""
|
||||
|
||||
mock_api.get_field_value.side_effect = _get_field_value
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.renson.RensonVentilation",
|
||||
return_value=mock_api,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.renson.PLATFORMS",
|
||||
[Platform.SENSOR],
|
||||
),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "80:7d:3a:bd:1e:32")}
|
||||
)
|
||||
assert device_entry == snapshot
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
@@ -47,6 +47,7 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import (
|
||||
CONF_BC_CONNECT,
|
||||
@@ -1205,7 +1206,7 @@ async def test_firmware_update_delay(
|
||||
call_count: int,
|
||||
) -> None:
|
||||
"""Test delay of firmware update check."""
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
now = dt_util.utcnow()
|
||||
check_delay = (
|
||||
now
|
||||
+ timedelta(seconds=seconds)
|
||||
|
||||
@@ -49,6 +49,21 @@ async def test_base_station_migration(
|
||||
assert device_registry.async_get_device(identifiers=new_identifiers) is not None
|
||||
|
||||
|
||||
async def test_base_station_model_is_string(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
config_entry: MockConfigEntry,
|
||||
patch_simplisafe_api,
|
||||
) -> None:
|
||||
"""Test that the base station model is stored as a string in the device registry."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, "12345")})
|
||||
assert device is not None
|
||||
assert isinstance(device.model, str)
|
||||
|
||||
|
||||
async def test_coordinator_update_triggers_reauth_on_invalid_credentials(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_registry[with_mac]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
tuple(
|
||||
'mac',
|
||||
'ff:00:00:00:00:00',
|
||||
),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vilfo',
|
||||
'testadmin.vilfo.com',
|
||||
'FF-00-00-00-00-00',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Vilfo AB',
|
||||
'model': 'Vilfo Router',
|
||||
'model_id': None,
|
||||
'name': 'Vilfo Router',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.1.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_registry[without_mac]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'vilfo',
|
||||
'testadmin.vilfo.com',
|
||||
None,
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Vilfo AB',
|
||||
'model': 'Vilfo Router',
|
||||
'model_id': None,
|
||||
'name': 'Vilfo Router',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.1.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Tests for the Vilfo Router integration setup."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.vilfo.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mac", "identifiers"),
|
||||
[
|
||||
pytest.param(
|
||||
"FF-00-00-00-00-00",
|
||||
{(DOMAIN, "testadmin.vilfo.com", "FF-00-00-00-00-00")},
|
||||
id="with_mac",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
{(DOMAIN, "testadmin.vilfo.com", None)},
|
||||
id="without_mac",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_device_registry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mac: str | None,
|
||||
identifiers: set[tuple[str, str | None]],
|
||||
) -> None:
|
||||
"""Test the device registry entry.
|
||||
|
||||
The network MAC connection is only attached when the router reports a MAC;
|
||||
a router set up by host may not report one.
|
||||
"""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.vilfo.VilfoClient", autospec=True
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.mac = mac
|
||||
client.get_board_information.return_value = {
|
||||
"version": "1.1.0",
|
||||
"bootTime": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
client.get_load.return_value = 30
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers=identifiers)
|
||||
assert device_entry == snapshot
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError
|
||||
from aiowebdav2.exceptions import (
|
||||
AccessDeniedError,
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN
|
||||
@@ -28,8 +33,29 @@ from tests.common import MockConfigEntry
|
||||
"Access denied to /access_denied",
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
),
|
||||
(
|
||||
ConnectionExceptionError(ConnectionError("Connection refused")),
|
||||
"Connection refused",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
(
|
||||
NoConnectionError("webdav.demo"),
|
||||
"No connection with webdav.demo",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
(
|
||||
TimeoutError(),
|
||||
"",
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"UnauthorizedError",
|
||||
"AccessDeniedError",
|
||||
"ConnectionExceptionError",
|
||||
"NoConnectionError",
|
||||
"TimeoutError",
|
||||
],
|
||||
ids=["UnauthorizedError", "AccessDeniedError"],
|
||||
)
|
||||
async def test_error_during_setup(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# serializer version: 1
|
||||
# name: test_entry_diagnostics
|
||||
dict({
|
||||
'entry': dict({
|
||||
'data': dict({
|
||||
'auth_implementation': 'yoto',
|
||||
'token': dict({
|
||||
'access_token': '**REDACTED**',
|
||||
'expires_in': 3600,
|
||||
'refresh_token': '**REDACTED**',
|
||||
'scope': 'offline_access family:view family:devices:view family:devices:control family:devices:manage family:library:view user:content:view user:icons:manage',
|
||||
'token_type': 'Bearer',
|
||||
}),
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'yoto',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Yoto',
|
||||
'unique_id': 'auth0|user-test',
|
||||
'version': 1,
|
||||
}),
|
||||
'players': dict({
|
||||
'player-test': dict({
|
||||
'device': dict({
|
||||
'description': None,
|
||||
'device_family': 'v3',
|
||||
'device_group': None,
|
||||
'device_id': 'player-test',
|
||||
'device_type': 'v3',
|
||||
'form_factor': None,
|
||||
'generation': 'gen3',
|
||||
'has_user_given_name': False,
|
||||
'name': 'Nursery Yoto',
|
||||
'release_channel': None,
|
||||
}),
|
||||
'devices_refreshed_at': '2026-05-08T12:00:00+00:00',
|
||||
'extended_status': dict({
|
||||
'active_card': None,
|
||||
'ambient_light_sensor_reading': None,
|
||||
'average_download_speed_bytes_second': None,
|
||||
'battery_level_percentage': None,
|
||||
'battery_level_raw': None,
|
||||
'battery_profile': None,
|
||||
'battery_temperature': None,
|
||||
'battery_voltage_mv': None,
|
||||
'card_insertion_state': None,
|
||||
'current_display_brightness': None,
|
||||
'day_mode': None,
|
||||
'free_disk_space_bytes': None,
|
||||
'is_audio_device_connected': None,
|
||||
'is_background_download_active': None,
|
||||
'is_bluetooth_audio_connected': None,
|
||||
'is_charging': None,
|
||||
'network_ssid': None,
|
||||
'nightlight_mode': None,
|
||||
'power_source': None,
|
||||
'system_volume_percentage': None,
|
||||
'temperature_celcius': None,
|
||||
'total_disk_space_bytes': None,
|
||||
'updated_at': None,
|
||||
'uptime': None,
|
||||
'user_volume_percentage': None,
|
||||
'utc_offset_seconds': None,
|
||||
'utc_time': None,
|
||||
'wifi_strength': None,
|
||||
}),
|
||||
'info': dict({
|
||||
'activation_pop_code': None,
|
||||
'config': dict({
|
||||
'alarms': list([
|
||||
]),
|
||||
'bluetooth_enabled': None,
|
||||
'bt_headphones_enabled': None,
|
||||
'clock_face': None,
|
||||
'day_ambient_colour': None,
|
||||
'day_display_brightness': None,
|
||||
'day_display_brightness_auto': None,
|
||||
'day_max_volume_limit': None,
|
||||
'day_sounds_off': None,
|
||||
'day_time': dict({
|
||||
'__type': "<class 'datetime.time'>",
|
||||
'isoformat': '07:00:00',
|
||||
}),
|
||||
'day_yoto_daily': None,
|
||||
'day_yoto_radio': None,
|
||||
'display_dim_brightness': None,
|
||||
'display_dim_timeout': None,
|
||||
'headphones_volume_limited': None,
|
||||
'hour_format': None,
|
||||
'locale': None,
|
||||
'log_level': None,
|
||||
'night_ambient_colour': None,
|
||||
'night_display_brightness': None,
|
||||
'night_display_brightness_auto': None,
|
||||
'night_max_volume_limit': None,
|
||||
'night_sounds_off': None,
|
||||
'night_time': dict({
|
||||
'__type': "<class 'datetime.time'>",
|
||||
'isoformat': '19:00:00',
|
||||
}),
|
||||
'night_yoto_daily': None,
|
||||
'night_yoto_radio': None,
|
||||
'pause_power_button': None,
|
||||
'pause_volume_down': None,
|
||||
'repeat_all': None,
|
||||
'show_diagnostics': None,
|
||||
'shutdown_timeout': None,
|
||||
'system_volume': None,
|
||||
'timezone': None,
|
||||
'volume_level': None,
|
||||
}),
|
||||
'device_family': None,
|
||||
'device_group': None,
|
||||
'device_type': None,
|
||||
'error_code': None,
|
||||
'firmware_version': 'v2.17.5',
|
||||
'geo_timezone': None,
|
||||
'mac': '**REDACTED**',
|
||||
'name': None,
|
||||
'pop_code': None,
|
||||
'release_channel_id': None,
|
||||
}),
|
||||
'info_refreshed_at': '2026-05-08T12:00:00+00:00',
|
||||
'is_online': True,
|
||||
'last_event': dict({
|
||||
'card_id': 'card-test',
|
||||
'chapter_key': '01',
|
||||
'chapter_title': 'Chapter 1',
|
||||
'event_utc': None,
|
||||
'playback_status': 'playing',
|
||||
'playback_wait': None,
|
||||
'player_id': 'player-test',
|
||||
'position': 120,
|
||||
'repeat_all': None,
|
||||
'request_id': None,
|
||||
'sleep_timer_active': None,
|
||||
'sleep_timer_seconds': None,
|
||||
'source': None,
|
||||
'streaming': None,
|
||||
'track_key': '01-INT',
|
||||
'track_length': 300,
|
||||
'track_title': 'Introduction',
|
||||
'volume': 8,
|
||||
'volume_max': 16,
|
||||
}),
|
||||
'last_event_received_at': '2026-05-08T12:00:00+00:00',
|
||||
'online_refreshed_at': None,
|
||||
'status': dict({
|
||||
'active_card': None,
|
||||
'ambient_light_sensor_reading': None,
|
||||
'battery_level_percentage': 75,
|
||||
'card_insertion_state': 1,
|
||||
'current_display_brightness': None,
|
||||
'day_mode': 1,
|
||||
'free_disk_space_bytes': None,
|
||||
'is_audio_device_connected': False,
|
||||
'is_bluetooth_audio_connected': False,
|
||||
'is_charging': True,
|
||||
'nightlight_mode': None,
|
||||
'system_volume_percentage': None,
|
||||
'updated_at': None,
|
||||
'user_volume_percentage': None,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for the diagnostics data provided by the Yoto integration."""
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from syrupy.filters import props
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("setup_credentials", "mock_yoto_client")
|
||||
|
||||
|
||||
async def test_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test config entry diagnostics."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, mock_config_entry
|
||||
) == snapshot(exclude=props("entry_id", "created_at", "modified_at", "expires_at"))
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test the condition helper."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
@@ -5908,6 +5908,19 @@ async def test_history_priming_manager_serializes_queries(
|
||||
assert max_running == 1
|
||||
|
||||
|
||||
async def _advance_until(predicate: Callable[[], bool]) -> None:
|
||||
"""Pump the event loop until predicate holds, failing if it never does.
|
||||
|
||||
Avoids coupling tests to an exact number of internal await hops while still
|
||||
failing cleanly rather than hanging on a regression.
|
||||
"""
|
||||
for _ in range(1000):
|
||||
if predicate():
|
||||
return
|
||||
await asyncio.sleep(0)
|
||||
pytest.fail("condition was not reached")
|
||||
|
||||
|
||||
async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
@@ -5917,8 +5930,8 @@ async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
sees them. A condition that started tracking after an in-flight flush began
|
||||
could miss its own just-queued change if it rode that flush, so it waits the
|
||||
flush out and a fresh one is performed for it. Without the lobby step this
|
||||
test fails: the late arrivals would ride the first flush (one flush total)
|
||||
instead of sharing a second, fresh one.
|
||||
test fails: the late arrivals would ride the first flush (it would stay at
|
||||
one flush total) instead of sharing a second, fresh one.
|
||||
"""
|
||||
manager = _HistoryPrimingManager(hass)
|
||||
instance = get_instance(hass)
|
||||
@@ -5936,32 +5949,69 @@ async def test_history_priming_manager_does_not_ride_in_flight_flush(
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
# C0 claims the flush and is mid-flush (its commit future is pending).
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
assert len(flush_futures) == 1
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
|
||||
# Two conditions arrive while C0's flush runs; they must not ride it.
|
||||
c1 = asyncio.create_task(manager.async_prime(_job))
|
||||
c2 = asyncio.create_task(manager.async_prime(_job))
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
# Parked in the lobby: no new flush yet, none finished.
|
||||
assert len(flush_futures) == 1
|
||||
assert not c1.done()
|
||||
assert not c2.done()
|
||||
|
||||
# C0's flush completes; C1 now performs a fresh flush and C2 rides it.
|
||||
# C0's flush completes; C1 then performs a fresh flush and C2 rides it.
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
# Exactly one fresh flush is shared by C1 and C2, not one each: this is
|
||||
# the assertion that fails without the lobby (it would stay 1).
|
||||
assert len(flush_futures) == 2
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
|
||||
flush_futures[1].set_result(None)
|
||||
assert await asyncio.gather(c1, c2) == ["done", "done"]
|
||||
# One fresh flush shared by C1 and C2, not one each (and not C0's stale
|
||||
# one): C1 flushed, C2 rode it.
|
||||
assert len(flush_futures) == 2
|
||||
|
||||
|
||||
async def test_history_priming_manager_retries_after_cancelled_flush(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""A rider re-flushes when the flush it rode was cancelled before completing.
|
||||
|
||||
If the condition performing a generation's shared flush is cancelled by its
|
||||
timeout while awaiting the commit, the riders must not read against the
|
||||
unflushed queue — they perform a fresh flush instead. Without that retry this
|
||||
test fails: the rider would proceed on the cancelled flush and never make a
|
||||
second one.
|
||||
"""
|
||||
manager = _HistoryPrimingManager(hass)
|
||||
instance = get_instance(hass)
|
||||
|
||||
flush_futures: list[asyncio.Future[None]] = []
|
||||
|
||||
def _spy_commit_future() -> asyncio.Future[None]:
|
||||
fut = hass.loop.create_future()
|
||||
flush_futures.append(fut)
|
||||
return fut
|
||||
|
||||
async def _job(_recorder: Recorder) -> str:
|
||||
return "done"
|
||||
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
# C0 takes the lobby so c1 and c2 form one generation behind it.
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
c1 = asyncio.create_task(manager.async_prime(_job))
|
||||
c2 = asyncio.create_task(manager.async_prime(_job))
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
|
||||
# c1 performs the generation's flush (the second one) and c2 rides it.
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
|
||||
# c1 is cancelled mid-flush, as its timeout would do. c2 must then run
|
||||
# its own fresh flush rather than ride c1's cancelled one.
|
||||
c1.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await c1
|
||||
await _advance_until(lambda: len(flush_futures) == 3)
|
||||
|
||||
flush_futures[2].set_result(None)
|
||||
assert await c2 == "done"
|
||||
|
||||
|
||||
async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
@@ -5987,14 +6037,11 @@ async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
|
||||
with patch.object(instance, "async_get_commit_future", _spy_commit_future):
|
||||
c0 = asyncio.create_task(manager.async_prime(_job))
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
# A second priming parks in the lobby, then its timeout cancels it.
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
# A second priming parks in the lobby (reached in one step, as its lock
|
||||
# acquire is uncontended), then its timeout cancels it.
|
||||
waiter = asyncio.create_task(manager.async_prime(_job))
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
waiter.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
@@ -6003,9 +6050,7 @@ async def test_history_priming_manager_cancelled_lobby_waiter(
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
later = asyncio.create_task(manager.async_prime(_job))
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert len(flush_futures) == 2
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
flush_futures[1].set_result(None)
|
||||
assert await later == "done"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test service helpers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -16,7 +17,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
@@ -805,16 +806,20 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test on_entities_update callback reports added and removed entities."""
|
||||
entity_updates: list[tuple[set[str], set[str]]] = []
|
||||
entity_updates: list[tuple[set[str], set[str], set[str]]] = []
|
||||
|
||||
@callback
|
||||
def state_change_callback(event: target.TargetStateChangedData) -> None:
|
||||
"""Handle state change events."""
|
||||
|
||||
@callback
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
"""Track entity set changes."""
|
||||
entity_updates.append((added, removed))
|
||||
entity_updates.append((added, removed, set(entity_states)))
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -844,9 +849,10 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
on_entities_update=on_entities_update,
|
||||
)
|
||||
|
||||
# Initial setup fires on_entities_update with all entities as "added"
|
||||
# Initial setup fires on_entities_update with all entities as "added".
|
||||
# The states mapping covers the currently targeted entities.
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set())
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set(), {entity_a.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Add label to entity_b → added
|
||||
@@ -854,7 +860,11 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == ({entity_b.entity_id}, set())
|
||||
assert entity_updates[-1] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_a → removed
|
||||
@@ -862,7 +872,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (set(), {entity_a.entity_id})
|
||||
assert entity_updates[-1] == (set(), {entity_a.entity_id}, {entity_b.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_b → removed
|
||||
@@ -870,7 +880,7 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == (set(), {entity_b.entity_id})
|
||||
assert entity_updates[-1] == (set(), {entity_b.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# Re-add both labels at once — entity_a first, then entity_b
|
||||
@@ -880,8 +890,12 @@ async def test_async_track_target_selector_state_change_event_on_entities_update
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(entity_updates) == 2
|
||||
assert entity_updates[0] == ({entity_a.entity_id}, set())
|
||||
assert entity_updates[1] == ({entity_b.entity_id}, set())
|
||||
assert entity_updates[0] == ({entity_a.entity_id}, set(), {entity_a.entity_id})
|
||||
assert entity_updates[1] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
entity_updates.clear()
|
||||
|
||||
# After unsubscribing, no more callbacks
|
||||
@@ -903,7 +917,11 @@ async def test_async_track_target_selector_cancels_update_task_on_unsubscribe(
|
||||
def state_change_callback(event: target.TargetStateChangedData) -> None:
|
||||
"""Handle state change events."""
|
||||
|
||||
async def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
async def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
nonlocal cancelled
|
||||
started.set()
|
||||
try:
|
||||
|
||||
+389
-51
@@ -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:
|
||||
@@ -4735,15 +4871,11 @@ async def test_entity_trigger_duration_each_cancelled_when_entity_leaves_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test an each duration timer when its entity is untargeted mid-wait.
|
||||
"""Test an each duration timer is cancelled when its entity is untargeted.
|
||||
|
||||
A pending `for:` wait should not outlive the entity's membership of the
|
||||
A pending `for:` wait does not outlive the entity's membership of the
|
||||
target: when a registry change removes the entity from the target, the
|
||||
timer should be cancelled.
|
||||
|
||||
This test documents existing unwanted behavior: the duration timer
|
||||
keeps running and the trigger fires for an entity which is no longer
|
||||
targeted.
|
||||
timer is cancelled and the trigger does not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test Each Removal")
|
||||
@@ -4772,13 +4904,66 @@ async def test_entity_trigger_duration_each_cancelled_when_entity_leaves_target(
|
||||
entity_registry.async_update_entity(entry.entity_id, labels=set())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration. Unwanted: the trigger fires for
|
||||
# the no-longer-targeted entity.
|
||||
# Advance past the original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry.entity_id
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_each_cancelled_on_entity_rename(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test an each duration timer is cancelled when its entity is renamed.
|
||||
|
||||
A rename is delivered as the old entity id leaving the target and the
|
||||
new entity id joining it, so the pending per-entity timer is cancelled
|
||||
rather than transferred to the new id. The entity stays on and targeted
|
||||
under the new id, but never had an off→on transition as the new id, so
|
||||
no timer is armed for it and the trigger does not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test Each Rename")
|
||||
entry = entity_registry.async_get_or_create("test", "test", "labeled")
|
||||
entity_registry.async_update_entity(entry.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_EACH,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# Rename the entity mid-wait. It keeps its label, so it stays targeted
|
||||
# under the new id; the state follows the rename like the entity
|
||||
# component does.
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
new_entity_id = "test.renamed"
|
||||
entity_registry.async_update_entity(entry.entity_id, new_entity_id=new_entity_id)
|
||||
hass.states.async_remove(entry.entity_id)
|
||||
hass.states.async_set(new_entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire: the timer for
|
||||
# the old id was cancelled, and the new id never transitioned off→on.
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4788,17 +4973,12 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending all timer when an entity is removed from the target.
|
||||
"""Test a pending all timer ignores an entity removed from the target.
|
||||
|
||||
Once an entity is removed from the target it should no longer gate the
|
||||
all-match: the timer should keep running and fire if the remaining
|
||||
targeted entities stay matching, even if the removed entity changes to
|
||||
a non-matching state.
|
||||
|
||||
This test documents existing unwanted behavior: the duration cancel
|
||||
check still tracks the entity set frozen when the timer was armed, so
|
||||
the removed entity turning off cancels the timer and the trigger does
|
||||
not fire.
|
||||
Once an entity is removed from the target, it no longer gates the
|
||||
all-match: the timer keeps running and fires if the remaining targeted
|
||||
entities stay matching, even if the removed entity changes to a
|
||||
non-matching state.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test All Removal")
|
||||
@@ -4826,8 +5006,8 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# B leaves the target mid-wait and turns off: it should no longer gate
|
||||
# the all-match.
|
||||
# B leaves the target mid-wait and turns off: it no longer gates the
|
||||
# all-match, so the timer keeps running.
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels=set())
|
||||
@@ -4835,14 +5015,177 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Unwanted: the remaining targeted entity stayed on for the duration,
|
||||
# so the trigger should fire — but the no-longer-targeted entity
|
||||
# cancelled the timer.
|
||||
# The remaining targeted entity stayed on for the duration — fires
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry_b.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("added_entity_state", "expected_calls"),
|
||||
[
|
||||
pytest.param(STATE_OFF, 0, id="added_entity_breaks_all_match"),
|
||||
pytest.param(STATE_ON, 1, id="added_entity_keeps_all_match"),
|
||||
],
|
||||
)
|
||||
async def test_entity_trigger_duration_all_revalidated_when_entity_joins_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
added_entity_state: str,
|
||||
expected_calls: int,
|
||||
) -> None:
|
||||
"""Test a pending all timer is re-validated when an entity is added.
|
||||
|
||||
An entity added to the target mid-wait participates in the all-match:
|
||||
a non-matching entity cancels the pending timer, while a matching one
|
||||
leaves it running and the trigger fires after the duration.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test All Addition")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_ALL,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(entry_b.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# A third entity joins the target mid-wait
|
||||
entry_c = entity_registry.async_get_or_create("test", "test", "labeled_c")
|
||||
hass.states.async_set(entry_c.entity_id, added_entity_state)
|
||||
await hass.async_block_till_done()
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_c.entity_id, labels={label.label_id})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the duration
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == expected_calls
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_cancelled_when_match_leaves_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending first timer when the matching entity is untargeted.
|
||||
|
||||
Removing the only matching entity from the target mid-wait leaves the
|
||||
target without a matching entity, so the pending timer is cancelled and
|
||||
the trigger does not fire.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test First Removal")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_FIRST,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# The only matching entity leaves the target mid-wait
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels=set())
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Advance past the original duration — should NOT fire
|
||||
freezer.tick(datetime.timedelta(seconds=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
async def test_entity_trigger_duration_first_survives_entity_joining_target(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending first timer when an entity is added to the target.
|
||||
|
||||
The added entity does not match, but at least one targeted entity
|
||||
still does, so the timer keeps running and the trigger fires.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test First Addition")
|
||||
entry_a = entity_registry.async_get_or_create("test", "test", "labeled_a")
|
||||
entity_registry.async_update_entity(entry_a.entity_id, labels={label.label_id})
|
||||
hass.states.async_set(entry_a.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
unsub = await _arm_off_to_on_trigger(
|
||||
hass,
|
||||
[],
|
||||
BEHAVIOR_FIRST,
|
||||
calls,
|
||||
duration={"seconds": 5},
|
||||
target={ATTR_LABEL_ID: label.label_id},
|
||||
)
|
||||
|
||||
hass.states.async_set(entry_a.entity_id, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# A non-matching entity joins the target mid-wait
|
||||
entry_b = entity_registry.async_get_or_create("test", "test", "labeled_b")
|
||||
hass.states.async_set(entry_b.entity_id, STATE_OFF)
|
||||
await hass.async_block_till_done()
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels={label.label_id})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The matching entity stayed on for the duration — fires
|
||||
freezer.tick(datetime.timedelta(seconds=4))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry_a.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
|
||||
@@ -4851,19 +5194,15 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
) -> None:
|
||||
"""Test a synchronous bus listener reverting a state change.
|
||||
|
||||
Writing states from a synchronous bus listener during state change
|
||||
dispatch is not supported: the nested state write is dispatched to the
|
||||
target tracker before the event that caused it, inverting per-entity
|
||||
delivery order. Supported state change tracking via
|
||||
async_track_state_change_event or async_track_state_change_filtered is
|
||||
deferred precisely so callbacks cannot run inside the dispatch loop and
|
||||
cause this.
|
||||
A synchronous bus listener turns entity_a off again from within the
|
||||
dispatch of its turn-on event. The event bus queues the nested off-event
|
||||
and dispatches it after the on-event, so the target tracker observes the
|
||||
two events in fire order and its tracked states view stays consistent.
|
||||
|
||||
This test documents the resulting behavior rather than guaranteeing it:
|
||||
both of entity_a's events are evaluated against the live state machine,
|
||||
which already shows the entity off again, so the trigger does not fire
|
||||
for the blip; entity_b turning on later counts as the only match and
|
||||
fires.
|
||||
The trigger fires for entity_a — it was the first entity to match, even
|
||||
though it was immediately reverted — consistent with behavior each and
|
||||
with a same-iteration blip. Because the view is not left stale, entity_b
|
||||
turning on later is correctly recognized as a first match and fires too.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -4881,9 +5220,6 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
):
|
||||
hass.states.async_set(entity_a, STATE_OFF)
|
||||
|
||||
# Registered before the trigger is armed, so it runs before the state
|
||||
# change tracker's bus listener and its nested write is dispatched to
|
||||
# the tracker first.
|
||||
unsub_revert = hass.bus.async_listen(EVENT_STATE_CHANGED, revert_entity_a)
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
@@ -4892,17 +5228,19 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
)
|
||||
|
||||
# entity_a turns on and is synchronously reverted to off. The trigger
|
||||
# receives (on→off) then (off→on); the on-event counts no matches in
|
||||
# the live state machine and the trigger does not fire.
|
||||
# receives (off→on) then (on→off) in fire order and fires for the
|
||||
# on-event: entity_a was the first matching entity.
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
|
||||
# entity_a is off, so entity_b is the first matching entity and fires.
|
||||
# entity_a is off again, so entity_b is now the first matching entity
|
||||
# and the trigger fires for it.
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_b
|
||||
assert len(calls) == 2
|
||||
assert calls[1]["entity_id"] == entity_b
|
||||
|
||||
unsub()
|
||||
unsub_revert()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user