mirror of
https://github.com/home-assistant/core.git
synced 2026-06-17 09:22:53 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38db9d18f7 | |||
| 6439ed840c |
@@ -642,7 +642,6 @@ 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.*
|
||||
|
||||
@@ -34,13 +34,11 @@ def generate_site_selector_name(site: Site) -> str:
|
||||
|
||||
|
||||
def filter_sites(sites: list[Site]) -> list[Site]:
|
||||
"""Filter out closed sites and deduplicate the list of sites."""
|
||||
"""Deduplicates 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": "gold",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyaqvify==0.0.11"]
|
||||
}
|
||||
|
||||
@@ -53,42 +53,28 @@ rules:
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
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
|
||||
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
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No repair issues are created.
|
||||
stale-devices: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"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_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_name": "JID options",
|
||||
"key_press": "Press",
|
||||
"key_release": "Release",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import Parameter
|
||||
from compit_inext_api import Param, 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)
|
||||
return float(value.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)
|
||||
return float(value.value)
|
||||
|
||||
@cached_property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
@@ -195,24 +195,27 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
"""Return the current preset mode."""
|
||||
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
|
||||
|
||||
if preset_mode is not None:
|
||||
return COMPIT_PRESET_MAP.get(CompitPresetMode(preset_mode))
|
||||
if preset_mode:
|
||||
compit_preset_mode = CompitPresetMode(preset_mode.value)
|
||||
return COMPIT_PRESET_MAP.get(compit_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 is not None:
|
||||
return COMPIT_FANSPEED_MAP.get(CompitFanMode(fan_mode))
|
||||
if fan_mode:
|
||||
compit_fan_mode = CompitFanMode(fan_mode.value)
|
||||
return COMPIT_FANSPEED_MAP.get(compit_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 is not None:
|
||||
return COMPIT_MODE_MAP.get(CompitHVACMode(hvac_mode))
|
||||
if hvac_mode:
|
||||
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
|
||||
return COMPIT_MODE_MAP.get(compit_hvac_mode)
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
@@ -255,6 +258,8 @@ class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntit
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> str | float | None:
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
|
||||
"""Get the parameter value from the device state."""
|
||||
return self.coordinator.connector.get_current_value(self.device_id, parameter)
|
||||
return self.coordinator.connector.get_device_parameter(
|
||||
self.device_id, parameter
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.9.1"]
|
||||
"requirements": ["compit-inext-api==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -852,7 +852,7 @@ class DefaultAgent(ConversationEntity):
|
||||
)
|
||||
|
||||
# Build filtered slot list
|
||||
text_lower = remove_punctuation(text).strip().lower()
|
||||
text_lower = text.strip().lower()
|
||||
return TextSlotList(
|
||||
name="name",
|
||||
values=[
|
||||
@@ -889,8 +889,7 @@ class DefaultAgent(ConversationEntity):
|
||||
for name in intent.async_get_entity_aliases(
|
||||
self.hass, entity_entry, state=state
|
||||
):
|
||||
# Strip punctuation so aliases match the cleaned input text.
|
||||
yield (remove_punctuation(name).strip(), name, context)
|
||||
yield (name, name, context)
|
||||
|
||||
def _recognize_strict(
|
||||
self,
|
||||
@@ -1163,7 +1162,7 @@ class DefaultAgent(ConversationEntity):
|
||||
areas = ar.async_get(self.hass)
|
||||
area_names = []
|
||||
for area in areas.async_list_areas():
|
||||
area_names.append((remove_punctuation(area.name).strip(), area.name))
|
||||
area_names.append((area.name, area.name))
|
||||
if not area.aliases:
|
||||
continue
|
||||
|
||||
@@ -1172,13 +1171,13 @@ class DefaultAgent(ConversationEntity):
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
area_names.append((remove_punctuation(alias).strip(), alias))
|
||||
area_names.append((alias, alias))
|
||||
|
||||
# Expose all floors.
|
||||
floors = fr.async_get(self.hass)
|
||||
floor_names = []
|
||||
for floor in floors.async_list_floors():
|
||||
floor_names.append((remove_punctuation(floor.name).strip(), floor.name))
|
||||
floor_names.append((floor.name, floor.name))
|
||||
if not floor.aliases:
|
||||
continue
|
||||
|
||||
@@ -1187,7 +1186,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if not alias:
|
||||
continue
|
||||
|
||||
floor_names.append((remove_punctuation(alias).strip(), floor.name))
|
||||
floor_names.append((alias, 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 CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ElectraSmartConfigEntry
|
||||
@@ -145,7 +145,6 @@ 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,56 +434,49 @@ 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-3.1-flash-tts-preview"
|
||||
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
|
||||
@@ -18,13 +18,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_TEMPERATURE,
|
||||
LOGGER,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TTS_MODEL,
|
||||
)
|
||||
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
|
||||
from .entity import GoogleGenerativeAILLMBaseEntity
|
||||
from .helpers import convert_to_wav
|
||||
|
||||
@@ -197,10 +191,7 @@ class GoogleGenerativeAITextToSpeechEntity(
|
||||
self, message: str, language: str, options: dict[str, Any]
|
||||
) -> TtsAudioType:
|
||||
"""Load tts audio file from the engine."""
|
||||
config = types.GenerateContentConfig()
|
||||
config.temperature = self.subentry.data.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
config = self.create_generate_content_config()
|
||||
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",
|
||||
"ssl_cipher_list": "SSL cipher list (Advanced)",
|
||||
"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 datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from enum import Enum
|
||||
import logging
|
||||
@@ -49,7 +49,6 @@ 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,
|
||||
@@ -340,7 +339,8 @@ class IntegrationSensor(RestoreSensor):
|
||||
else max_sub_interval
|
||||
)
|
||||
self._max_sub_interval_exceeded_callback: CALLBACK_TYPE = lambda *args: None
|
||||
self._last_integration_time: datetime = dt_util.utcnow()
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time: datetime = datetime.now(tz=UTC)
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
self._attr_suggested_display_precision = round_digits or 2
|
||||
|
||||
@@ -499,7 +499,8 @@ class IntegrationSensor(RestoreSensor):
|
||||
old_timestamp, new_timestamp, old_state, new_state
|
||||
)
|
||||
self._last_integration_trigger = _IntegrationTrigger.StateEvent
|
||||
self._last_integration_time = dt_util.utcnow()
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
finally:
|
||||
# When max_sub_interval exceeds without state change the source is assumed
|
||||
# constant with the last known state (new_state).
|
||||
@@ -607,7 +608,8 @@ class IntegrationSensor(RestoreSensor):
|
||||
self._update_integral(area)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._last_integration_time = dt_util.utcnow()
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
self._last_integration_time = datetime.now(tz=UTC)
|
||||
self._last_integration_trigger = _IntegrationTrigger.TimeElapsed
|
||||
|
||||
self._schedule_max_sub_interval_exceeded_if_state_is_numeric(
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"""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."""
|
||||
@@ -20,14 +16,3 @@ 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 TYPE_CHECKING, Any
|
||||
from typing import 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 = 2
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -49,39 +49,24 @@ class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
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:
|
||||
if entity_id := user_input.get(CONF_INFRARED_ENTITY_ID) or user_input.get(
|
||||
CONF_INFRARED_RECEIVER_ENTITY_ID
|
||||
):
|
||||
device_type = user_input[CONF_DEVICE_TYPE]
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# 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(title_entity_id)
|
||||
title_entity_name = (
|
||||
entry.name or entry.original_name or title_entity_id
|
||||
entry = ent_reg.async_get(entity_id)
|
||||
entity_name = (
|
||||
entry.name or entry.original_name or entity_id
|
||||
if entry
|
||||
else title_entity_id
|
||||
else entity_id
|
||||
)
|
||||
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
|
||||
title = f"LG {device_type_name} via {title_entity_name}"
|
||||
title = f"LG {device_type_name} via {entity_name}"
|
||||
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["opower==0.18.5"]
|
||||
"requirements": ["opower==0.18.4"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from renson_endura_delta.field_enum import (
|
||||
)
|
||||
from renson_endura_delta.renson import RensonVentilation
|
||||
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -24,11 +24,10 @@ 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, mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
identifiers={
|
||||
(DOMAIN, api.get_field_value(coordinator.data, MAC_ADDRESS.name))
|
||||
},
|
||||
manufacturer="Renson",
|
||||
model=api.get_field_value(coordinator.data, DEVICE_NAME_FIELD.name),
|
||||
name="Ventilation",
|
||||
@@ -42,4 +41,6 @@ class RensonEntity(CoordinatorEntity[RensonCoordinator]):
|
||||
|
||||
self.api = api
|
||||
|
||||
self._attr_unique_id = f"{mac}{name}"
|
||||
self._attr_unique_id = (
|
||||
api.get_field_value(coordinator.data, MAC_ADDRESS.name) + f"{name}"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Reolink integration for HomeAssistant."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from random import uniform
|
||||
from time import time
|
||||
@@ -26,7 +26,6 @@ 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,
|
||||
@@ -193,7 +192,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 = dt_util.utcnow()
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-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 timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum, auto
|
||||
|
||||
from sensoterra.probe import Probe, Sensor
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
@@ -166,5 +165,5 @@ class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity):
|
||||
return False
|
||||
|
||||
# Expire sensor if no update within the last few days.
|
||||
expiration = dt_util.utcnow() - timedelta(days=SENSOR_EXPIRATION_DAYS)
|
||||
expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) # pylint: disable=home-assistant-enforce-utcnow
|
||||
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=str(system.version),
|
||||
model=system.version,
|
||||
name=system.address,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
@@ -778,6 +779,9 @@ 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,7 +9,6 @@ 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
|
||||
@@ -64,7 +63,8 @@ def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time:
|
||||
hour -= 24
|
||||
minute = utc_minutes % 60
|
||||
try:
|
||||
utc = dt_util.utcnow().replace(
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
utc = datetime.now(UTC).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, entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -50,13 +50,6 @@ 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,12 +1,15 @@
|
||||
"""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,
|
||||
@@ -97,15 +100,6 @@ 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,
|
||||
@@ -241,8 +235,8 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
await self.async_run_script(
|
||||
self._action_scripts[CONF_ON_ACTION],
|
||||
run_variables={
|
||||
FanScriptVariable.PERCENTAGE: percentage,
|
||||
FanScriptVariable.PRESET_MODE: preset_mode,
|
||||
ATTR_PERCENTAGE: percentage,
|
||||
ATTR_PRESET_MODE: preset_mode,
|
||||
},
|
||||
context=self._context,
|
||||
)
|
||||
@@ -273,7 +267,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION):
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.PERCENTAGE: self._attr_percentage},
|
||||
run_variables={ATTR_PERCENTAGE: self._attr_percentage},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -290,7 +284,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION):
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.PRESET_MODE: self._attr_preset_mode},
|
||||
run_variables={ATTR_PRESET_MODE: self._attr_preset_mode},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -308,7 +302,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
) is not None:
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.OSCILLATING: self.oscillating},
|
||||
run_variables={ATTR_OSCILLATING: self.oscillating},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -324,7 +318,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
|
||||
) is not None:
|
||||
await self.async_run_script(
|
||||
script,
|
||||
run_variables={FanScriptVariable.DIRECTION: direction},
|
||||
run_variables={ATTR_DIRECTION: direction},
|
||||
context=self._context,
|
||||
)
|
||||
if CONF_DIRECTION not in self._templates:
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -160,7 +161,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
|
||||
if set_value := self._action_scripts.get(CONF_SET_VALUE):
|
||||
await self.async_run_script(
|
||||
set_value,
|
||||
run_variables={"value": value},
|
||||
run_variables={ATTR_VALUE: value},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ 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,
|
||||
@@ -46,7 +48,7 @@ SCRIPT_FIELDS = (CONF_SELECT_OPTION,)
|
||||
|
||||
SELECT_COMMON_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): cv.template,
|
||||
vol.Required(ATTR_OPTIONS): cv.template,
|
||||
vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
|
||||
vol.Optional(CONF_STATE): cv.template,
|
||||
}
|
||||
@@ -145,7 +147,7 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
|
||||
if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
|
||||
await self.async_run_script(
|
||||
select_option,
|
||||
run_variables={"option": option},
|
||||
run_variables={ATTR_OPTION: option},
|
||||
context=self._context,
|
||||
)
|
||||
|
||||
@@ -173,7 +175,7 @@ class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect):
|
||||
"""Select entity based on trigger data."""
|
||||
|
||||
domain = SELECT_DOMAIN
|
||||
extra_template_keys_complex = (CONF_OPTIONS,)
|
||||
extra_template_keys_complex = (ATTR_OPTIONS,)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -49,14 +50,13 @@ 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(CONF_LAST_RESET) is not None
|
||||
val.get(ATTR_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(CONF_LAST_RESET): cv.template,
|
||||
vol.Optional(ATTR_LAST_RESET): cv.template,
|
||||
}
|
||||
)
|
||||
.extend(SENSOR_COMMON_SCHEMA.schema)
|
||||
@@ -204,10 +204,10 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
|
||||
self._validate_state,
|
||||
)
|
||||
self.setup_template(
|
||||
CONF_LAST_RESET,
|
||||
ATTR_LAST_RESET,
|
||||
"_attr_last_reset",
|
||||
validate_datetime(
|
||||
self, CONF_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
|
||||
self, ATTR_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -388,7 +389,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
|
||||
|
||||
if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED):
|
||||
await self.async_run_script(
|
||||
script, run_variables={"fan_speed": fan_speed}, context=self._context
|
||||
script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Provides triggers for timers."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast, override
|
||||
|
||||
@@ -129,17 +128,13 @@ class TimeRemainingTrigger(Trigger):
|
||||
schedule_for_state(entity_id, to_state, event.context)
|
||||
|
||||
@callback
|
||||
def on_entities_update(
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> 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 = entity_states[entity_id]
|
||||
state = self._hass.states.get(entity_id)
|
||||
schedule_for_state(entity_id, state, state.context if state else None)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
|
||||
@@ -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 CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VilfoConfigEntry
|
||||
@@ -72,20 +72,12 @@ 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,11 +3,7 @@
|
||||
import logging
|
||||
|
||||
from aiowebdav2.client import Client
|
||||
from aiowebdav2.exceptions import (
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiowebdav2.exceptions import UnauthorizedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
@@ -39,11 +35,6 @@ 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 datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
|
||||
@@ -73,7 +72,8 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]):
|
||||
device_reporttime = device_state_resp.data.get("reportAt")
|
||||
if device_reporttime is not None:
|
||||
rpt_time_delta = (
|
||||
dt_util.utcnow().replace(tzinfo=None)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
datetime.now(tz=UTC).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,6 +97,10 @@ 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:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""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: 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).
|
||||
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
@@ -45,10 +45,10 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Cloud connection; DHCP discovery only triggers setup, no network address is persisted to update.
|
||||
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
|
||||
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: Auth failures go through the reauth flow; other errors are transient and retried by the coordinator.
|
||||
comment: No repair issues are raised yet.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
strict-typing: todo
|
||||
|
||||
+4
-22
@@ -760,29 +760,11 @@ 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 = 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
|
||||
)
|
||||
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³"
|
||||
_DEPRECATED_CONCENTRATION_PARTS_PER_CUBIC_METER = DeprecatedConstant(
|
||||
"p/m³", "p/m³", "2027.7"
|
||||
)
|
||||
|
||||
@@ -491,10 +491,7 @@ class _HistoryPrimingManager:
|
||||
tracking its entities, or the read could miss a change still queued in the
|
||||
recorder and compute too generous an anchor. A condition therefore never
|
||||
rides a flush that was already running when it arrived (the lobby); it waits
|
||||
that one out and joins the next, and re-attempts if the flush it rode was
|
||||
cancelled before completing. This mirrors `ReloadServiceHelper` minus its
|
||||
target de-duplication, which does not apply because each condition reads its
|
||||
own entities.
|
||||
that one out and joins the next.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
@@ -502,7 +499,6 @@ 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](
|
||||
@@ -524,30 +520,29 @@ class _HistoryPrimingManager:
|
||||
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; ride it.
|
||||
# A peer began a fresh flush after we cleared the lobby; it
|
||||
# covers us too, so wait for it and ride it.
|
||||
await self._flush_condition.wait()
|
||||
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.
|
||||
break
|
||||
|
||||
if not do_flush:
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -675,10 +670,7 @@ class EntityConditionBase(Condition):
|
||||
self._on_unload.append(unsub)
|
||||
|
||||
async def _async_on_entities_update(
|
||||
self,
|
||||
added: set[str],
|
||||
removed: set[str],
|
||||
_entity_states: Mapping[str, State | None],
|
||||
self, added: set[str], removed: set[str]
|
||||
) -> None:
|
||||
"""Handle changes to the tracked entity set.
|
||||
|
||||
|
||||
@@ -1557,13 +1557,13 @@ STATE_CONDITION_BASE_SCHEMA = {
|
||||
vol.Lower, vol.Any(ENTITY_MATCH_ALL, ENTITY_MATCH_ANY)
|
||||
),
|
||||
vol.Optional(CONF_ATTRIBUTE): str,
|
||||
vol.Optional(CONF_FOR): positive_time_period_template,
|
||||
}
|
||||
|
||||
STATE_CONDITION_STATE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**STATE_CONDITION_BASE_SCHEMA,
|
||||
vol.Required(CONF_STATE): vol.Any(str, [str]),
|
||||
vol.Optional(CONF_FOR): positive_time_period_template,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine, Mapping
|
||||
from collections.abc import Callable, Coroutine
|
||||
import dataclasses
|
||||
import logging
|
||||
from logging import Logger
|
||||
@@ -21,7 +21,6 @@ from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -44,19 +43,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class TargetStateChangedData:
|
||||
"""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.
|
||||
"""
|
||||
"""Data for state change events related to targets."""
|
||||
|
||||
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]]:
|
||||
@@ -370,8 +360,7 @@ class TargetStateChangeTracker(TargetEntityChangeTracker):
|
||||
action: Callable[[TargetStateChangedData], Any],
|
||||
entity_filter: Callable[[set[str]], set[str]],
|
||||
on_entities_update: Callable[
|
||||
[set[str], set[str], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
[set[str], set[str]], Coroutine[Any, Any, None] | None
|
||||
]
|
||||
| None = None,
|
||||
*,
|
||||
@@ -382,10 +371,7 @@ 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. 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.
|
||||
registry-driven changes.
|
||||
"""
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -397,7 +383,6 @@ 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]:
|
||||
@@ -433,49 +418,25 @@ 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, tracked_entity_states)
|
||||
result = self._on_entities_update(added, removed)
|
||||
|
||||
@callback
|
||||
def state_change_listener(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state change events."""
|
||||
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)
|
||||
)
|
||||
if event.data["entity_id"] in tracked_entities:
|
||||
self._action(TargetStateChangedData(event, tracked_entities))
|
||||
|
||||
_LOGGER.debug("Tracking state changes for entities: %s", tracked_entities)
|
||||
# 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
|
||||
if self._state_change_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:
|
||||
@@ -494,10 +455,7 @@ 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], Mapping[str, State | None]],
|
||||
Coroutine[Any, Any, None] | None,
|
||||
]
|
||||
on_entities_update: Callable[[set[str], set[str]], Coroutine[Any, Any, None] | None]
|
||||
| None = None,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
@@ -509,11 +467,9 @@ 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` 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.
|
||||
`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.
|
||||
"""
|
||||
target_selection = TargetSelection(target_selector_config)
|
||||
if not target_selection.has_any_target:
|
||||
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
@@ -75,7 +75,7 @@ from .automation import (
|
||||
get_relative_description_key,
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .event import async_call_later
|
||||
from .event import async_track_same_state
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .selector import (
|
||||
NumericThresholdMode,
|
||||
@@ -438,11 +438,7 @@ class EntityTriggerBase(Trigger):
|
||||
"""
|
||||
return state.state not in self._excluded_states
|
||||
|
||||
def count_matches(
|
||||
self,
|
||||
entity_ids: Iterable[str],
|
||||
states: Mapping[str, State | None] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
|
||||
"""Return (matches, included) for the entity set.
|
||||
|
||||
`matches` is the number of entities that pass `_should_include` AND
|
||||
@@ -451,19 +447,11 @@ 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:
|
||||
if states is not None:
|
||||
state = states[entity_id]
|
||||
else:
|
||||
state = self._hass.states.get(entity_id)
|
||||
state = self._hass.states.get(entity_id)
|
||||
if state is None or not self._should_include(state):
|
||||
continue
|
||||
included += 1
|
||||
@@ -471,60 +459,6 @@ 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
|
||||
@@ -532,32 +466,7 @@ class EntityTriggerBase(Trigger):
|
||||
"""Attach the trigger to an action runner."""
|
||||
|
||||
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
|
||||
# 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)()
|
||||
unsub_track_same: dict[str, Callable[[], None]] = {}
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
@@ -569,10 +478,35 @@ class EntityTriggerBase(Trigger):
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
if pending_timers:
|
||||
self._cancel_invalidated_timers(
|
||||
behavior, pending_timers, target_state_change_data
|
||||
)
|
||||
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 not from_state or not to_state:
|
||||
return
|
||||
@@ -592,15 +526,9 @@ class EntityTriggerBase(Trigger):
|
||||
):
|
||||
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_states,
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
if matches != included:
|
||||
return
|
||||
@@ -609,8 +537,7 @@ class EntityTriggerBase(Trigger):
|
||||
# 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_states,
|
||||
target_state_change_data.targeted_entity_ids
|
||||
)
|
||||
if matches != 1:
|
||||
return
|
||||
@@ -638,19 +565,18 @@ class EntityTriggerBase(Trigger):
|
||||
return
|
||||
|
||||
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
|
||||
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
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
unsub = await async_track_target_selector_state_change_event(
|
||||
@@ -658,7 +584,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -666,9 +591,9 @@ class EntityTriggerBase(Trigger):
|
||||
def async_remove() -> None:
|
||||
"""Remove state listeners async."""
|
||||
unsub()
|
||||
for cancel_timer in pending_timers.values():
|
||||
cancel_timer()
|
||||
pending_timers.clear()
|
||||
for async_remove in unsub_track_same.values():
|
||||
async_remove()
|
||||
unsub_track_same.clear()
|
||||
|
||||
return async_remove
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ cached-ipaddress==1.1.2
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==48.0.1
|
||||
cryptography==48.0.0
|
||||
dbus-fast==5.0.16
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.3
|
||||
|
||||
@@ -5,6 +5,9 @@ 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,
|
||||
@@ -14,7 +17,6 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -246,18 +248,18 @@ class CarbonMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e3
|
||||
),
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_CARBON_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -492,14 +494,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter):
|
||||
|
||||
UNIT_CLASS = "concentration"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
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,
|
||||
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,
|
||||
}
|
||||
VALID_UNITS = {
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -510,14 +512,14 @@ class NitrogenDioxideConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -527,13 +529,13 @@ class NitrogenMonoxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "nitrogen_monoxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_NITROGEN_MONOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -544,14 +546,14 @@ class OzoneConcentrationConverter(BaseUnitConverter):
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1e6,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_OZONE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -749,13 +751,13 @@ class SulphurDioxideConcentrationConverter(BaseUnitConverter):
|
||||
UNIT_CLASS = "sulphur_dioxide"
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1e9,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER: (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: (
|
||||
_SULPHUR_DIOXIDE_MOLAR_MASS / _AMBIENT_IDEAL_GAS_MOLAR_VOLUME * 1e6
|
||||
),
|
||||
}
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6180,16 +6180,6 @@ 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.1",
|
||||
"cryptography==48.0.0",
|
||||
"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.1
|
||||
cryptography==48.0.0
|
||||
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.9.1
|
||||
compit-inext-api==0.8.0
|
||||
|
||||
# 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.5
|
||||
opower==0.18.4
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.1.0
|
||||
|
||||
@@ -6,10 +6,6 @@ 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.
|
||||
# requirements_all.txt and requirements_test_all.txt.
|
||||
EXCLUDED_REQUIREMENTS_ALL = {
|
||||
"atenpdu", # depends on pysnmp which is not maintained at this time
|
||||
"avion",
|
||||
|
||||
@@ -216,15 +216,34 @@ 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 is filtered out."""
|
||||
"""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
|
||||
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") == "user"
|
||||
assert enter_api_key_result.get("errors") == {"api_token": "no_site"}
|
||||
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"
|
||||
|
||||
|
||||
async def test_single_site_rejoin(
|
||||
@@ -314,9 +333,13 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None:
|
||||
assert result.get("errors") == {"api_token": "unknown_error"}
|
||||
|
||||
|
||||
async def test_site_filtering(single_site_rejoin_api: Mock) -> None:
|
||||
"""Test that closed sites are filtered out and remaining sites are deduplicated."""
|
||||
async def test_site_deduplication(single_site_rejoin_api: Mock) -> None:
|
||||
"""Test site deduplication."""
|
||||
filtered = filter_sites(single_site_rejoin_api.get_sites())
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].nmi == "11111111111"
|
||||
assert filtered[0].status == SiteStatus.ACTIVE
|
||||
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
|
||||
)
|
||||
|
||||
@@ -255,88 +255,6 @@ 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,8 +695,6 @@ 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")
|
||||
|
||||
|
||||
@@ -859,6 +857,4 @@ 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")
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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,
|
||||
})
|
||||
# ---
|
||||
@@ -1,72 +0,0 @@
|
||||
"""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-3.1-flash-tts-preview',
|
||||
'model': 'gemini-2.5-flash-preview-tts',
|
||||
'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_31_flash_tts = Mock(
|
||||
model_25_flash_tts = Mock(
|
||||
supported_actions=["generateContent"],
|
||||
)
|
||||
model_31_flash_tts.name = "models/gemini-3.1-flash-tts-preview"
|
||||
model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts"
|
||||
|
||||
async def models_pager():
|
||||
yield model_25_flash
|
||||
yield model_20_flash
|
||||
yield model_15_flash
|
||||
yield model_15_pro
|
||||
yield model_31_flash_tts
|
||||
yield model_25_flash_tts
|
||||
|
||||
return models_pager()
|
||||
|
||||
@@ -278,6 +278,13 @@ 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,7 +14,11 @@ 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,
|
||||
@@ -181,6 +185,28 @@ 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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -228,5 +254,27 @@ 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,6 +39,7 @@ 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,11 +22,12 @@ from tests.components.infrared import (
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_title"),
|
||||
("config", "expected_title", "unique_id_entity_id"),
|
||||
[
|
||||
(
|
||||
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
|
||||
"LG TV via Test IR emitter",
|
||||
mock_infrared_emitter_entity_id,
|
||||
),
|
||||
(
|
||||
{
|
||||
@@ -34,10 +35,12 @@ 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,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -48,6 +51,7 @@ 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(
|
||||
@@ -65,7 +69,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 is None
|
||||
assert result["result"].unique_id == f"lg_ir_tv_{unique_id_entity_id}"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
@@ -86,33 +90,9 @@ async def test_user_flow_requires_emitter_or_receiver(
|
||||
assert result["errors"] == {"base": "missing_infrared_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",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
user_input: dict[str, str],
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test user flow aborts when entry is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
@@ -125,7 +105,10 @@ 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, **user_input},
|
||||
user_input={
|
||||
CONF_DEVICE_TYPE: LGDeviceType.TV,
|
||||
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -172,5 +155,6 @@ 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,24 +1,9 @@
|
||||
"""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(
|
||||
@@ -32,30 +17,3 @@ 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,7 +21,6 @@ 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
|
||||
|
||||
@@ -105,7 +104,8 @@ 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."""
|
||||
return dt_util.utcnow() + datetime.timedelta(days=1)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
return datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry_with_auth")
|
||||
|
||||
@@ -1622,7 +1622,8 @@ async def test_remove_stale_media(
|
||||
event_media = media_files[0]
|
||||
assert event_media.name.endswith(".mp4")
|
||||
|
||||
event_time1 = dt_util.utcnow() - datetime.timedelta(days=8)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time1 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=8)
|
||||
extra_media1 = (
|
||||
device_path / f"{int(event_time1.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1633,7 +1634,8 @@ async def test_remove_stale_media(
|
||||
)
|
||||
extra_media2.write_bytes(mp4.getvalue())
|
||||
# This event will not be garbage collected because it is too recent
|
||||
event_time3 = dt_util.utcnow() - datetime.timedelta(days=3)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
event_time3 = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=3)
|
||||
extra_media3 = (
|
||||
device_path / f"{int(event_time3.timestamp())}-camera_motion-test.mp4"
|
||||
)
|
||||
@@ -1643,7 +1645,8 @@ 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.
|
||||
point_in_time = dt_util.utcnow() + datetime.timedelta(days=1)
|
||||
# pylint: disable-next=home-assistant-enforce-utcnow
|
||||
point_in_time = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1)
|
||||
with freeze_time(point_in_time):
|
||||
async_fire_time_changed(hass, point_in_time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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,
|
||||
})
|
||||
# ---
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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 timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
@@ -47,7 +47,6 @@ 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,
|
||||
@@ -1206,7 +1205,7 @@ async def test_firmware_update_delay(
|
||||
call_count: int,
|
||||
) -> None:
|
||||
"""Test delay of firmware update check."""
|
||||
now = dt_util.utcnow()
|
||||
now = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
|
||||
check_delay = (
|
||||
now
|
||||
+ timedelta(seconds=seconds)
|
||||
|
||||
@@ -49,21 +49,6 @@ 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,9 +10,8 @@ import pytest
|
||||
|
||||
from homeassistant.components import sun
|
||||
from homeassistant.components.sun import entity
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, Platform
|
||||
from homeassistant.const import EVENT_STATE_CHANGED
|
||||
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
|
||||
|
||||
@@ -246,31 +245,3 @@ 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,6 +9,7 @@ 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,7 +51,6 @@ from .const import (
|
||||
UNIQUE_ID,
|
||||
VEHICLE_DATA,
|
||||
VEHICLE_DATA_ALT,
|
||||
VEHICLE_DATA_ASLEEP,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
@@ -194,23 +193,6 @@ 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,69 +0,0 @@
|
||||
# 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,
|
||||
})
|
||||
# ---
|
||||
@@ -1,59 +0,0 @@
|
||||
"""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,12 +2,7 @@
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiowebdav2.exceptions import (
|
||||
AccessDeniedError,
|
||||
ConnectionExceptionError,
|
||||
NoConnectionError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN
|
||||
@@ -33,29 +28,8 @@ 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,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# 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,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -1,29 +0,0 @@
|
||||
"""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 Callable, Mapping
|
||||
from collections.abc import Mapping
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
@@ -1462,6 +1462,19 @@ async def test_state_for_invalid_template(
|
||||
assert not test.async_check()
|
||||
|
||||
|
||||
def test_state_for_not_allowed_with_attribute() -> None:
|
||||
"""Test state condition rejects `for` combined with `attribute` is rejected."""
|
||||
config = {
|
||||
"condition": "state",
|
||||
"entity_id": "sensor.temperature",
|
||||
"attribute": "battery_level",
|
||||
"state": "100",
|
||||
"for": {"seconds": 5},
|
||||
}
|
||||
with pytest.raises(vol.Invalid, match=r"extra keys not allowed @ data\['for'\]"):
|
||||
cv.CONDITION_SCHEMA(config)
|
||||
|
||||
|
||||
async def test_state_unknown_attribute(hass: HomeAssistant) -> None:
|
||||
"""Test that state returns False on unknown attribute."""
|
||||
# Unknown attribute
|
||||
@@ -5908,19 +5921,6 @@ 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:
|
||||
@@ -5930,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 (it would stay at
|
||||
one flush total) instead of sharing a second, fresh one.
|
||||
test fails: the late arrivals would ride the first flush (one flush total)
|
||||
instead of sharing a second, fresh one.
|
||||
"""
|
||||
manager = _HistoryPrimingManager(hass)
|
||||
instance = get_instance(hass)
|
||||
@@ -5949,69 +5949,32 @@ 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))
|
||||
await _advance_until(lambda: len(flush_futures) == 1)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
assert 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 then performs a fresh flush and C2 rides it.
|
||||
# C0's flush completes; C1 now performs a fresh flush and C2 rides it.
|
||||
flush_futures[0].set_result(None)
|
||||
assert await c0 == "done"
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
|
||||
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
|
||||
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(
|
||||
@@ -6037,11 +6000,14 @@ 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))
|
||||
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.
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
if flush_futures:
|
||||
break
|
||||
# A second priming parks in the lobby, then its timeout cancels it.
|
||||
waiter = asyncio.create_task(manager.async_prime(_job))
|
||||
await asyncio.sleep(0)
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
waiter.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await waiter
|
||||
@@ -6050,7 +6016,9 @@ 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))
|
||||
await _advance_until(lambda: len(flush_futures) == 2)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
assert len(flush_futures) == 2
|
||||
flush_futures[1].set_result(None)
|
||||
assert await later == "done"
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test service helpers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,7 +16,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
@@ -806,20 +805,16 @@ 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], set[str]]] = []
|
||||
entity_updates: list[tuple[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],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
"""Track entity set changes."""
|
||||
entity_updates.append((added, removed, set(entity_states)))
|
||||
entity_updates.append((added, removed))
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
@@ -849,10 +844,9 @@ 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".
|
||||
# The states mapping covers the currently targeted entities.
|
||||
# Initial setup fires on_entities_update with all entities as "added"
|
||||
assert len(entity_updates) == 1
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set(), {entity_a.entity_id})
|
||||
assert entity_updates[-1] == ({entity_a.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# Add label to entity_b → added
|
||||
@@ -860,11 +854,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] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
assert entity_updates[-1] == ({entity_b.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_a → removed
|
||||
@@ -872,7 +862,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}, {entity_b.entity_id})
|
||||
assert entity_updates[-1] == (set(), {entity_a.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Remove label from entity_b → removed
|
||||
@@ -880,7 +870,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}, set())
|
||||
assert entity_updates[-1] == (set(), {entity_b.entity_id})
|
||||
entity_updates.clear()
|
||||
|
||||
# Re-add both labels at once — entity_a first, then entity_b
|
||||
@@ -890,12 +880,8 @@ 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(), {entity_a.entity_id})
|
||||
assert entity_updates[1] == (
|
||||
{entity_b.entity_id},
|
||||
set(),
|
||||
{entity_a.entity_id, entity_b.entity_id},
|
||||
)
|
||||
assert entity_updates[0] == ({entity_a.entity_id}, set())
|
||||
assert entity_updates[1] == ({entity_b.entity_id}, set())
|
||||
entity_updates.clear()
|
||||
|
||||
# After unsubscribing, no more callbacks
|
||||
@@ -917,11 +903,7 @@ 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],
|
||||
entity_states: Mapping[str, State | None],
|
||||
) -> None:
|
||||
async def on_entities_update(added: set[str], removed: set[str]) -> None:
|
||||
nonlocal cancelled
|
||||
started.set()
|
||||
try:
|
||||
|
||||
+47
-249
@@ -4735,11 +4735,15 @@ async def test_entity_trigger_duration_each_cancelled_when_entity_leaves_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test an each duration timer is cancelled when its entity is untargeted.
|
||||
"""Test an each duration timer when its entity is untargeted mid-wait.
|
||||
|
||||
A pending `for:` wait does not outlive the entity's membership of the
|
||||
A pending `for:` wait should not outlive the entity's membership of the
|
||||
target: when a registry change removes the entity from the target, the
|
||||
timer is cancelled and the trigger does not fire.
|
||||
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.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test Each Removal")
|
||||
@@ -4768,66 +4772,13 @@ 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 — should NOT fire
|
||||
# Advance past the original duration. Unwanted: the trigger fires for
|
||||
# the no-longer-targeted entity.
|
||||
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_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
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entry.entity_id
|
||||
|
||||
unsub()
|
||||
|
||||
@@ -4837,12 +4788,17 @@ async def test_entity_trigger_duration_all_survives_entity_leaving_target(
|
||||
freezer: FrozenDateTimeFactory,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a pending all timer ignores an entity removed from the target.
|
||||
"""Test a pending all timer when an entity is removed from the target.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
label_registry = lr.async_get(hass)
|
||||
label = label_registry.async_create("Test All Removal")
|
||||
@@ -4870,8 +4826,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 no longer gates the
|
||||
# all-match, so the timer keeps running.
|
||||
# B leaves the target mid-wait and turns off: it should no longer gate
|
||||
# the all-match.
|
||||
freezer.tick(datetime.timedelta(seconds=2))
|
||||
async_fire_time_changed(hass)
|
||||
entity_registry.async_update_entity(entry_b.entity_id, labels=set())
|
||||
@@ -4879,177 +4835,14 @@ 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()
|
||||
|
||||
# The remaining targeted entity stayed on for the duration — fires
|
||||
# Unwanted: the remaining targeted entity stayed on for the duration,
|
||||
# so the trigger should fire — but the no-longer-targeted entity
|
||||
# cancelled the timer.
|
||||
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()
|
||||
|
||||
|
||||
@@ -5058,15 +4851,19 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
) -> None:
|
||||
"""Test a synchronous bus listener reverting a state change.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
entity_a = "test.entity_a"
|
||||
entity_b = "test.entity_b"
|
||||
@@ -5084,6 +4881,9 @@ 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]] = []
|
||||
@@ -5092,19 +4892,17 @@ async def test_entity_trigger_first_nested_state_revert(
|
||||
)
|
||||
|
||||
# entity_a turns on and is synchronously reverted to off. The trigger
|
||||
# receives (off→on) then (on→off) in fire order and fires for the
|
||||
# on-event: entity_a was the first matching entity.
|
||||
# receives (on→off) then (off→on); the on-event counts no matches in
|
||||
# the live state machine and the trigger does not fire.
|
||||
hass.states.async_set(entity_a, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_a
|
||||
assert len(calls) == 0
|
||||
|
||||
# entity_a is off again, so entity_b is now the first matching entity
|
||||
# and the trigger fires for it.
|
||||
# entity_a is off, so entity_b is the first matching entity and fires.
|
||||
hass.states.async_set(entity_b, STATE_ON)
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 2
|
||||
assert calls[1]["entity_id"] == entity_b
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["entity_id"] == entity_b
|
||||
|
||||
unsub()
|
||||
unsub_revert()
|
||||
|
||||
@@ -6,6 +6,9 @@ from itertools import chain
|
||||
import pytest
|
||||
|
||||
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,
|
||||
@@ -14,7 +17,6 @@ from homeassistant.const import (
|
||||
UnitOfBloodGlucoseConcentration,
|
||||
UnitOfConductivity,
|
||||
UnitOfDataRate,
|
||||
UnitOfDensity,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -126,7 +128,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
18.016,
|
||||
),
|
||||
CarbonMonoxideConcentrationConverter: (
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1.16441,
|
||||
),
|
||||
@@ -162,22 +164,22 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
InformationConverter: (UnitOfInformation.BITS, UnitOfInformation.BYTES, 8),
|
||||
MassConverter: (UnitOfMass.STONES, UnitOfMass.KILOGRAMS, 0.157473),
|
||||
MassVolumeConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
1000,
|
||||
),
|
||||
NitrogenDioxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.912503,
|
||||
),
|
||||
NitrogenMonoxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
),
|
||||
OzoneConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.995417,
|
||||
),
|
||||
@@ -199,7 +201,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo
|
||||
1.609343,
|
||||
),
|
||||
SulphurDioxideConcentrationConverter: (
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
2.6633,
|
||||
),
|
||||
@@ -334,13 +336,13 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.16441,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
0.00116441,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# PPM to other units
|
||||
(
|
||||
@@ -353,51 +355,51 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1.16441,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1164.41,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# MICROGRAMS_PER_CUBIC_METER to other units
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
103056.5,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
103.0565,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
(
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# MILLIGRAMS_PER_CUBIC_METER to other units
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
103056.5,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
103.0565,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
120000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
],
|
||||
NitrogenDioxideConcentrationConverter: [
|
||||
@@ -405,11 +407,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.912503,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
62.744976,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -417,11 +419,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1912.503,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
0.062744976,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
@@ -443,11 +445,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.247389,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
96.200906,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -801,11 +803,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
1.995417,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
60.1378,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -813,11 +815,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
1995.417,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
0.0601378,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
@@ -1003,11 +1005,11 @@ _CONVERTED_VALUE: dict[
|
||||
1,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
2.6633,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
(
|
||||
120,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
45.056879,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
@@ -1058,23 +1060,23 @@ _CONVERTED_VALUE: dict[
|
||||
# 1000 µg/m³ = 1 mg/m³
|
||||
(
|
||||
1000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
1,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# 2 mg/m³ = 2000 µg/m³
|
||||
(
|
||||
2,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
2000,
|
||||
UnitOfDensity.MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
# 3 g/m³ = 3000 mg/m³
|
||||
(
|
||||
3,
|
||||
UnitOfDensity.GRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||
3000,
|
||||
UnitOfDensity.MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
),
|
||||
],
|
||||
VolumeConverter: [
|
||||
|
||||
Reference in New Issue
Block a user