Compare commits

...

33 Commits

Author SHA1 Message Date
Bram Kragten
c830320730 Bump version to 2026.4.0b4 2026-03-27 22:46:53 +01:00
Paul Bottein
336aa0f5df Update frontend to 20260325.2 (#166717) 2026-03-27 22:46:49 +01:00
Artur Pragacz
754291b34f Use legacy naming for entities (#166696) 2026-03-27 22:46:49 +01:00
Åke Strandberg
bbae0862b0 Add missing miele oven codes (#166690) 2026-03-27 22:46:48 +01:00
Åke Strandberg
6b7693b2fd Add missing miele program_id code (#166685) 2026-03-27 22:46:47 +01:00
Simone Chemelli
954926a05c Bump aioamazondevices to 13.3.1 (#166658) 2026-03-27 22:46:46 +01:00
Abílio Costa
71981f66ec Update idasen-ha to 2.6.5 (#166645) 2026-03-27 22:46:45 +01:00
Artur Pragacz
7f94f95ac9 Wait for device registry in entity registry loading (#166636) 2026-03-27 22:46:44 +01:00
Erik Montnemery
4ee3177c5d Add select conditions (#166612) 2026-03-27 22:46:43 +01:00
Erik Montnemery
9c1f9ca5c6 Add weather support to humidity conditions (#166599) 2026-03-27 22:46:42 +01:00
Franck Nijhof
cff4cf4d2c Bump version to 2026.4.0b3 2026-03-26 19:51:36 +00:00
Erik Montnemery
ee9d9781ee Add climate.is_hvac_mode condition (#166570) 2026-03-26 19:51:07 +00:00
Jamie Magee
1b972d4adc Remove tplink_lte integration (#166615) 2026-03-26 19:49:52 +00:00
Bram Kragten
72598479d5 Update frontend to 20260325.1 (#166614) 2026-03-26 19:49:50 +00:00
Erik Montnemery
02599a4a6e Add condition humidifier.is_mode (#166610) 2026-03-26 19:49:49 +00:00
Erik Montnemery
af9f351fce Restore support for number entities as limits in moisture conditions and triggers (#166608) 2026-03-26 19:49:47 +00:00
Erik Montnemery
ff79943776 Restore support for number entities as limits in battery conditions and triggers (#166607) 2026-03-26 19:49:46 +00:00
Erik Montnemery
e60048ef30 Add input_boolean support to switch conditions (#166602) 2026-03-26 19:49:45 +00:00
Erik Montnemery
24c0b22038 Add light.is_brightness condition (#166601) 2026-03-26 19:49:43 +00:00
Norbert Rittel
6f32a53742 Make siren conditions consistent with new wording (#166600) 2026-03-26 19:49:42 +00:00
Erik Montnemery
da9d1080d9 Remove number entity support from power triggers and conditions (#166597) 2026-03-26 19:49:41 +00:00
Erik Montnemery
2ea4d7913e Remove number entity support from moisture triggers and conditions (#166596) 2026-03-26 19:49:40 +00:00
Erik Montnemery
16999e3707 Remove number entity support from illuminance triggers and conditions (#166595) 2026-03-26 19:49:38 +00:00
Erik Montnemery
5c53b847dc Remove number entity support from humidity triggers and conditions (#166594) 2026-03-26 19:49:37 +00:00
Erik Montnemery
3afd763d16 Remove number entity support from battery triggers and conditions (#166593) 2026-03-26 19:49:35 +00:00
Abílio Costa
75a15ed24e Add todo to experimental triggers (#166591) 2026-03-26 19:49:34 +00:00
Ronald van der Meer
6d56597a2a Bump pooldose 0.9.0 (#166589) 2026-03-26 19:49:32 +00:00
Erik Montnemery
5872222213 Remove class NumericalDomainSpec (#166588) 2026-03-26 19:49:31 +00:00
reneboer
bd5c73fd7b Bump renault-api to 0.5.7 (#166586) 2026-03-26 19:49:30 +00:00
hanwg
d8a32dcf69 Add missing translations for Telegram bot (#166581)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-03-26 19:49:29 +00:00
Devin Slick
87cd90ab5d Bump lojack-api to 0.7.2 (#166560)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 19:45:06 +00:00
Tom
cb5b0c5b5e Verify Proxmox permissions when creating snapshots (#166547) 2026-03-26 19:45:04 +00:00
John Meyers
2fa16101f4 Update rainmachine solar radiation to reflect it is per day, not per … (#166040) 2026-03-26 19:45:03 +00:00
111 changed files with 1804 additions and 1857 deletions

View File

@@ -468,6 +468,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
translation.async_setup(hass)
recovery = hass.config.recovery_mode
device_registry.async_setup(hass)
try:
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),

View File

@@ -13,7 +13,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition,
@@ -59,18 +59,18 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_smoke_cleared": _make_cleared_condition(BinarySensorDeviceClass.SMOKE),
# Numerical sensor conditions with unit conversion
"is_co_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"is_ozone_value": make_entity_numerical_condition_with_unit(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"is_voc_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
@@ -79,7 +79,7 @@ CONDITIONS: dict[str, type[Condition]] = {
),
"is_voc_ratio_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
@@ -87,59 +87,43 @@ CONDITIONS: dict[str, type[Condition]] = {
UnitlessRatioConverter,
),
"is_no_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"is_no2_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"is_so2_value": make_entity_numerical_condition_with_unit(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.SULPHUR_DIOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor conditions without unit conversion (single-unit device classes)
"is_co2_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"is_pm1_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm25_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm4_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_pm10_value": make_entity_numerical_condition(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"is_n2o_value": make_entity_numerical_condition(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROUS_OXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}

View File

@@ -13,7 +13,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
Trigger,
@@ -64,28 +64,28 @@ TRIGGERS: dict[str, type[Trigger]] = {
"smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE),
# Numerical sensor triggers with unit conversion
"co_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
@@ -94,7 +94,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
),
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
@@ -103,7 +103,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
),
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
@@ -112,7 +112,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
),
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
@@ -120,114 +120,82 @@ TRIGGERS: dict[str, type[Trigger]] = {
UnitlessRatioConverter,
),
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.SULPHUR_DIOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.SULPHUR_DIOXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor triggers without unit conversion (single-unit device classes)
"co2_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"pm1_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_changed": make_entity_numerical_state_changed_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROUS_OXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROUS_OXIDE
)
},
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROUS_OXIDE)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.3.0"]
"requirements": ["aioamazondevices==13.3.1"]
}

View File

@@ -142,6 +142,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"person",
"power",
"schedule",
"select",
"siren",
"switch",
"temperature",
@@ -186,6 +187,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"switch",
"temperature",
"text",
"todo",
"update",
"vacuum",
"water_heater",

View File

@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
@@ -27,7 +26,6 @@ BATTERY_CHARGING_DOMAIN_SPECS = {
}
BATTERY_PERCENTAGE_DOMAIN_SPECS = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY),
}
CONDITIONS: dict[str, type[Condition]] = {

View File

@@ -53,8 +53,6 @@ is_level:
entity:
- domain: sensor
device_class: battery
- domain: number
device_class: battery
fields:
behavior: *condition_behavior
threshold:

View File

@@ -6,11 +6,10 @@ from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
@@ -28,9 +27,8 @@ BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.BATTERY),
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.BATTERY),
BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
}
TRIGGERS: dict[str, type[Trigger]] = {

View File

@@ -38,8 +38,6 @@
entity:
- domain: sensor
device_class: battery
- domain: number
device_class: battery
low:
fields:

View File

@@ -1,10 +1,18 @@
"""Provides conditions for climates."""
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
@@ -13,12 +21,42 @@ from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
_HVAC_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
),
},
}
)
class ClimateHVACModeCondition(EntityConditionBase):
"""Condition for climate HVAC mode."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = _HVAC_MODE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the HVAC mode condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._hvac_modes: set[str] = set(config.options[CONF_HVAC_MODE])
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches any of the expected HVAC modes."""
return entity_state.state in self._hvac_modes
class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
"""Mixin for climate target temperature conditions with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, entity_state: State) -> str | None:
@@ -28,6 +66,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
@@ -50,7 +89,7 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,

View File

@@ -45,6 +45,21 @@ is_cooling: *condition_common
is_drying: *condition_common
is_heating: *condition_common
is_hvac_mode:
target: *condition_climate_target
fields:
behavior: *condition_behavior
hvac_mode:
context:
filter_target: target
required: true
selector:
state:
hide_states:
- unavailable
- unknown
multiple: true
target_humidity:
target: *condition_climate_target
fields:

View File

@@ -9,6 +9,9 @@
"is_heating": {
"condition": "mdi:fire"
},
"is_hvac_mode": {
"condition": "mdi:thermostat"
},
"is_off": {
"condition": "mdi:power-off"
},

View File

@@ -41,6 +41,20 @@
},
"name": "Climate-control device is heating"
},
"is_hvac_mode": {
"description": "Tests if one or more climate-control devices are set to a specific HVAC mode.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"hvac_mode": {
"description": "The HVAC modes to test for.",
"name": "Modes"
}
},
"name": "Climate-control device HVAC mode"
},
"is_off": {
"description": "Tests if one or more climate-control devices are off.",
"fields": {

View File

@@ -5,7 +5,7 @@ import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerWithUnitBase,
@@ -52,7 +52,7 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
"""Mixin for climate target temperature triggers with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, state: State) -> str | None:
@@ -84,11 +84,11 @@ TRIGGERS: dict[str, type[Trigger]] = {
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,

View File

@@ -1,5 +1,7 @@
"""Provides conditions for covers."""
from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.condition import Condition, EntityConditionBase
@@ -8,9 +10,11 @@ from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec
class CoverConditionBase(EntityConditionBase[CoverDomainSpec]):
class CoverConditionBase(EntityConditionBase):
"""Base condition for cover state checks."""
_domain_specs: Mapping[str, CoverDomainSpec]
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected cover state."""
domain_spec = self._domain_specs[entity_state.domain]

View File

@@ -1,5 +1,7 @@
"""Provides triggers for covers."""
from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
@@ -8,9 +10,11 @@ from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
class CoverTriggerBase(EntityTriggerBase):
"""Base trigger for cover state changes."""
_domain_specs: Mapping[str, CoverDomainSpec]
def _get_value(self, state: State) -> str | bool | None:
"""Extract the relevant value from state based on domain spec."""
domain_spec = self._domain_specs[state.domain]

View File

@@ -51,7 +51,6 @@ def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
return a.name not in (
"_cache",
"compat_aliases",
"compat_name",
"original_name_unprefixed",
)

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260325.0"]
"requirements": ["home-assistant-frontend==20260325.2"]
}

View File

@@ -1,15 +1,73 @@
"""Provides conditions for humidifiers."""
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
from .const import (
ATTR_ACTION,
ATTR_HUMIDITY,
DOMAIN,
HumidifierAction,
HumidifierEntityFeature,
)
CONF_MODE = "mode"
IS_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
},
}
)
def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Test if an entity supports the specified features."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
return False
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)}
_schema = IS_MODE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the mode condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._states = set(config.options[CONF_MODE])
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if _supports_feature(self._hass, entity_id, HumidifierEntityFeature.MODES)
}
from .const import ATTR_ACTION, ATTR_HUMIDITY, DOMAIN, HumidifierAction
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
@@ -20,8 +78,9 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_humidifying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
}

View File

@@ -32,6 +32,19 @@ is_on: *condition_common
is_drying: *condition_common
is_humidifying: *condition_common
is_mode:
target: *condition_humidifier_target
fields:
behavior: *condition_behavior
mode:
context:
filter_target: target
required: true
selector:
state:
attribute: available_modes
multiple: true
is_target_humidity:
target: *condition_humidifier_target
fields:

View File

@@ -6,6 +6,9 @@
"is_humidifying": {
"condition": "mdi:arrow-up-bold"
},
"is_mode": {
"condition": "mdi:air-humidifier"
},
"is_off": {
"condition": "mdi:air-humidifier-off"
},

View File

@@ -28,6 +28,20 @@
},
"name": "Humidifier is humidifying"
},
"is_mode": {
"description": "Tests if one or more humidifiers are set to a specific mode.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"mode": {
"description": "The operation modes to check for.",
"name": "Mode"
}
},
"name": "Humidifier is in mode"
},
"is_off": {
"description": "Tests if one or more humidifiers are off.",
"fields": {

View File

@@ -10,22 +10,27 @@ from homeassistant.components.humidifier import (
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
DOMAIN as HUMIDIFIER_DOMAIN,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: NumericalDomainSpec(
CLIMATE_DOMAIN: DomainSpec(
value_source=CLIMATE_ATTR_CURRENT_HUMIDITY,
),
HUMIDIFIER_DOMAIN: NumericalDomainSpec(
HUMIDIFIER_DOMAIN: DomainSpec(
value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
),
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.HUMIDITY),
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.HUMIDITY),
WEATHER_DOMAIN: DomainSpec(
value_source=ATTR_WEATHER_HUMIDITY,
),
}
CONDITIONS: dict[str, type[Condition]] = {

View File

@@ -17,10 +17,9 @@ is_value:
entity:
- domain: sensor
device_class: humidity
- domain: number
device_class: humidity
- domain: climate
- domain: humidifier
- domain: weather
fields:
behavior:
required: true

View File

@@ -16,24 +16,24 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
CLIMATE_DOMAIN: NumericalDomainSpec(
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
CLIMATE_DOMAIN: DomainSpec(
value_source=CLIMATE_ATTR_CURRENT_HUMIDITY,
),
HUMIDIFIER_DOMAIN: NumericalDomainSpec(
HUMIDIFIER_DOMAIN: DomainSpec(
value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
),
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.HUMIDITY,
),
WEATHER_DOMAIN: NumericalDomainSpec(
WEATHER_DOMAIN: DomainSpec(
value_source=ATTR_WEATHER_HUMIDITY,
),
}

View File

@@ -13,5 +13,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["idasen-ha==2.6.4"]
"requirements": ["idasen-ha==2.6.5"]
}

View File

@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
@@ -22,7 +21,6 @@ ILLUMINANCE_DETECTED_DOMAIN_SPECS = {
}
ILLUMINANCE_VALUE_DOMAIN_SPECS = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE),
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.ILLUMINANCE),
}
CONDITIONS: dict[str, type[Condition]] = {

View File

@@ -23,8 +23,6 @@ is_value:
entity:
- domain: sensor
device_class: illuminance
- domain: number
device_class: illuminance
fields:
behavior: *condition_behavior
threshold:

View File

@@ -6,11 +6,10 @@ from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
@@ -18,9 +17,8 @@ from homeassistant.helpers.trigger import (
make_entity_target_state_trigger,
)
ILLUMINANCE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.ILLUMINANCE),
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.ILLUMINANCE),
ILLUMINANCE_DOMAIN_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.ILLUMINANCE),
}
TRIGGERS: dict[str, type[Trigger]] = {

View File

@@ -29,8 +29,6 @@
.trigger_numerical_target: &trigger_numerical_target
entity:
- domain: number
device_class: illuminance
- domain: sensor
device_class: illuminance

View File

@@ -1,12 +1,43 @@
"""Provides conditions for lights."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from typing import Any
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityNumericalConditionBase,
make_entity_state_condition,
)
from . import ATTR_BRIGHTNESS
from .const import DOMAIN
BRIGHTNESS_DOMAIN_SPECS = {
DOMAIN: DomainSpec(value_source=ATTR_BRIGHTNESS),
}
class BrightnessCondition(EntityNumericalConditionBase):
"""Condition for light brightness with uint8 to percentage conversion."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, entity_state: State) -> Any:
"""Get the brightness value converted from uint8 (0-255) to percentage (0-100)."""
raw = super()._get_tracked_value(entity_state)
if raw is None:
return None
try:
return (float(raw) / 255.0) * 100.0
except TypeError, ValueError:
return None
CONDITIONS: dict[str, type[Condition]] = {
"is_brightness": BrightnessCondition,
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}

View File

@@ -1,9 +1,9 @@
.condition_common: &condition_common
target:
target: &condition_light_target
entity:
domain: light
fields:
behavior:
behavior: &condition_behavior
required: true
default: any
selector:
@@ -13,5 +13,31 @@
- all
- any
.brightness_threshold_entity: &brightness_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
.brightness_threshold_number: &brightness_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
is_off: *condition_common
is_on: *condition_common
is_brightness:
target: *condition_light_target
fields:
behavior: *condition_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *brightness_threshold_entity
mode: is
number: *brightness_threshold_number

View File

@@ -1,5 +1,8 @@
{
"conditions": {
"is_brightness": {
"condition": "mdi:lightbulb-on-50"
},
"is_off": {
"condition": "mdi:lightbulb-off"
},

View File

@@ -2,6 +2,8 @@
"common": {
"condition_behavior_description": "How the state should match on the targeted lights.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.",
"field_brightness_name": "Brightness value",
"field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.",
@@ -42,6 +44,20 @@
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_brightness": {
"description": "Tests the brightness of one or more lights.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
"name": "[%key:component::light::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::light::common::condition_threshold_description%]",
"name": "[%key:component::light::common::condition_threshold_name%]"
}
},
"name": "Light brightness"
},
"is_off": {
"description": "Tests if one or more lights are off.",
"fields": {

View File

@@ -1,40 +1,54 @@
"""Provides triggers for lights."""
from typing import Any
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
from . import ATTR_BRIGHTNESS
from .const import DOMAIN
def _convert_uint8_to_percentage(value: Any) -> float:
"""Convert a uint8 value (0-255) to a percentage (0-100)."""
return (float(value) / 255.0) * 100.0
BRIGHTNESS_DOMAIN_SPECS = {
DOMAIN: NumericalDomainSpec(
value_source=ATTR_BRIGHTNESS,
value_converter=_convert_uint8_to_percentage,
),
DOMAIN: DomainSpec(value_source=ATTR_BRIGHTNESS),
}
class BrightnessTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for brightness triggers."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
_valid_unit = "%"
def _get_tracked_value(self, state: State) -> float | None:
"""Get tracked brightness as a percentage."""
value = super()._get_tracked_value(state)
if value is None:
return None
# Convert uint8 value (0-255) to a percentage (0-100)
return (value / 255.0) * 100.0
class BrightnessChangedTrigger(
EntityNumericalStateChangedTriggerBase, BrightnessTriggerMixin
):
"""Trigger for light brightness changes."""
class BrightnessCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerBase, BrightnessTriggerMixin
):
"""Trigger for light brightness crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"brightness_changed": make_entity_numerical_state_changed_trigger(
BRIGHTNESS_DOMAIN_SPECS, valid_unit="%"
),
"brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BRIGHTNESS_DOMAIN_SPECS, valid_unit="%"
),
"brightness_changed": BrightnessChangedTrigger,
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["lojack_api"],
"quality_scale": "silver",
"requirements": ["lojack-api==0.7.1"]
"requirements": ["lojack-api==0.7.2"]
}

View File

@@ -270,6 +270,7 @@ class ProgramPhaseOven(MieleEnum, missing_to_none=True):
process_finished = 3078
searing = 3080
roasting = 3081
cooling_down = 3083
energy_save = 3084
pre_heating = 3099
@@ -452,6 +453,7 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
proofing = 27, 10057
sportswear = 29, 10052
automatic_plus = 31
table_linen = 33
outerwear = 37
pillows = 39
cool_air = 45 # washer-dryer
@@ -586,6 +588,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
microwave_fan_grill = 23
conventional_heat = 24
top_heat = 25
booster = 27
fan_grill = 29
bottom_heat = 31
moisture_plus_auto_roast = 35, 48
@@ -594,6 +597,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
moisture_plus_conventional_heat = 51, 76
popcorn = 53
quick_microwave = 54
airfry = 95
custom_program_1 = 97
custom_program_2 = 98
custom_program_3 = 99

View File

@@ -273,6 +273,7 @@
"program_id": {
"name": "Program",
"state": {
"airfry": "AirFry",
"almond_macaroons_1_tray": "Almond macaroons (1 tray)",
"almond_macaroons_2_trays": "Almond macaroons (2 trays)",
"amaranth": "Amaranth",
@@ -334,6 +335,7 @@
"blanching": "Blanching",
"blueberry_muffins": "Blueberry muffins",
"bologna_sausage": "Bologna sausage",
"booster": "Booster",
"bottling": "Bottling",
"bottling_hard": "Bottling (hard)",
"bottling_medium": "Bottling (medium)",
@@ -881,6 +883,7 @@
"swiss_roll": "Swiss roll",
"swiss_toffee_cream_100_ml": "Swiss toffee cream (100 ml)",
"swiss_toffee_cream_150_ml": "Swiss toffee cream (150 ml)",
"table_linen": "Table linen",
"tagliatelli_fresh": "Tagliatelli (fresh)",
"tall_items": "Tall items",
"tart_flambe": "Tart flambè",

View File

@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
@@ -25,7 +24,6 @@ _MOISTURE_BINARY_DOMAIN_SPECS = {
_MOISTURE_NUMERICAL_DOMAIN_SPECS = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE),
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.MOISTURE),
}
CONDITIONS: dict[str, type[Condition]] = {

View File

@@ -37,8 +37,6 @@ is_value:
entity:
- domain: sensor
device_class: moisture
- domain: number
device_class: moisture
fields:
behavior: *condition_behavior
threshold:

View File

@@ -6,11 +6,10 @@ from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
@@ -22,9 +21,8 @@ MOISTURE_BINARY_DOMAIN_SPECS: dict[str, DomainSpec] = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOISTURE),
}
MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.MOISTURE),
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.MOISTURE),
MOISTURE_NUMERICAL_DOMAIN_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.MOISTURE),
}

View File

@@ -31,8 +31,6 @@
.trigger_numerical_target: &trigger_numerical_target
entity:
- domain: number
device_class: moisture
- domain: sensor
device_class: moisture

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["python-pooldose==0.8.6"]
"requirements": ["python-pooldose==0.9.0"]
}

View File

@@ -2,11 +2,10 @@
from __future__ import annotations
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition_with_unit,
@@ -14,8 +13,7 @@ from homeassistant.helpers.condition import (
from homeassistant.util.unit_conversion import PowerConverter
POWER_DOMAIN_SPECS = {
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER),
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER),
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER),
}

View File

@@ -28,8 +28,6 @@
is_value:
target:
entity:
- domain: number
device_class: power
- domain: sensor
device_class: power
fields:

View File

@@ -2,11 +2,10 @@
from __future__ import annotations
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_with_unit_trigger,
@@ -14,9 +13,8 @@ from homeassistant.helpers.trigger import (
)
from homeassistant.util.unit_conversion import PowerConverter
POWER_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER),
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER),
POWER_DOMAIN_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.POWER),
}

View File

@@ -29,8 +29,6 @@
.trigger_target: &trigger_target
entity:
- domain: number
device_class: power
- domain: sensor
device_class: power

View File

@@ -23,7 +23,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .const import DOMAIN, ProxmoxPermission
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
from .helpers import is_granted
@@ -34,6 +34,8 @@ class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox node button description."""
press_action: Callable[[ProxmoxCoordinator, str], None]
permission: ProxmoxPermission = ProxmoxPermission.POWER
permission_raise: str = "no_permission_node_power"
@dataclass(frozen=True, kw_only=True)
@@ -41,6 +43,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox VM button description."""
press_action: Callable[[ProxmoxCoordinator, str, int], None]
permission: ProxmoxPermission = ProxmoxPermission.POWER
permission_raise: str = "no_permission_vm_lxc_power"
@dataclass(frozen=True, kw_only=True)
@@ -48,6 +52,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox container button description."""
press_action: Callable[[ProxmoxCoordinator, str, int], None]
permission: ProxmoxPermission = ProxmoxPermission.POWER
permission_raise: str = "no_permission_vm_lxc_power"
NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
@@ -156,6 +162,8 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
)
)
),
permission=ProxmoxPermission.SNAPSHOT,
permission_raise="no_permission_snapshot",
entity_category=EntityCategory.CONFIG,
),
)
@@ -199,6 +207,8 @@ CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
)
)
),
permission=ProxmoxPermission.SNAPSHOT,
permission_raise="no_permission_snapshot",
entity_category=EntityCategory.CONFIG,
),
)
@@ -315,10 +325,15 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
async def _async_press_call(self) -> None:
"""Execute the node button action via executor."""
node_id = self._node_data.node["node"]
if not is_granted(self.coordinator.permissions, p_type="nodes", p_id=node_id):
if not is_granted(
self.coordinator.permissions,
p_type="nodes",
p_id=node_id,
permission=self.entity_description.permission,
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_permission_node_power",
translation_key=self.entity_description.permission_raise,
)
await self.hass.async_add_executor_job(
self.entity_description.press_action,
@@ -335,10 +350,15 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
async def _async_press_call(self) -> None:
"""Execute the VM button action via executor."""
vmid = self.vm_data["vmid"]
if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid):
if not is_granted(
self.coordinator.permissions,
p_type="vms",
p_id=vmid,
permission=self.entity_description.permission,
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_permission_vm_lxc_power",
translation_key=self.entity_description.permission_raise,
)
await self.hass.async_add_executor_job(
self.entity_description.press_action,
@@ -357,10 +377,15 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
"""Execute the container button action via executor."""
vmid = self.container_data["vmid"]
# Container power actions fall under vms
if not is_granted(self.coordinator.permissions, p_type="vms", p_id=vmid):
if not is_granted(
self.coordinator.permissions,
p_type="vms",
p_id=vmid,
permission=self.entity_description.permission,
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_permission_vm_lxc_power",
translation_key=self.entity_description.permission_raise,
)
await self.hass.async_add_executor_job(
self.entity_description.press_action,

View File

@@ -1,5 +1,7 @@
"""Constants for ProxmoxVE."""
from enum import StrEnum
DOMAIN = "proxmoxve"
CONF_AUTH_METHOD = "auth_method"
CONF_REALM = "realm"
@@ -33,4 +35,9 @@ TYPE_VM = 0
TYPE_CONTAINER = 1
UPDATE_INTERVAL = 60
PERM_POWER = "VM.PowerMgmt"
class ProxmoxPermission(StrEnum):
"""Proxmox permissions."""
POWER = "VM.PowerMgmt"
SNAPSHOT = "VM.Snapshot"

View File

@@ -1,13 +1,13 @@
"""Helpers for Proxmox VE."""
from .const import PERM_POWER
from .const import ProxmoxPermission
def is_granted(
permissions: dict[str, dict[str, int]],
p_type: str = "vms",
p_id: str | int | None = None, # can be str for nodes
permission: str = PERM_POWER,
permission: ProxmoxPermission = ProxmoxPermission.POWER,
) -> bool:
"""Validate user permissions for the given type and permission."""
paths = [f"/{p_type}/{p_id}", f"/{p_type}", "/"]

View File

@@ -315,6 +315,9 @@
"no_permission_node_power": {
"message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again."
},
"no_permission_snapshot": {
"message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again."
},
"no_permission_vm_lxc_power": {
"message": "The configured Proxmox VE user does not have permission to manage the power state of VMs and containers. Please grant the user the 'VM.PowerMgmt' permission and try again."
},

View File

@@ -102,7 +102,7 @@ CV_WX_DATA_VALID_TEMP_RANGE = vol.All(vol.Coerce(float), vol.Range(min=-40.0, ma
CV_WX_DATA_VALID_RAIN_RANGE = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1000.0))
CV_WX_DATA_VALID_WIND_SPEED = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=65.0))
CV_WX_DATA_VALID_PRESSURE = vol.All(vol.Coerce(float), vol.Range(min=60.0, max=110.0))
CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=5.0))
CV_WX_DATA_VALID_SOLARRAD = vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0))
SERVICE_NAME_PAUSE_WATERING = "pause_watering"
SERVICE_NAME_PUSH_FLOW_METER_DATA = "push_flow_meter_data"

View File

@@ -131,9 +131,9 @@ push_weather_data:
selector:
number:
min: 0
max: 5
max: 100
step: 0.1
unit_of_measurement: "MJ/m²/h"
unit_of_measurement: "MJ/m²/d"
et:
selector:
number:

View File

@@ -175,7 +175,7 @@
"name": "Measured rainfall"
},
"solarrad": {
"description": "Current solar radiation (MJ/m²/h).",
"description": "Daily solar radiation (MJ/m²/d).",
"name": "Solar radiation"
},
"temperature": {

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.6"]
"requirements": ["renault-api==0.5.7"]
}

View File

@@ -0,0 +1,55 @@
"""Provides conditions for selects."""
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.input_select import DOMAIN as INPUT_SELECT_DOMAIN
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityStateConditionBase,
)
from .const import CONF_OPTION, DOMAIN
IS_OPTION_SELECTED_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_OPTION): vol.All(
cv.ensure_list, vol.Length(min=1), [str]
),
},
}
)
SELECT_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_SELECT_DOMAIN: DomainSpec()}
class IsOptionSelectedCondition(EntityStateConditionBase):
"""Condition for select option."""
_domain_specs = SELECT_DOMAIN_SPECS
_schema = IS_OPTION_SELECTED_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the option selected condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._states = set(config.options[CONF_OPTION])
CONDITIONS: dict[str, type[Condition]] = {
"is_option_selected": IsOptionSelectedCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the select conditions."""
return CONDITIONS

View File

@@ -0,0 +1,26 @@
is_option_selected:
target:
entity:
- domain: select
- domain: input_select
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
option:
context:
filter_target: target
required: true
selector:
state:
attribute: options
hide_states:
- unavailable
- unknown
multiple: true

View File

@@ -1,4 +1,9 @@
{
"conditions": {
"is_option_selected": {
"condition": "mdi:format-list-bulleted"
}
},
"entity_component": {
"_": {
"default": "mdi:format-list-bulleted"

View File

@@ -1,4 +1,20 @@
{
"conditions": {
"is_option_selected": {
"description": "Tests if one or more dropdowns have a specific option selected.",
"fields": {
"behavior": {
"description": "Whether the condition should pass when any or all targeted entities match.",
"name": "Behavior"
},
"option": {
"description": "The options to check for.",
"name": "Option"
}
},
"name": "Option is selected"
}
},
"device_automation": {
"action_type": {
"select_first": "Change {entity_name} to first option",
@@ -36,6 +52,14 @@
"message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}."
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
}
},
"services": {
"select_first": {
"description": "Selects the first option of a select.",

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::siren::common::condition_behavior_name%]"
}
},
"name": "If a siren is off"
"name": "Siren is off"
},
"is_on": {
"description": "Tests if one or more sirens are on.",
@@ -24,7 +24,7 @@
"name": "[%key:component::siren::common::condition_behavior_name%]"
}
},
"name": "If a siren is on"
"name": "Siren is on"
}
},
"entity_component": {

View File

@@ -1,14 +1,18 @@
"""Provides conditions for switches."""
from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
SWITCH_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_BOOLEAN_DOMAIN: DomainSpec()}
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
"is_off": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_OFF),
"is_on": make_entity_state_condition(SWITCH_DOMAIN_SPECS, STATE_ON),
}

View File

@@ -1,7 +1,8 @@
.condition_common: &condition_common
target:
entity:
domain: switch
- domain: switch
- domain: input_boolean
fields:
behavior:
required: true

View File

@@ -188,7 +188,7 @@ class OptionsFlowHandler(OptionsFlow):
)
class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
class TelegramBotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Telegram."""
VERSION = 1

View File

@@ -225,9 +225,9 @@ send_media_group:
multiple: true
label_field: url
description_field: caption
translation_key: "media"
fields:
media_type:
label: Media type
selector:
select:
options:
@@ -237,20 +237,16 @@ send_media_group:
- "video"
translation_key: "media_type"
caption:
label: Caption
selector:
text:
url:
label: URL
selector:
text:
type: url
verify_ssl:
label: Verify SSL
selector:
boolean:
authentication:
label: Authentication
selector:
select:
options:
@@ -259,16 +255,13 @@ send_media_group:
- "bearer_token"
translation_key: "authentication"
username:
label: Username
selector:
text:
password:
label: Password
selector:
text:
type: password
file:
label: File
selector:
text:
parse_mode:

View File

@@ -279,6 +279,18 @@
"upload_voice": "Uploading voice"
}
},
"media": {
"fields": {
"authentication": "Authentication",
"caption": "Caption",
"file": "File",
"media_type": "Media type",
"password": "Password",
"url": "URL",
"username": "Username",
"verify_ssl": "Verify SSL"
}
},
"media_type": {
"options": {
"animation": "Animation",

View File

@@ -18,7 +18,7 @@ from homeassistant.components.weather import (
)
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityNumericalConditionWithUnitBase,
@@ -26,16 +26,16 @@ from homeassistant.helpers.condition import (
from homeassistant.util.unit_conversion import TemperatureConverter
TEMPERATURE_DOMAIN_SPECS = {
CLIMATE_DOMAIN: NumericalDomainSpec(
CLIMATE_DOMAIN: DomainSpec(
value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE,
),
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.TEMPERATURE,
),
WATER_HEATER_DOMAIN: NumericalDomainSpec(
WATER_HEATER_DOMAIN: DomainSpec(
value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
),
WEATHER_DOMAIN: NumericalDomainSpec(
WEATHER_DOMAIN: DomainSpec(
value_source=ATTR_WEATHER_TEMPERATURE,
),
}

View File

@@ -18,7 +18,7 @@ from homeassistant.components.weather import (
)
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
@@ -28,16 +28,14 @@ from homeassistant.helpers.trigger import (
from homeassistant.util.unit_conversion import TemperatureConverter
TEMPERATURE_DOMAIN_SPECS = {
CLIMATE_DOMAIN: NumericalDomainSpec(
CLIMATE_DOMAIN: DomainSpec(
value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE,
),
SENSOR_DOMAIN: NumericalDomainSpec(
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.TEMPERATURE,
),
WATER_HEATER_DOMAIN: NumericalDomainSpec(
value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE
),
WEATHER_DOMAIN: NumericalDomainSpec(
WATER_HEATER_DOMAIN: DomainSpec(value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE),
WEATHER_DOMAIN: DomainSpec(
value_source=ATTR_WEATHER_TEMPERATURE,
),
}

View File

@@ -1,172 +1,30 @@
"""Support for TP-Link LTE modems."""
"""The tplink_lte integration."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
import attr
import tp_connected
import voluptuous as vol
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_RECIPIENT,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "tplink_lte"
DATA_KEY = "tplink_lte"
CONF_NOTIFY = "notify"
_NOTIFY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]),
}
)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
cv.ensure_list,
[
vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_NOTIFY): vol.All(
cv.ensure_list, [_NOTIFY_SCHEMA]
),
}
)
],
)
},
{DOMAIN: cv.match_all},
extra=vol.ALLOW_EXTRA,
)
@attr.s
class ModemData:
"""Class for modem state."""
host: str = attr.ib()
modem: tp_connected.Modem = attr.ib()
connected: bool = attr.ib(init=False, default=True)
@attr.s
class LTEData:
"""Shared state."""
websession: aiohttp.ClientSession = attr.ib()
modem_data: dict[str, ModemData] = attr.ib(init=False, factory=dict)
def get_modem_data(self, config: dict[str, Any]) -> ModemData | None:
"""Get the requested or the only modem_data value."""
if CONF_HOST in config:
return self.modem_data.get(config[CONF_HOST])
if len(self.modem_data) == 1:
return next(iter(self.modem_data.values()))
return None
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up TP-Link LTE component."""
if DATA_KEY not in hass.data:
websession = async_create_clientsession(
hass, cookie_jar=aiohttp.CookieJar(unsafe=True)
)
hass.data[DATA_KEY] = LTEData(websession)
domain_config = config.get(DOMAIN, [])
tasks = [_setup_lte(hass, conf) for conf in domain_config]
if tasks:
await asyncio.gather(*tasks)
for conf in domain_config:
for notify_conf in conf.get(CONF_NOTIFY, []):
hass.async_create_task(
discovery.async_load_platform(
hass, Platform.NOTIFY, DOMAIN, notify_conf, config
)
)
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"ghsa_url": "https://github.com/advisories/GHSA-h95x-26f3-88hr",
},
)
return True
async def _setup_lte(
hass: HomeAssistant, lte_config: dict[str, Any], delay: int = 0
) -> None:
"""Set up a TP-Link LTE modem."""
host: str = lte_config[CONF_HOST]
password: str = lte_config[CONF_PASSWORD]
lte_data: LTEData = hass.data[DATA_KEY]
modem = tp_connected.Modem(hostname=host, websession=lte_data.websession)
modem_data = ModemData(host, modem)
try:
await _login(hass, modem_data, password)
except tp_connected.Error:
retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password))
@callback
def cleanup_retry(event: Event) -> None:
"""Clean up retry task resources."""
if not retry_task.done():
retry_task.cancel()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry)
async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None:
"""Log in and complete setup."""
await modem_data.modem.login(password=password)
modem_data.connected = True
lte_data: LTEData = hass.data[DATA_KEY]
lte_data.modem_data[modem_data.host] = modem_data
async def cleanup(event: Event) -> None:
"""Clean up resources."""
await modem_data.modem.logout()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
async def _retry_login(
hass: HomeAssistant, modem_data: ModemData, password: str
) -> None:
"""Sleep and retry setup."""
_LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host)
modem_data.connected = False
delay = 15
while not modem_data.connected:
await asyncio.sleep(delay)
try:
await _login(hass, modem_data, password)
_LOGGER.warning("Connected to %s", modem_data.host)
except tp_connected.Error:
delay = min(2 * delay, 300)

View File

@@ -4,7 +4,6 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/tplink_lte",
"iot_class": "local_polling",
"loggers": ["tp_connected"],
"quality_scale": "legacy",
"requirements": ["tp-connected==0.0.4"]
"requirements": []
}

View File

@@ -1,55 +0,0 @@
"""Support for TP-Link LTE notifications."""
from __future__ import annotations
import logging
from typing import Any
import attr
import tp_connected
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
from homeassistant.const import CONF_RECIPIENT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DATA_KEY, LTEData
_LOGGER = logging.getLogger(__name__)
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> TplinkNotifyService | None:
"""Get the notification service."""
if discovery_info is None:
return None
return TplinkNotifyService(hass, discovery_info)
@attr.s
class TplinkNotifyService(BaseNotificationService):
"""Implementation of a notification service."""
hass: HomeAssistant = attr.ib()
config: dict[str, Any] = attr.ib()
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
lte_data: LTEData = self.hass.data[DATA_KEY]
modem_data = lte_data.get_modem_data(self.config)
if not modem_data:
_LOGGER.error("No modem available")
return
phone = self.config[CONF_RECIPIENT]
targets = kwargs.get(ATTR_TARGET, phone)
if targets and message:
for target in targets:
try:
await modem_data.modem.sms(target, message)
except tp_connected.Error:
_LOGGER.error("Unable to send to %s", target)

View File

@@ -0,0 +1,8 @@
{
"issues": {
"integration_removed": {
"description": "The TP-Link LTE integration has been removed from Home Assistant.\n\nThe integration has not been working since Home Assistant 2023.6.0, has no maintainer, and its underlying library depends on a package with a [critical security vulnerability]({ghsa_url}).\n\nTo resolve this issue, remove the `tplink_lte` configuration from your `configuration.yaml` file and restart Home Assistant.",
"title": "The TP-Link LTE integration has been removed"
}
}
}

View File

@@ -14,7 +14,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
@@ -73,7 +73,7 @@ class WaterHeaterTargetTemperatureCondition(EntityNumericalConditionWithUnitBase
"""Condition for water heater target temperature."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, entity_state: State) -> str | None:

View File

@@ -10,7 +10,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerWithUnitBase,
@@ -57,7 +57,7 @@ class _WaterHeaterTargetTemperatureTriggerMixin(
"""Mixin for water heater target temperature triggers with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, state: State) -> str | None:

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 4
PATCH_VERSION: Final = "0b2"
PATCH_VERSION: Final = "0b4"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)

View File

@@ -1,6 +1,6 @@
"""Helpers for automation."""
from collections.abc import Callable, Mapping
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from typing import Any, Final, Self
@@ -37,14 +37,6 @@ class DomainSpec:
"""Attribute name to extract the value from, or None for state.state."""
@dataclass(frozen=True, slots=True)
class NumericalDomainSpec(DomainSpec):
"""DomainSpec with an optional value converter for numerical triggers."""
value_converter: Callable[[float], float] | None = None
"""Optional converter for numerical values (e.g. uint8 → percentage)."""
def filter_by_domain_specs(
hass: HomeAssistant,
domain_specs: Mapping[str, DomainSpec],

View File

@@ -342,10 +342,10 @@ ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema(
)
class EntityConditionBase[DomainSpecT: DomainSpec = DomainSpec](Condition):
class EntityConditionBase(Condition):
"""Base class for entity conditions."""
_domain_specs: Mapping[str, DomainSpecT]
_domain_specs: Mapping[str, DomainSpec]
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
@override

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import Iterable, Mapping
from datetime import datetime
@@ -771,6 +772,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
devices: ActiveDeviceRegistryItems
deleted_devices: DeviceRegistryItems[DeletedDeviceEntry]
_device_data: dict[str, DeviceEntry]
_loaded_event: asyncio.Event | None = None
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the device registry."""
@@ -784,6 +786,11 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
serialize_in_event_loop=False,
)
@callback
def async_setup(self) -> None:
"""Set up the registry."""
self._loaded_event = asyncio.Event()
@callback
def async_get(self, device_id: str) -> DeviceEntry | None:
"""Get device.
@@ -1463,6 +1470,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
async def _async_load(self) -> None:
"""Load the device registry."""
assert self._loaded_event is not None
assert not self._loaded_event.is_set()
async_setup_cleanup(self.hass, self)
data = await self._store.async_load()
@@ -1560,6 +1570,16 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self.deleted_devices = deleted_devices
self._device_data = devices.data
self._loaded_event.set()
async def async_wait_loaded(self) -> None:
"""Wait until the device registry is fully loaded.
Will only wait if the registry had already been set up.
"""
if self._loaded_event is not None:
await self._loaded_event.wait()
@callback
def _data_to_save(self) -> dict[str, Any]:
"""Return data of device registry to store in a file."""
@@ -1706,9 +1726,14 @@ def async_get(hass: HomeAssistant) -> DeviceRegistry:
return DeviceRegistry(hass)
def async_setup(hass: HomeAssistant) -> None:
"""Set up device registry."""
assert DATA_REGISTRY not in hass.data
async_get(hass).async_setup()
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load device registry."""
assert DATA_REGISTRY not in hass.data
await async_get(hass).async_load(load_empty=load_empty)

View File

@@ -80,7 +80,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1
STORAGE_VERSION_MINOR = 21
STORAGE_VERSION_MINOR = 22
STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24
@@ -240,7 +240,6 @@ class RegistryEntry:
# For backwards compatibility, should be removed in the future
compat_aliases: list[str] = attr.ib(factory=list, eq=False)
compat_name: str | None = attr.ib(default=None, eq=False)
# original_name_unprefixed is used to store the result of stripping
# the device name prefix from the original_name, if possible.
@@ -413,8 +412,7 @@ class RegistryEntry:
"has_entity_name": self.has_entity_name,
"labels": list(self.labels),
"modified_at": self.modified_at,
"name": self.compat_name,
"name_v2": self.name,
"name": self.name,
"object_id_base": self.object_id_base,
"options": self.options,
"original_device_class": self.original_device_class,
@@ -471,6 +469,7 @@ def _async_get_full_entity_name(
original_name: str | None,
original_name_unprefixed: str | None | UndefinedType = UNDEFINED,
overridden_name: str | None = None,
use_legacy_naming: bool = False,
) -> str:
"""Get full name for an entity.
@@ -480,7 +479,7 @@ def _async_get_full_entity_name(
if name is None and overridden_name is not None:
name = overridden_name
else:
elif not use_legacy_naming or name is None:
device_name: str | None = None
if (
device_id is not None
@@ -533,6 +532,7 @@ def async_get_full_entity_name(
name=entry.name,
original_name=original_name,
original_name_unprefixed=original_name_unprefixed,
use_legacy_naming=True,
)
@@ -660,7 +660,6 @@ class DeletedRegistryEntry:
# For backwards compatibility, should be removed in the future
compat_aliases: list[str] = attr.ib(factory=list, eq=False)
compat_name: str | None = attr.ib(default=None, eq=False)
_cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False)
@@ -696,8 +695,7 @@ class DeletedRegistryEntry:
"id": self.id,
"labels": list(self.labels),
"modified_at": self.modified_at,
"name": self.compat_name,
"name_v2": self.name,
"name": self.name,
"options": self.options if self.options is not UNDEFINED else {},
"options_undefined": self.options is UNDEFINED,
"orphaned_timestamp": self.orphaned_timestamp,
@@ -850,46 +848,37 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entity in data["entities"]:
entity["object_id_base"] = entity["original_name"]
if old_minor_version < 21:
# Version 1.21 migrates the full name to include device name,
# even if entity name is overwritten by user.
# It also adds support for COMPUTED_NAME in aliases and starts preserving their order.
# To avoid a major version bump, we keep the old name and aliases as-is
# and use new name_v2 and aliases_v2 fields instead.
if old_minor_version == 21:
# Version 1.21 has been reverted.
# It migrated entity names to the new format stored in `name_v2`
# field, automatically stripping any device name prefix present.
# The old name was stored in `name` field for backwards compatibility.
# For users who already migrated to v1.21, we restore old names
# but try to preserve any user renames made since that migration.
device_registry = dr.async_get(self.hass)
for entity in data["entities"]:
alias_to_add: str | None = None
old_name = entity["name"]
name = entity.pop("name_v2")
if (
(name := entity["name"])
(name != old_name)
and (device_id := entity["device_id"]) is not None
and (device := device_registry.async_get(device_id)) is not None
and (device_name := device.name_by_user or device.name)
):
# Strip the device name prefix from the entity name if present,
# and add the full generated name as an alias.
# If the name doesn't have the device name prefix and the
# entity is exposed to a voice assistant, add the previous
# name as an alias instead to preserve backwards compatibility.
if (
new_name := _async_strip_prefix_from_entity_name(
name, device_name
)
) is not None:
name = new_name
elif any(
entity.get("options", {}).get(key, {}).get("should_expose")
for key in ("conversation", "cloud.google_assistant")
):
alias_to_add = name
name = f"{device_name} {name}"
entity["name_v2"] = name
entity["aliases_v2"] = [alias_to_add, *entity["aliases"]]
entity["name"] = name
if old_minor_version < 22:
# Version 1.22 adds support for COMPUTED_NAME in aliases and starts preserving
# their order.
# To avoid a major version bump, we keep the old aliases as-is and use aliases_v2
# field instead.
for entity in data["entities"]:
entity["aliases_v2"] = [None, *entity["aliases"]]
for entity in data["deleted_entities"]:
# We don't know what the device name was, so the only thing we can do
# is to clear the overwritten name to not mislead users.
entity["name_v2"] = None
entity["aliases_v2"] = [None, *entity["aliases"]]
if old_major_version > 1:
@@ -1363,7 +1352,6 @@ class EntityRegistry(BaseRegistry):
area_id = deleted_entity.area_id
categories = deleted_entity.categories
compat_aliases = deleted_entity.compat_aliases
compat_name = deleted_entity.compat_name
created_at = deleted_entity.created_at
device_class = deleted_entity.device_class
if deleted_entity.disabled_by is not UNDEFINED:
@@ -1395,7 +1383,6 @@ class EntityRegistry(BaseRegistry):
area_id = None
categories = {}
compat_aliases = []
compat_name = None
device_class = None
icon = None
labels = set()
@@ -1443,7 +1430,6 @@ class EntityRegistry(BaseRegistry):
categories=categories,
capabilities=none_if_undefined(capabilities),
compat_aliases=compat_aliases,
compat_name=compat_name,
config_entry_id=none_if_undefined(config_entry_id),
config_subentry_id=none_if_undefined(config_subentry_id),
created_at=created_at,
@@ -1506,7 +1492,6 @@ class EntityRegistry(BaseRegistry):
area_id=entity.area_id,
categories=entity.categories,
compat_aliases=entity.compat_aliases,
compat_name=entity.compat_name,
config_entry_id=config_entry_id,
config_subentry_id=entity.config_subentry_id,
created_at=entity.created_at,
@@ -1620,14 +1605,27 @@ class EntityRegistry(BaseRegistry):
for entity in entities:
if entity.has_entity_name:
continue
name = (
entity.original_name_unprefixed
if by_user and entity.name is None
else UNDEFINED
)
# When a user renames a device, update entity names to reflect
# the new device name.
# An empty name_unprefixed means the entity name equals
# the device name (e.g. a main sensor); a non-empty one
# is appended as a suffix.
name: str | None | UndefinedType = UNDEFINED
if (
by_user
and entity.name is None
and (name_unprefixed := entity.original_name_unprefixed) is not None
):
if not name_unprefixed:
name = device_name
elif device_name:
name = f"{device_name} {name_unprefixed}"
original_name_unprefixed = _async_strip_prefix_from_entity_name(
entity.original_name, device_name
)
self._async_update_entity(
entity.entity_id,
name=name,
@@ -1944,6 +1942,10 @@ class EntityRegistry(BaseRegistry):
async def _async_load(self) -> None:
"""Load the entity registry."""
# Device registry must be loaded before entity registry because
# migration and entity processing reference device names.
await dr.async_get(self.hass).async_wait_loaded()
_async_setup_cleanup(self.hass, self)
_async_setup_entity_restore(self.hass, self)
@@ -1991,7 +1993,6 @@ class EntityRegistry(BaseRegistry):
categories=entity["categories"],
capabilities=entity["capabilities"],
compat_aliases=entity["aliases"],
compat_name=entity["name"],
config_entry_id=entity["config_entry_id"],
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
@@ -2012,7 +2013,7 @@ class EntityRegistry(BaseRegistry):
has_entity_name=entity["has_entity_name"],
labels=set(entity["labels"]),
modified_at=datetime.fromisoformat(entity["modified_at"]),
name=entity["name_v2"],
name=entity["name"],
object_id_base=entity.get("object_id_base"),
options=entity["options"],
original_device_class=entity["original_device_class"],
@@ -2063,7 +2064,6 @@ class EntityRegistry(BaseRegistry):
area_id=entity["area_id"],
categories=entity["categories"],
compat_aliases=entity["aliases"],
compat_name=entity["name"],
config_entry_id=entity["config_entry_id"],
config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
@@ -2083,7 +2083,7 @@ class EntityRegistry(BaseRegistry):
id=entity["id"],
labels=set(entity["labels"]),
modified_at=datetime.fromisoformat(entity["modified_at"]),
name=entity["name_v2"],
name=entity["name"],
options=entity["options"]
if not entity["options_undefined"]
else UNDEFINED,

View File

@@ -68,7 +68,6 @@ from homeassistant.util.yaml import load_yaml_dict
from . import config_validation as cv, selector
from .automation import (
DomainSpec,
NumericalDomainSpec,
ThresholdConfig,
filter_by_domain_specs,
get_absolute_description_key,
@@ -340,10 +339,10 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EntityTriggerBase[DomainSpecT: DomainSpec = DomainSpec](Trigger):
class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
_domain_specs: Mapping[str, DomainSpecT]
_domain_specs: Mapping[str, DomainSpec]
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
@override
@@ -534,7 +533,7 @@ NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]):
class EntityNumericalStateTriggerBase(EntityTriggerBase):
"""Base class for numerical state and state attribute triggers."""
_valid_unit: str | None | UndefinedType = UNDEFINED
@@ -595,21 +594,12 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]):
# Entity state is not a valid number
return None
def _get_converter(self, state: State) -> Callable[[float], float]:
"""Get the value converter for an entity."""
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_converter is not None:
return domain_spec.value_converter
return lambda x: x
def is_valid_state(self, state: State) -> bool:
"""Check if the new state or state attribute matches the expected one."""
# Handle missing or None value case first to avoid expensive exceptions
if (_attribute_value := self._get_tracked_value(state)) is None:
if (current_value := self._get_tracked_value(state)) is None:
return False
current_value = self._get_converter(state)(_attribute_value)
if self._threshold_type == NumericThresholdType.ANY:
# If the threshold type is "any" we always trigger on valid state
# changes
@@ -890,7 +880,7 @@ def make_entity_origin_state_trigger(
def make_entity_numerical_state_changed_trigger(
domain_specs: Mapping[str, NumericalDomainSpec],
domain_specs: Mapping[str, DomainSpec],
valid_unit: str | None | UndefinedType = UNDEFINED,
) -> type[EntityNumericalStateChangedTriggerBase]:
"""Create a trigger for numerical state value change."""
@@ -905,7 +895,7 @@ def make_entity_numerical_state_changed_trigger(
def make_entity_numerical_state_crossed_threshold_trigger(
domain_specs: Mapping[str, NumericalDomainSpec],
domain_specs: Mapping[str, DomainSpec],
valid_unit: str | None | UndefinedType = UNDEFINED,
) -> type[EntityNumericalStateCrossedThresholdTriggerBase]:
"""Create a trigger for numerical state value crossing a threshold."""
@@ -920,7 +910,7 @@ def make_entity_numerical_state_crossed_threshold_trigger(
def make_entity_numerical_state_changed_with_unit_trigger(
domain_specs: Mapping[str, NumericalDomainSpec],
domain_specs: Mapping[str, DomainSpec],
base_unit: str,
unit_converter: type[BaseUnitConverter],
) -> type[EntityNumericalStateChangedTriggerWithUnitBase]:
@@ -937,7 +927,7 @@ def make_entity_numerical_state_changed_with_unit_trigger(
def make_entity_numerical_state_crossed_threshold_with_unit_trigger(
domain_specs: Mapping[str, NumericalDomainSpec],
domain_specs: Mapping[str, DomainSpec],
base_unit: str,
unit_converter: type[BaseUnitConverter],
) -> type[EntityNumericalStateCrossedThresholdTriggerWithUnitBase]:

View File

@@ -39,7 +39,7 @@ habluetooth==5.11.1
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260325.0
home-assistant-frontend==20260325.2
home-assistant-intents==2026.3.24
httpx==0.28.1
ifaddr==0.2.0

View File

@@ -55,6 +55,7 @@ def run(args: Sequence[str] | None) -> None:
async def run_command(args: argparse.Namespace) -> None:
"""Run the command."""
hass = HomeAssistant(os.path.join(os.getcwd(), args.config))
dr.async_setup(hass)
await asyncio.gather(dr.async_load(hass), er.async_load(hass))
hass.auth = await auth_manager_from_config(hass, [{"type": "homeassistant"}], [])
provider = hass.auth.auth_providers[0]

View File

@@ -302,6 +302,7 @@ async def async_check_config(config_dir):
hass = core.HomeAssistant(config_dir)
loader.async_setup(hass)
hass.config_entries = ConfigEntries(hass, {})
dr.async_setup(hass)
await ar.async_load(hass)
await dr.async_load(hass)
await er.async_load(hass)

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.4.0b2"
version = "2026.4.0b4"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."

15
requirements_all.txt generated
View File

@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.3.0
aioamazondevices==13.3.1
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -1229,7 +1229,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260325.0
home-assistant-frontend==20260325.2
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
@@ -1286,7 +1286,7 @@ icalendar==6.3.1
icmplib==3.0
# homeassistant.components.idasen_desk
idasen-ha==2.6.4
idasen-ha==2.6.5
# homeassistant.components.idrive_e2
idrive-e2-client==0.1.1
@@ -1452,7 +1452,7 @@ livisi==0.0.25
locationsharinglib==5.0.1
# homeassistant.components.lojack
lojack-api==0.7.1
lojack-api==0.7.2
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -2651,7 +2651,7 @@ python-overseerr==0.9.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.6
python-pooldose==0.9.0
# homeassistant.components.hr_energy_qube
python-qube-heatpump==1.7.0
@@ -2823,7 +2823,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.6
renault-api==0.5.7
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -3138,9 +3138,6 @@ toonapi==0.3.0
# homeassistant.components.totalconnect
total-connect-client==2025.12.2
# homeassistant.components.tplink_lte
tp-connected==0.0.4
# homeassistant.components.tplink_omada
tplink-omada-client==1.5.6

View File

@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.3.0
aioamazondevices==13.3.1
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -1093,7 +1093,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260325.0
home-assistant-frontend==20260325.2
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
@@ -1144,7 +1144,7 @@ icalendar==6.3.1
icmplib==3.0
# homeassistant.components.idasen_desk
idasen-ha==2.6.4
idasen-ha==2.6.5
# homeassistant.components.idrive_e2
idrive-e2-client==0.1.1
@@ -1274,7 +1274,7 @@ libsoundtouch==0.8
livisi==0.0.25
# homeassistant.components.lojack
lojack-api==0.7.1
lojack-api==0.7.2
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -2253,7 +2253,7 @@ python-overseerr==0.9.0
python-picnic-api2==1.3.1
# homeassistant.components.pooldose
python-pooldose==0.8.6
python-pooldose==0.9.0
# homeassistant.components.hr_energy_qube
python-qube-heatpump==1.7.0
@@ -2401,7 +2401,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.6
renault-api==0.5.7
# homeassistant.components.renson
renson-endura-delta==1.7.2

View File

@@ -305,6 +305,8 @@ async def async_test_home_assistant(
hass
)
if load_registries:
dr.async_setup(hass)
with (
patch.object(StoreWithoutWriteLoad, "async_load", return_value=None),
patch(

View File

@@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.mark.parametrize(
"condition",
[

View File

@@ -4,7 +4,6 @@ from typing import Any
import pytest
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
@@ -40,12 +39,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.mark.parametrize(
"trigger_key",
[
@@ -364,122 +357,3 @@ async def test_battery_level_crossed_threshold_sensor_behavior_last(
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"battery.level_changed",
device_class=NumberDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"battery.level_crossed_threshold",
device_class=NumberDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_battery_number_trigger_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test battery number triggers with 'any' behavior."""
await assert_trigger_behavior_any(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"battery.level_crossed_threshold",
device_class=NumberDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_battery_level_crossed_threshold_number_behavior_first(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test battery level_crossed_threshold trigger fires on the first number state change."""
await assert_trigger_behavior_first(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"battery.level_crossed_threshold",
device_class=NumberDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_battery_level_crossed_threshold_number_behavior_last(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test battery level_crossed_threshold trigger fires when the last number changes state."""
await assert_trigger_behavior_last(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)

View File

@@ -47,6 +47,7 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
"climate.is_cooling",
"climate.is_drying",
"climate.is_heating",
"climate.is_hvac_mode",
"climate.target_humidity",
"climate.target_temperature",
],
@@ -83,6 +84,24 @@ async def test_climate_conditions_gated_by_labs_flag(
],
other_states=[HVACMode.OFF],
),
*(
param
for mode in HVACMode
for param in parametrize_condition_states_any(
condition="climate.is_hvac_mode",
condition_options={"hvac_mode": [mode]},
target_states=[mode],
other_states=[m for m in HVACMode if m != mode],
)
),
*parametrize_condition_states_any(
condition="climate.is_hvac_mode",
condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=[
m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL)
],
),
],
)
async def test_climate_state_condition_behavior_any(
@@ -133,6 +152,24 @@ async def test_climate_state_condition_behavior_any(
],
other_states=[HVACMode.OFF],
),
*(
param
for mode in HVACMode
for param in parametrize_condition_states_all(
condition="climate.is_hvac_mode",
condition_options={"hvac_mode": [mode]},
target_states=[mode],
other_states=[m for m in HVACMode if m != mode],
)
),
*parametrize_condition_states_all(
condition="climate.is_hvac_mode",
condition_options={"hvac_mode": [HVACMode.HEAT, HVACMode.COOL]},
target_states=[HVACMode.HEAT, HVACMode.COOL],
other_states=[
m for m in HVACMode if m not in (HVACMode.HEAT, HVACMode.COOL)
],
),
],
)
async def test_climate_state_condition_behavior_all(

View File

@@ -1,16 +1,29 @@
"""Test humidifier conditions."""
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from typing import Any
import pytest
import voluptuous as vol
from homeassistant.components.humidifier.condition import CONF_MODE
from homeassistant.components.humidifier.const import (
ATTR_ACTION,
ATTR_HUMIDITY,
HumidifierAction,
HumidifierEntityFeature,
)
from homeassistant.const import (
ATTR_MODE,
ATTR_SUPPORTED_FEATURES,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_OFF,
STATE_ON,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import async_validate_condition_config
from tests.components.common import (
ConditionStateDescription,
@@ -39,6 +52,7 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]:
"humidifier.is_on",
"humidifier.is_drying",
"humidifier.is_humidifying",
"humidifier.is_mode",
"humidifier.is_target_humidity",
],
)
@@ -153,6 +167,20 @@ async def test_humidifier_state_condition_behavior_all(
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
),
*parametrize_condition_states_any(
condition="humidifier.is_mode",
condition_options={CONF_MODE: ["eco", "sleep"]},
target_states=[
(STATE_ON, {ATTR_MODE: "eco"}),
(STATE_ON, {ATTR_MODE: "sleep"}),
],
other_states=[
(STATE_ON, {ATTR_MODE: "normal"}),
],
required_filter_attributes={
ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES
},
),
],
)
async def test_humidifier_attribute_condition_behavior_any(
@@ -196,6 +224,20 @@ async def test_humidifier_attribute_condition_behavior_any(
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
),
*parametrize_condition_states_all(
condition="humidifier.is_mode",
condition_options={CONF_MODE: ["eco", "sleep"]},
target_states=[
(STATE_ON, {ATTR_MODE: "eco"}),
(STATE_ON, {ATTR_MODE: "sleep"}),
],
other_states=[
(STATE_ON, {ATTR_MODE: "normal"}),
],
required_filter_attributes={
ATTR_SUPPORTED_FEATURES: HumidifierEntityFeature.MODES
},
),
],
)
async def test_humidifier_attribute_condition_behavior_all(
@@ -291,3 +333,51 @@ async def test_humidifier_numerical_condition_behavior_all(
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition", "condition_options", "expected_result"),
[
# Valid configurations
(
"humidifier.is_mode",
{CONF_MODE: ["eco", "sleep"]},
does_not_raise(),
),
(
"humidifier.is_mode",
{CONF_MODE: "eco"},
does_not_raise(),
),
# Invalid configurations
(
"humidifier.is_mode",
# Empty mode list
{CONF_MODE: []},
pytest.raises(vol.Invalid),
),
(
"humidifier.is_mode",
# Missing CONF_MODE
{},
pytest.raises(vol.Invalid),
),
],
)
async def test_humidifier_is_mode_condition_validation(
hass: HomeAssistant,
condition: str,
condition_options: dict[str, Any],
expected_result: AbstractContextManager,
) -> None:
"""Test humidifier is_mode condition config validation."""
with expected_result:
await async_validate_condition_config(
hass,
{
"condition": condition,
CONF_TARGET: {CONF_ENTITY_ID: "humidifier.test"},
CONF_OPTIONS: condition_options,
},
)

View File

@@ -11,6 +11,7 @@ from homeassistant.components.climate import (
from homeassistant.components.humidifier import (
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
)
from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON
from homeassistant.core import HomeAssistant
@@ -36,12 +37,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.fixture
async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple climate entities associated with different targets."""
@@ -54,6 +49,12 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "humidifier")
@pytest.fixture
async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple weather entities associated with different targets."""
return await target_entities(hass, "weather")
@pytest.mark.parametrize(
"condition",
[
@@ -139,78 +140,6 @@ async def test_humidity_sensor_condition_behavior_all(
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_any(
"humidity.is_value",
device_class="humidity",
unit_attributes=_HUMIDITY_UNIT_ATTRS,
),
)
async def test_humidity_number_condition_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the humidity number condition with 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_all(
"humidity.is_value",
device_class="humidity",
unit_attributes=_HUMIDITY_UNIT_ATTRS,
),
)
async def test_humidity_number_condition_behavior_all(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the humidity number condition with 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
@@ -353,3 +282,75 @@ async def test_humidity_humidifier_condition_behavior_all(
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("weather"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_attribute_condition_above_below_any(
"humidity.is_value",
"sunny",
ATTR_WEATHER_HUMIDITY,
),
)
async def test_humidity_weather_condition_behavior_any(
hass: HomeAssistant,
target_weathers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the humidity weather condition with 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_weathers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("weather"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_attribute_condition_above_below_all(
"humidity.is_value",
"sunny",
ATTR_WEATHER_HUMIDITY,
),
)
async def test_humidity_weather_condition_behavior_all(
hass: HomeAssistant,
target_weathers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the humidity weather condition with 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_weathers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)

View File

@@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.mark.parametrize(
"condition",
[
@@ -221,75 +215,3 @@ async def test_illuminance_value_condition_behavior_all(
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_any(
"illuminance.is_value",
device_class="illuminance",
unit_attributes=_ILLUMINANCE_UNIT_ATTRS,
),
)
async def test_illuminance_value_number_condition_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the illuminance value condition with number entities and 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_all(
"illuminance.is_value",
device_class="illuminance",
unit_attributes=_ILLUMINANCE_UNIT_ATTRS,
),
)
async def test_illuminance_value_number_condition_behavior_all(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the illuminance value condition with number entities and 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)

View File

@@ -5,7 +5,6 @@ from typing import Any
import pytest
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
@@ -36,12 +35,6 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "binary_sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple sensor entities associated with different targets."""
@@ -340,125 +333,3 @@ async def test_illuminance_trigger_sensor_crossed_threshold_behavior_last(
trigger_options=trigger_options,
states=states,
)
# --- Number changed/crossed_threshold tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"illuminance.changed",
device_class=NumberDeviceClass.ILLUMINANCE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX},
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"illuminance.crossed_threshold",
device_class=NumberDeviceClass.ILLUMINANCE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX},
),
],
)
async def test_illuminance_trigger_number_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test illuminance trigger fires for number entities with device_class illuminance."""
await assert_trigger_behavior_any(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"illuminance.crossed_threshold",
device_class=NumberDeviceClass.ILLUMINANCE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX},
),
],
)
async def test_illuminance_trigger_number_crossed_threshold_behavior_first(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test illuminance crossed_threshold trigger fires on the first number state change."""
await assert_trigger_behavior_first(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"illuminance.crossed_threshold",
device_class=NumberDeviceClass.ILLUMINANCE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX},
),
],
)
async def test_illuminance_trigger_number_crossed_threshold_behavior_last(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test illuminance crossed_threshold trigger fires when the last number changes state."""
await assert_trigger_behavior_last(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)

View File

@@ -4,11 +4,14 @@ from typing import Any
import pytest
from homeassistant.components.light import ATTR_BRIGHTNESS
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
@@ -19,6 +22,116 @@ from tests.components.common import (
)
def parametrize_brightness_condition_states_any(
condition: str, state: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below threshold test cases for brightness conditions.
Note: The brightness in the condition configuration is in percentage (0-100) scale,
the underlying attribute in the state is in uint8 (0-255) scale.
"""
return [
*parametrize_condition_states_any(
condition=condition,
condition_options={"threshold": {"type": "above", "value": {"number": 10}}},
target_states=[
(state, {attribute: 128}),
(state, {attribute: 255}),
],
other_states=[
(state, {attribute: 0}),
(state, {attribute: None}),
],
),
*parametrize_condition_states_any(
condition=condition,
condition_options={"threshold": {"type": "below", "value": {"number": 90}}},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 128}),
],
other_states=[
(state, {attribute: 255}),
(state, {attribute: None}),
],
),
*parametrize_condition_states_any(
condition=condition,
condition_options={
"threshold": {
"type": "between",
"value_min": {"number": 10},
"value_max": {"number": 90},
}
},
target_states=[
(state, {attribute: 128}),
(state, {attribute: 153}),
],
other_states=[
(state, {attribute: 0}),
(state, {attribute: 255}),
(state, {attribute: None}),
],
),
]
def parametrize_brightness_condition_states_all(
condition: str, state: str, attribute: str
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
"""Parametrize above/below threshold test cases for brightness conditions with 'all' behavior.
Note: The brightness in the condition configuration is in percentage (0-100) scale,
the underlying attribute in the state is in uint8 (0-255) scale.
"""
return [
*parametrize_condition_states_all(
condition=condition,
condition_options={"threshold": {"type": "above", "value": {"number": 10}}},
target_states=[
(state, {attribute: 128}),
(state, {attribute: 255}),
],
other_states=[
(state, {attribute: 0}),
(state, {attribute: None}),
],
),
*parametrize_condition_states_all(
condition=condition,
condition_options={"threshold": {"type": "below", "value": {"number": 90}}},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 128}),
],
other_states=[
(state, {attribute: 255}),
(state, {attribute: None}),
],
),
*parametrize_condition_states_all(
condition=condition,
condition_options={
"threshold": {
"type": "between",
"value_min": {"number": 10},
"value_max": {"number": 90},
}
},
target_states=[
(state, {attribute: 128}),
(state, {attribute: 153}),
],
other_states=[
(state, {attribute: 0}),
(state, {attribute: 255}),
(state, {attribute: None}),
],
),
]
@pytest.fixture
async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple light entities associated with different targets."""
@@ -38,6 +151,7 @@ async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"condition",
[
"light.is_brightness",
"light.is_off",
"light.is_on",
],
@@ -176,3 +290,75 @@ async def test_light_state_condition_behavior_all(
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_brightness_condition_states_any(
"light.is_brightness", STATE_ON, ATTR_BRIGHTNESS
),
],
)
async def test_light_brightness_condition_behavior_any(
hass: HomeAssistant,
target_lights: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the light brightness condition with the 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_lights,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_brightness_condition_states_all(
"light.is_brightness", STATE_ON, ATTR_BRIGHTNESS
),
],
)
async def test_light_brightness_condition_behavior_all(
hass: HomeAssistant,
target_lights: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the light brightness condition with the 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_lights,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)

View File

@@ -5243,6 +5243,7 @@
'area_id': None,
'capabilities': dict({
'options': list([
'airfry',
'almond_macaroons_1_tray',
'almond_macaroons_2_trays',
'amaranth',
@@ -5294,6 +5295,7 @@
'blanching',
'blueberry_muffins',
'bologna_sausage',
'booster',
'bottling',
'bottling_hard',
'bottling_medium',
@@ -5856,6 +5858,7 @@
'device_class': 'enum',
'friendly_name': 'Oven Program',
'options': list([
'airfry',
'almond_macaroons_1_tray',
'almond_macaroons_2_trays',
'amaranth',
@@ -5907,6 +5910,7 @@
'blanching',
'blueberry_muffins',
'bologna_sausage',
'booster',
'bottling',
'bottling_hard',
'bottling_medium',
@@ -6449,6 +6453,7 @@
'area_id': None,
'capabilities': dict({
'options': list([
'cooling_down',
'energy_save',
'heating_up',
'not_running',
@@ -6495,6 +6500,7 @@
'device_class': 'enum',
'friendly_name': 'Oven Program phase',
'options': list([
'cooling_down',
'energy_save',
'heating_up',
'not_running',
@@ -7624,6 +7630,7 @@
'starch',
'steam_care',
'stuffed_toys',
'table_linen',
'trainers',
'trainers_refresh',
'warm_air',
@@ -7707,6 +7714,7 @@
'starch',
'steam_care',
'stuffed_toys',
'table_linen',
'trainers',
'trainers_refresh',
'warm_air',
@@ -9041,6 +9049,7 @@
'area_id': None,
'capabilities': dict({
'options': list([
'airfry',
'almond_macaroons_1_tray',
'almond_macaroons_2_trays',
'amaranth',
@@ -9092,6 +9101,7 @@
'blanching',
'blueberry_muffins',
'bologna_sausage',
'booster',
'bottling',
'bottling_hard',
'bottling_medium',
@@ -9654,6 +9664,7 @@
'device_class': 'enum',
'friendly_name': 'Oven Program',
'options': list([
'airfry',
'almond_macaroons_1_tray',
'almond_macaroons_2_trays',
'amaranth',
@@ -9705,6 +9716,7 @@
'blanching',
'blueberry_muffins',
'bologna_sausage',
'booster',
'bottling',
'bottling_hard',
'bottling_medium',
@@ -10247,6 +10259,7 @@
'area_id': None,
'capabilities': dict({
'options': list([
'cooling_down',
'energy_save',
'heating_up',
'not_running',
@@ -10293,6 +10306,7 @@
'device_class': 'enum',
'friendly_name': 'Oven Program phase',
'options': list([
'cooling_down',
'energy_save',
'heating_up',
'not_running',
@@ -11422,6 +11436,7 @@
'starch',
'steam_care',
'stuffed_toys',
'table_linen',
'trainers',
'trainers_refresh',
'warm_air',
@@ -11505,6 +11520,7 @@
'starch',
'steam_care',
'stuffed_toys',
'table_linen',
'trainers',
'trainers_refresh',
'warm_air',

View File

@@ -40,12 +40,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.mark.parametrize(
"condition",
[
@@ -221,75 +215,3 @@ async def test_moisture_sensor_condition_behavior_all(
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_any(
"moisture.is_value",
device_class="moisture",
unit_attributes=_MOISTURE_UNIT_ATTRS,
),
)
async def test_moisture_number_condition_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the moisture number condition with 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_all(
"moisture.is_value",
device_class="moisture",
unit_attributes=_MOISTURE_UNIT_ATTRS,
),
)
async def test_moisture_number_condition_behavior_all(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the moisture number condition with 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)

View File

@@ -5,7 +5,6 @@ from typing import Any
import pytest
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
@@ -36,12 +35,6 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "binary_sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple sensor entities associated with different targets."""
@@ -336,128 +329,6 @@ async def test_moisture_trigger_sensor_crossed_threshold_behavior_last(
)
# --- Number entity tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"moisture.changed",
device_class=NumberDeviceClass.MOISTURE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"moisture.crossed_threshold",
device_class=NumberDeviceClass.MOISTURE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_moisture_trigger_number_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test moisture trigger fires for number entities with device_class moisture."""
await assert_trigger_behavior_any(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"moisture.crossed_threshold",
device_class=NumberDeviceClass.MOISTURE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_moisture_trigger_number_crossed_threshold_behavior_first(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test moisture crossed_threshold trigger fires on the first number state change."""
await assert_trigger_behavior_first(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"moisture.crossed_threshold",
device_class=NumberDeviceClass.MOISTURE,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_moisture_trigger_number_crossed_threshold_behavior_last(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test moisture crossed_threshold trigger fires when the last number changes state."""
await assert_trigger_behavior_last(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger", "trigger_options", "limit_entities"),

View File

@@ -26,12 +26,6 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
return await target_entities(hass, "sensor")
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.mark.parametrize(
"condition",
["power.is_value"],
@@ -117,80 +111,6 @@ async def test_power_sensor_condition_behavior_all(
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_any(
"power.is_value",
device_class="power",
threshold_unit=UnitOfPower.WATT,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT},
),
)
async def test_power_number_condition_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the power number condition with 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
parametrize_numerical_condition_above_below_all(
"power.is_value",
device_class="power",
threshold_unit=UnitOfPower.WATT,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT},
),
)
async def test_power_number_condition_behavior_all(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the power number condition with 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_numbers,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_power_condition_unit_conversion_sensor(
hass: HomeAssistant,

View File

@@ -4,7 +4,6 @@ from typing import Any
import pytest
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower
from homeassistant.core import HomeAssistant
@@ -24,12 +23,6 @@ from tests.components.common import (
_POWER_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple sensor entities associated with different targets."""
@@ -171,129 +164,3 @@ async def test_power_trigger_sensor_crossed_threshold_behavior_last(
trigger_options=trigger_options,
states=states,
)
# --- Number entity tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"power.changed",
device_class=NumberDeviceClass.POWER,
threshold_unit=UnitOfPower.WATT,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"power.crossed_threshold",
device_class=NumberDeviceClass.POWER,
threshold_unit=UnitOfPower.WATT,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_trigger_number_behavior_any(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test power trigger fires for number entities with device_class power."""
await assert_trigger_behavior_any(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"power.crossed_threshold",
device_class=NumberDeviceClass.POWER,
threshold_unit=UnitOfPower.WATT,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_trigger_number_crossed_threshold_behavior_first(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test power crossed_threshold trigger fires on the first number state change."""
await assert_trigger_behavior_first(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"power.crossed_threshold",
device_class=NumberDeviceClass.POWER,
threshold_unit=UnitOfPower.WATT,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_trigger_number_crossed_threshold_behavior_last(
hass: HomeAssistant,
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test power crossed_threshold trigger fires when the last number changes state."""
await assert_trigger_behavior_last(
hass,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)

View File

@@ -31,9 +31,20 @@ POWER_PERMISSIONS = {
"/vms/101": {"VM.PowerMgmt": 0},
}
SNAPSHOT_PERMISSIONS = {
"/vms": {"VM.Snapshot": 1},
"/vms/101": {"VM.Snapshot": 0},
}
MERGED_PERMISSIONS = {
key: {**AUDIT_PERMISSIONS.get(key, {}), **POWER_PERMISSIONS.get(key, {})}
for key in set(AUDIT_PERMISSIONS) | set(POWER_PERMISSIONS)
key: {
**AUDIT_PERMISSIONS.get(key, {}),
**POWER_PERMISSIONS.get(key, {}),
**SNAPSHOT_PERMISSIONS.get(key, {}),
}
for key in set(AUDIT_PERMISSIONS)
| set(POWER_PERMISSIONS)
| set(SNAPSHOT_PERMISSIONS)
}

Some files were not shown because too many files have changed in this diff Show More