Compare commits

..

41 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
Franck Nijhof
6dd5c30b49 Bump version to 2026.4.0b2 2026-03-26 10:59:11 +00:00
AlCalzone
72f5a572eb Revert: Create repair issue for legacy Z-Wave Door state sensors that are still in use (#166583) 2026-03-26 10:58:55 +00:00
Erik Montnemery
d501d8cb28 Adjust some trigger and condition schemas (#166568) 2026-03-26 10:58:54 +00:00
Keilin Bickar
35c4b4ff5b Bump asyncsleepiq to 1.7.1 (#166552) 2026-03-26 10:58:53 +00:00
Keilin Bickar
f3e8ac5b8e Bump sense-energy to 0.14.0 (#166550) 2026-03-26 10:58:51 +00:00
tronikos
ab2bcd84c6 Add Google Drive backup upload progress (#166549) 2026-03-26 10:58:50 +00:00
Ariel Ebersberger
cdf7b013a9 Add battery triggers (#166258) 2026-03-26 10:58:48 +00:00
Erik Montnemery
eeba0467a1 Add trigger humidifier.mode_changed (#166241)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-26 10:58:47 +00:00
126 changed files with 2714 additions and 2167 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",
@@ -155,6 +156,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"climate",
"counter",
@@ -185,6 +187,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"switch",
"temperature",
"text",
"todo",
"update",
"vacuum",
"water_heater",

View File

@@ -1,4 +1,4 @@
"""Integration for battery conditions."""
"""Integration for battery triggers and conditions."""
from __future__ import annotations

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

@@ -15,5 +15,25 @@
"is_not_low": {
"condition": "mdi:battery"
}
},
"triggers": {
"level_changed": {
"trigger": "mdi:battery-unknown"
},
"level_crossed_threshold": {
"trigger": "mdi:battery-alert"
},
"low": {
"trigger": "mdi:battery-alert"
},
"not_low": {
"trigger": "mdi:battery"
},
"started_charging": {
"trigger": "mdi:battery-charging"
},
"stopped_charging": {
"trigger": "mdi:battery"
}
}
}

View File

@@ -3,7 +3,12 @@
"condition_behavior_description": "How the state should match on the targeted batteries.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration"
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted batteries to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_charging": {
@@ -67,7 +72,80 @@
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Battery"
"title": "Battery",
"triggers": {
"level_changed": {
"description": "Triggers after the battery level of one or more batteries changes.",
"fields": {
"threshold": {
"description": "[%key:component::battery::common::trigger_threshold_changed_description%]",
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
"name": "Battery level changed"
},
"level_crossed_threshold": {
"description": "Triggers after the battery level of one or more batteries crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::battery::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
"name": "Battery level crossed threshold"
},
"low": {
"description": "Triggers after one or more batteries become low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery low"
},
"not_low": {
"description": "Triggers after one or more batteries are no longer low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery not low"
},
"started_charging": {
"description": "Triggers after one or more batteries start charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery started charging"
},
"stopped_charging": {
"description": "Triggers after one or more batteries stop charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
"name": "Battery stopped charging"
}
}
}

View File

@@ -0,0 +1,54 @@
"""Provides triggers for batteries."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
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
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
BATTERY_LOW_DOMAIN_SPECS: dict[str, DomainSpec] = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY),
}
BATTERY_CHARGING_DOMAIN_SPECS: dict[str, DomainSpec] = {
BINARY_SENSOR_DOMAIN: DomainSpec(
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
),
}
BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
}
TRIGGERS: dict[str, type[Trigger]] = {
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
"started_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
),
"stopped_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"level_changed": make_entity_numerical_state_changed_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for batteries."""
return TRIGGERS

View File

@@ -0,0 +1,83 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
unit_of_measurement: "%"
- domain: number
device_class: battery
- domain: sensor
device_class: battery
.battery_threshold_number: &battery_threshold_number
min: 0
max: 100
mode: box
unit_of_measurement: "%"
.trigger_target_battery: &trigger_target_battery
entity:
- domain: binary_sensor
device_class: battery
.trigger_target_charging: &trigger_target_charging
entity:
- domain: binary_sensor
device_class: battery_charging
.trigger_target_percentage: &trigger_target_percentage
entity:
- domain: sensor
device_class: battery
low:
fields:
behavior: *trigger_behavior
target: *trigger_target_battery
not_low:
fields:
behavior: *trigger_behavior
target: *trigger_target_battery
started_charging:
fields:
behavior: *trigger_behavior
target: *trigger_target_charging
stopped_charging:
fields:
behavior: *trigger_behavior
target: *trigger_target_charging
level_changed:
target: *trigger_target_percentage
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: changed
number: *battery_threshold_number
level_crossed_threshold:
target: *trigger_target_percentage
fields:
behavior: *trigger_behavior
threshold:
required: true
selector:
numeric_threshold:
entity: *battery_threshold_entity
mode: crossed
number: *battery_threshold_number

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

@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.13.8"]
"requirements": ["sense-energy==0.14.0"]
}

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

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Coroutine
from functools import wraps
import logging
from typing import Any
@@ -84,8 +85,22 @@ class GoogleDriveBackupAgent(BackupAgent):
:param open_stream: A function returning an async iterator that yields bytes.
:param backup: Metadata about the backup that should be uploaded.
"""
@wraps(open_stream)
async def wrapped_open_stream() -> AsyncIterator[bytes]:
stream = await open_stream()
async def _progress_stream() -> AsyncIterator[bytes]:
bytes_uploaded = 0
async for chunk in stream:
yield chunk
bytes_uploaded += len(chunk)
on_progress(bytes_uploaded=bytes_uploaded)
return _progress_stream()
try:
await self._client.async_upload_backup(open_stream, backup)
await self._client.async_upload_backup(wrapped_open_stream, backup)
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
raise BackupAgentError(f"Failed to upload backup: {err}") from err

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"
},
@@ -67,6 +70,9 @@
}
},
"triggers": {
"mode_changed": {
"trigger": "mdi:air-humidifier"
},
"started_drying": {
"trigger": "mdi:arrow-down-bold"
},

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": {
@@ -201,6 +215,20 @@
},
"title": "Humidifier",
"triggers": {
"mode_changed": {
"description": "Triggers after the operation mode of one or more humidifiers changes.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"mode": {
"description": "The operation modes to trigger on.",
"name": "Mode"
}
},
"name": "Humidifier mode changed"
},
"started_drying": {
"description": "Triggers after one or more humidifiers start drying.",
"fields": {

View File

@@ -1,13 +1,65 @@
"""Provides triggers for humidifiers."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_target_state_trigger,
)
from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
CONF_MODE = "mode"
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.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 ModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for humidifier mode changes."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)}
_schema = MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the mode trigger."""
super().__init__(hass, config)
self._to_states = set(self._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, DOMAIN, HumidifierAction
TRIGGERS: dict[str, type[Trigger]] = {
"mode_changed": ModeChangedTrigger,
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
),

View File

@@ -1,9 +1,9 @@
.trigger_common: &trigger_common
target:
target: &trigger_humidifier_target
entity:
domain: humidifier
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
@@ -18,3 +18,16 @@ started_drying: *trigger_common
started_humidifying: *trigger_common
turned_on: *trigger_common
turned_off: *trigger_common
mode_changed:
target: *trigger_humidifier_target
fields:
behavior: *trigger_behavior
mode:
context:
filter_target: target
required: true
selector:
state:
attribute: available_modes
multiple: true

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

@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
"requirements": ["sense-energy==0.13.8"]
"requirements": ["sense-energy==0.14.0"]
}

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

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["asyncsleepiq"],
"requirements": ["asyncsleepiq==1.7.0"]
"requirements": ["asyncsleepiq==1.7.1"]
}

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

@@ -5,14 +5,12 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN
from homeassistant.const import CONF_OPTIONS, CONF_TARGET
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ALL,
BEHAVIOR_ANY,
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityConditionBase,
@@ -22,13 +20,9 @@ from .const import DOMAIN
CONF_VALUE = "value"
_TEXT_CONDITION_SCHEMA = vol.Schema(
_TEXT_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
vol.Required(CONF_VALUE): cv.string,
},
}

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

@@ -9,17 +9,14 @@ import voluptuous as vol
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_OPTIONS,
CONF_TARGET,
STATE_OFF,
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.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ALL,
BEHAVIOR_ANY,
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityConditionBase,
@@ -33,13 +30,9 @@ from .const import DOMAIN
ATTR_OPERATION_MODE = "operation_mode"
_OPERATION_MODE_CONDITION_SCHEMA = vol.Schema(
_OPERATION_MODE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
vol.Required(ATTR_OPERATION_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [str]
),
@@ -80,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

@@ -18,27 +18,17 @@ from zwave_js_server.const.command_class.notification import (
)
from zwave_js_server.model.driver import Driver
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.start import async_at_started
from .const import DOMAIN
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
@@ -413,93 +403,6 @@ def is_valid_notification_binary_sensor(
return len(info.primary_value.metadata.states) > 1
@callback
def _async_check_legacy_entity_repair(
hass: HomeAssistant,
driver: Driver,
entity: ZWaveLegacyDoorStateBinarySensor,
) -> None:
"""Schedule a repair issue check once HA has fully started."""
@callback
def _async_do_check(hass: HomeAssistant) -> None:
"""Create or delete a repair issue for a deprecated legacy door state entity."""
ent_reg = er.async_get(hass)
if entity.unique_id is None:
return
entity_id = ent_reg.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id
)
if entity_id is None:
return
issue_id = f"deprecated_legacy_door_state.{entity_id}"
# Delete any stale repair issue if the entity is disabled or missing —
# the user has already dealt with it.
entity_entry = ent_reg.async_get(entity_id)
if entity_entry is None or entity_entry.disabled:
async_delete_issue(hass, DOMAIN, issue_id)
return
entity_automations = automations_with_entity(hass, entity_id)
entity_scripts = scripts_with_entity(hass, entity_id)
# Delete any stale repair issue if the entity is no longer referenced
# in any automation or script.
if not entity_automations and not entity_scripts:
async_delete_issue(hass, DOMAIN, issue_id)
return
opening_state_value = get_opening_state_notification_value(
entity.info.node, entity.info.primary_value.endpoint
)
if opening_state_value is None:
async_delete_issue(hass, DOMAIN, issue_id)
return
opening_state_unique_id = (
f"{driver.controller.home_id}.{opening_state_value.value_id}"
)
opening_state_entity_id = ent_reg.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, opening_state_unique_id
)
# Delete any stale repair issue if the replacement opening state sensor
# no longer exists for some reason
if opening_state_entity_id is None:
async_delete_issue(hass, DOMAIN, issue_id)
return
items = [
f"- [{item.name or item.original_name or eid}](/config/{domain}/edit/{item.unique_id})"
for domain, entity_ids in (
("automation", entity_automations),
("script", entity_scripts),
)
for eid in entity_ids
if (item := ent_reg.async_get(eid))
]
async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=False,
is_persistent=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_legacy_door_state",
translation_placeholders={
"entity_id": entity_id,
"entity_name": entity_entry.name
or entity_entry.original_name
or entity_id,
"opening_state_entity_id": opening_state_entity_id,
"items": "\n".join(items),
},
)
async_at_started(hass, _async_do_check)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ZwaveJSConfigEntry,
@@ -543,9 +446,9 @@ async def async_setup_entry(
isinstance(info, NewZwaveDiscoveryInfo)
and info.entity_class is ZWaveLegacyDoorStateBinarySensor
):
entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info)
entities.append(entity)
_async_check_legacy_entity_repair(hass, driver, entity)
entities.append(
ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info)
)
elif isinstance(info, NewZwaveDiscoveryInfo):
pass # other entity classes are not migrated yet
elif info.platform_hint == "notification":

View File

@@ -303,10 +303,6 @@
}
},
"issues": {
"deprecated_legacy_door_state": {
"description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the opening state sensor `{opening_state_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the opening state sensor `{opening_state_entity_id}` and disable the binary sensor `{entity_id}` to fix this issue.\n\nNote that `{opening_state_entity_id}` reports three states:\n- Closed\n- Open\n- Tilted (if supported by the device).",
"title": "Deprecation: {entity_name}"
},
"device_config_file_changed": {
"fix_flow": {
"abort": {

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 4
PATCH_VERSION: Final = "0b1"
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
@@ -462,19 +462,13 @@ def make_entity_state_condition(
return CustomCondition
NUMERICAL_CONDITION_SCHEMA = vol.Schema(
NUMERICAL_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): vol.All(
{
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS)
),
},
),
vol.Required(CONF_OPTIONS): {
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS)
),
},
}
)
@@ -588,22 +582,16 @@ def _make_numerical_condition_with_unit_schema(
unit_converter: type[BaseUnitConverter],
) -> vol.Schema:
"""Factory for numerical condition schema with unit option."""
return vol.Schema(
return ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): vol.All(
{
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(
mode=NumericThresholdMode.IS,
unit_of_measurement=list(unit_converter.VALID_UNITS),
)
),
},
),
vol.Required(CONF_OPTIONS): {
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(
mode=NumericThresholdMode.IS,
unit_of_measurement=list(unit_converter.VALID_UNITS),
)
),
},
}
)

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,
@@ -336,15 +335,14 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
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
@@ -535,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
@@ -596,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
@@ -746,19 +735,16 @@ class EntityNumericalStateChangedTriggerWithUnitBase(
cls._schema = make_numerical_state_changed_with_unit_schema(cls._unit_converter)
NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): vol.All(
{
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
),
NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.CROSSED)
),
},
)
}
}
)
)
@@ -787,21 +773,16 @@ def _make_numerical_state_crossed_threshold_with_unit_schema(
This trigger only fires when the observed attribute changes from not within to within
the defined threshold.
"""
return ENTITY_STATE_TRIGGER_SCHEMA.extend(
return ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS, default={}): vol.All(
{
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
),
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(
mode=NumericThresholdMode.CROSSED,
unit_of_measurement=list(unit_converter.VALID_UNITS),
)
),
},
)
vol.Required(CONF_OPTIONS, default={}): {
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(
mode=NumericThresholdMode.CROSSED,
unit_of_measurement=list(unit_converter.VALID_UNITS),
)
),
},
}
)
@@ -899,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."""
@@ -914,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."""
@@ -929,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]:
@@ -946,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.0b1"
version = "2026.4.0b4"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."

19
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
@@ -562,7 +562,7 @@ asyncinotify==4.4.0
asyncpysupla==0.0.5
# homeassistant.components.sleepiq
asyncsleepiq==1.7.0
asyncsleepiq==1.7.1
# homeassistant.components.sftp_storage
asyncssh==2.21.0
@@ -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
@@ -2902,7 +2902,7 @@ sendgrid==6.8.2
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense-energy==0.13.8
sense-energy==0.14.0
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -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
@@ -523,7 +523,7 @@ async-upnp-client==0.46.2
asyncarve==0.1.1
# homeassistant.components.sleepiq
asyncsleepiq==1.7.0
asyncsleepiq==1.7.1
# homeassistant.components.sftp_storage
asyncssh==2.21.0
@@ -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
@@ -2459,7 +2459,7 @@ securetar==2026.2.0
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
sense-energy==0.13.8
sense-energy==0.14.0
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1

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

@@ -0,0 +1,359 @@
"""Test battery triggers."""
from typing import Any
import pytest
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
parametrize_numerical_state_value_changed_trigger_states,
parametrize_numerical_state_value_crossed_threshold_trigger_states,
parametrize_target_entities,
parametrize_trigger_states,
target_entities,
)
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
return await target_entities(hass, "binary_sensor")
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple sensor entities associated with different targets."""
return await target_entities(hass, "sensor")
@pytest.mark.parametrize(
"trigger_key",
[
"battery.low",
"battery.not_low",
"battery.started_charging",
"battery.stopped_charging",
"battery.level_changed",
"battery.level_crossed_threshold",
],
)
async def test_battery_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the battery triggers are gated by the labs flag."""
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="battery.low",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.not_low",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.started_charging",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.stopped_charging",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"},
trigger_from_none=False,
),
],
)
async def test_battery_binary_sensor_trigger_behavior_any(
hass: HomeAssistant,
target_binary_sensors: 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 the battery binary sensor triggers with 'any' behavior."""
await assert_trigger_behavior_any(
hass,
target_entities=target_binary_sensors,
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("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="battery.low",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.not_low",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.started_charging",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.stopped_charging",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"},
trigger_from_none=False,
),
],
)
async def test_battery_binary_sensor_trigger_behavior_first(
hass: HomeAssistant,
target_binary_sensors: 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 the battery binary sensor triggers with 'first' behavior."""
await assert_trigger_behavior_first(
hass,
target_entities=target_binary_sensors,
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("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="battery.low",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.not_low",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.started_charging",
target_states=[STATE_ON],
other_states=[STATE_OFF],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="battery.stopped_charging",
target_states=[STATE_OFF],
other_states=[STATE_ON],
required_filter_attributes={ATTR_DEVICE_CLASS: "battery_charging"},
trigger_from_none=False,
),
],
)
async def test_battery_binary_sensor_trigger_behavior_last(
hass: HomeAssistant,
target_binary_sensors: 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 the battery binary sensor triggers with 'last' behavior."""
await assert_trigger_behavior_last(
hass,
target_entities=target_binary_sensors,
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("sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"battery.level_changed",
device_class=SensorDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"battery.level_crossed_threshold",
device_class=SensorDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_battery_sensor_trigger_behavior_any(
hass: HomeAssistant,
target_sensors: 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 sensor triggers with 'any' behavior."""
await assert_trigger_behavior_any(
hass,
target_entities=target_sensors,
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("sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"battery.level_crossed_threshold",
device_class=SensorDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_battery_level_crossed_threshold_sensor_behavior_first(
hass: HomeAssistant,
target_sensors: 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 sensor state change."""
await assert_trigger_behavior_first(
hass,
target_entities=target_sensors,
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("sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"battery.level_crossed_threshold",
device_class=SensorDeviceClass.BATTERY,
unit_attributes={ATTR_UNIT_OF_MEASUREMENT: "%"},
),
],
)
async def test_battery_level_crossed_threshold_sensor_behavior_last(
hass: HomeAssistant,
target_sensors: 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 sensor changes state."""
await assert_trigger_behavior_last(
hass,
target_entities=target_sensors,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)

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