Compare commits

...

20 Commits

Author SHA1 Message Date
Erik Montnemery
7d748796dc Apply suggestions from code review
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-23 13:32:16 +01:00
Erik
7648b36b00 Add trigger humidifier.mode_changed 2026-03-23 08:37:28 +01:00
Erik Montnemery
3616a52b37 Add temperature triggers (#165247) 2026-03-22 15:24:53 +01:00
Ludovic BOUÉ
0128372258 Update python-roborock to 4.26.3 (#166178) 2026-03-22 14:01:23 +01:00
EnjoyingM
21863cd9d7 Bump wolf_comm to 0.0.48 (#166144) 2026-03-22 10:27:18 +01:00
Sean O'Keeffe
d67caec5c1 Add additional miele oven programs (#166100) 2026-03-22 09:04:07 +01:00
J. Nick Koston
8286014ae1 Bump habluetooth to 5.11.1 (#166161) 2026-03-21 18:22:53 -10:00
J. Nick Koston
1ff8d2279a Bump oralb-ble to 1.1.0 (#166165) 2026-03-21 18:22:21 -10:00
Ludovic BOUÉ
5dcbc1d5d9 feat(roborock): Add Q10 empty dustbin button entity (#166149) 2026-03-22 00:36:43 +01:00
Ludovic BOUÉ
3068653cc7 Update python-roborock to 4.26.2 (#166152) 2026-03-21 23:44:02 +01:00
Paulus Schoutsen
61b1a45889 Add logger to OpenDisplay (#166146) 2026-03-21 22:30:01 +01:00
Ray Xue
573d4eba02 Bump xiaomi-ble to 1.10.0 (#166099) 2026-03-21 20:40:54 +01:00
Ludovic BOUÉ
09895aa601 Update python-roborock to 4.26.1 (#166138) 2026-03-21 20:25:24 +01:00
Joost Lekkerkerker
aa6a4c7eab Add binary sensor for stick cleaner status to SmartThings (#166122) 2026-03-21 20:24:53 +01:00
Michael
662c44b125 Fix reload of FRITZ!Box Tools in case of connection issues (#166111) 2026-03-21 20:24:21 +01:00
Josef Zweck
5a80087cf4 Bump aiotedee to 0.2.27 (#166101)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-21 20:23:15 +01:00
TimL
c28dc32168 Add PSRAM sensor for SMLIGHT integration (#166104) 2026-03-21 20:20:33 +01:00
Matthias Alphart
eef3472c43 KNX: Clean up internal setting of name, unique_id and entity_category for YAML entities (#160265) 2026-03-21 20:12:10 +01:00
tronikos
f9bd9f4982 Add diagnostics in Google Weather (#166105) 2026-03-21 18:43:45 +01:00
Jack Boswell
e4620a208d Update starlink-grpc-core to 1.2.4 (#165882) 2026-03-21 18:30:04 +01:00
75 changed files with 3572 additions and 442 deletions

2
CODEOWNERS generated
View File

@@ -1703,6 +1703,8 @@ build.json @home-assistant/supervisor
/tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken
/homeassistant/components/temperature/ @home-assistant/core
/tests/components/temperature/ @home-assistant/core
/homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77

View File

@@ -247,6 +247,7 @@ DEFAULT_INTEGRATIONS = {
"humidity",
"motion",
"occupancy",
"temperature",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

@@ -169,6 +169,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"select",
"siren",
"switch",
"temperature",
"text",
"update",
"vacuum",

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.10.2"
"habluetooth==5.11.1"
]
}

View File

@@ -13,6 +13,7 @@ from fritzconnection.core.exceptions import (
FritzSecurityError,
FritzServiceError,
)
from requests.exceptions import ConnectionError
from homeassistant.const import Platform
@@ -68,6 +69,7 @@ BUTTON_TYPE_WOL = "WakeOnLan"
UPTIME_DEVIATION = 5
FRITZ_EXCEPTIONS = (
ConnectionError,
FritzActionError,
FritzActionFailedError,
FritzConnectionException,

View File

@@ -0,0 +1,44 @@
"""Diagnostics support for Google Weather."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from .const import CONF_REFERRER
from .coordinator import GoogleWeatherConfigEntry
TO_REDACT = {
CONF_API_KEY,
CONF_REFERRER,
CONF_LATITUDE,
CONF_LONGITUDE,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
diag_data: dict[str, Any] = {
"entry": entry.as_dict(),
"subentries": {},
}
for subentry_id, subentry_rt in entry.runtime_data.subentries_runtime_data.items():
diag_data["subentries"][subentry_id] = {
"observation_data": subentry_rt.coordinator_observation.data.to_dict()
if subentry_rt.coordinator_observation.data
else None,
"daily_forecast_data": subentry_rt.coordinator_daily_forecast.data.to_dict()
if subentry_rt.coordinator_daily_forecast.data
else None,
"hourly_forecast_data": subentry_rt.coordinator_hourly_forecast.data.to_dict()
if subentry_rt.coordinator_hourly_forecast.data
else None,
}
return async_redact_data(diag_data, TO_REDACT)

View File

@@ -43,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: No discovery.

View File

@@ -64,6 +64,9 @@
}
},
"triggers": {
"mode_changed": {
"trigger": "mdi:air-humidifier"
},
"started_drying": {
"trigger": "mdi:arrow-down-bold"
},

View File

@@ -199,6 +199,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,46 @@
"""Provides triggers for humidifiers."""
from homeassistant.const import STATE_OFF, STATE_ON
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
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
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]),
},
}
)
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])
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

@@ -114,24 +114,26 @@ class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX binary sensor."""
self._device = XknxBinarySensor(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[CONF_STATE_ADDRESS],
invert=config[CONF_INVERT],
sync_state=config[CONF_SYNC_STATE],
ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE],
context_timeout=config.get(CONF_CONTEXT_TIMEOUT),
reset_after=config.get(CONF_RESET_AFTER),
always_callback=True,
)
super().__init__(
knx_module=knx_module,
device=XknxBinarySensor(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[CONF_STATE_ADDRESS],
invert=config[CONF_INVERT],
sync_state=config[CONF_SYNC_STATE],
ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE],
context_timeout=config.get(CONF_CONTEXT_TIMEOUT),
reset_after=config.get(CONF_RESET_AFTER),
always_callback=True,
),
unique_id=str(self._device.remote_value.group_address_state),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_force_update = self._device.ignore_internal_state
self._attr_unique_id = str(self._device.remote_value.group_address_state)
class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity):

View File

@@ -35,19 +35,18 @@ class KNXButton(KnxYamlEntity, ButtonEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX button."""
super().__init__(
knx_module=knx_module,
device=XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
),
self._device = XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
)
self._payload = config[CONF_PAYLOAD]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.remote_value.group_address}_{self._payload}"
super().__init__(
knx_module=knx_module,
unique_id=f"{self._device.remote_value.group_address}_{self._payload}",
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
async def async_press(self) -> None:

View File

@@ -119,7 +119,7 @@ async def async_setup_entry(
async_add_entities(entities)
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
def _create_climate_yaml(xknx: XKNX, config: ConfigType) -> XknxClimate:
"""Return a KNX Climate device to be used within XKNX."""
climate_mode = XknxClimateMode(
xknx,
@@ -646,9 +646,17 @@ class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
self._device = _create_climate_yaml(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_climate(knx_module.xknx, config),
unique_id=(
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
default_hvac_mode: HVACMode = config[ClimateConf.DEFAULT_CONTROLLER_MODE]
fan_max_step = config[ClimateConf.FAN_MAX_STEP]
@@ -660,14 +668,6 @@ class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
fan_zero_mode=fan_zero_mode,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
)
class KnxUiClimate(_KnxClimate, KnxUiEntity):
"""Representation of a KNX climate device configured from the UI."""

View File

@@ -191,36 +191,34 @@ class KnxYamlCover(_KnxCover, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize the cover."""
self._device = XknxCover(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS),
group_address_position_state=config.get(
CoverSchema.CONF_POSITION_STATE_ADDRESS
),
group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS),
group_address_angle_state=config.get(CoverSchema.CONF_ANGLE_STATE_ADDRESS),
group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS),
travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN],
travel_time_up=config[CoverConf.TRAVELLING_TIME_UP],
invert_updown=config[CoverConf.INVERT_UPDOWN],
invert_position=config[CoverConf.INVERT_POSITION],
invert_angle=config[CoverConf.INVERT_ANGLE],
)
super().__init__(
knx_module=knx_module,
device=XknxCover(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS),
group_address_position_state=config.get(
CoverSchema.CONF_POSITION_STATE_ADDRESS
),
group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS),
group_address_angle_state=config.get(
CoverSchema.CONF_ANGLE_STATE_ADDRESS
),
group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS),
travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN],
travel_time_up=config[CoverConf.TRAVELLING_TIME_UP],
invert_updown=config[CoverConf.INVERT_UPDOWN],
invert_position=config[CoverConf.INVERT_POSITION],
invert_angle=config[CoverConf.INVERT_ANGLE],
unique_id=(
f"{self._device.updown.group_address}_"
f"{self._device.position_target.group_address}"
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self.init_base()
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.updown.group_address}_"
f"{self._device.position_target.group_address}"
)
if custom_device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = custom_device_class

View File

@@ -105,20 +105,21 @@ class KnxYamlDate(_KNXDate, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX date."""
self._device = XknxDateDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxDateDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiDate(_KNXDate, KnxUiEntity):

View File

@@ -110,20 +110,21 @@ class KnxYamlDateTime(_KNXDateTime, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX datetime."""
self._device = XknxDateTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxDateTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiDateTime(_KNXDateTime, KnxUiEntity):

View File

@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
from xknx.devices import Device as XknxDevice
from homeassistant.const import CONF_ENTITY_CATEGORY, EntityCategory
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import EntityPlatform
@@ -52,14 +52,11 @@ class _KnxEntityBase(Entity):
"""Representation of a KNX entity."""
_attr_should_poll = False
_attr_unique_id: str
_knx_module: KNXModule
_device: XknxDevice
@property
def name(self) -> str:
"""Return the name of the KNX device."""
return self._device.name
@property
def available(self) -> bool:
"""Return True if entity is available."""
@@ -100,16 +97,23 @@ class _KnxEntityBase(Entity):
class KnxYamlEntity(_KnxEntityBase):
"""Representation of a KNX entity configured from YAML."""
def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None:
def __init__(
self,
knx_module: KNXModule,
unique_id: str,
name: str,
entity_category: EntityCategory | None,
) -> None:
"""Initialize the YAML entity."""
self._knx_module = knx_module
self._device = device
self._attr_name = name or None
self._attr_unique_id = unique_id
self._attr_entity_category = entity_category
class KnxUiEntity(_KnxEntityBase):
"""Representation of a KNX UI entity."""
_attr_unique_id: str
_attr_has_entity_name = True
def __init__(
@@ -117,6 +121,8 @@ class KnxUiEntity(_KnxEntityBase):
) -> None:
"""Initialize the UI entity."""
self._knx_module = knx_module
self._attr_name = entity_config[CONF_NAME]
self._attr_unique_id = unique_id
if entity_category := entity_config.get(CONF_ENTITY_CATEGORY):
self._attr_entity_category = EntityCategory(entity_category)

View File

@@ -208,35 +208,32 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanConf.MAX_STEP)
self._device = XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(FanSchema.CONF_OSCILLATION_ADDRESS),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(FanSchema.CONF_SWITCH_STATE_ADDRESS),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
)
super().__init__(
knx_module=knx_module,
device=XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(
FanSchema.CONF_OSCILLATION_ADDRESS
),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(
FanSchema.CONF_SWITCH_STATE_ADDRESS
),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
unique_id=(
str(self._device.speed.group_address)
if self._device.speed.group_address
else str(self._device.switch.group_address)
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
if self._device.speed.group_address:
self._attr_unique_id = str(self._device.speed.group_address)
else:
self._attr_unique_id = str(self._device.switch.group_address)
class KnxUiFan(_KnxFan, KnxUiEntity):

View File

@@ -558,15 +558,16 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX light."""
self._device = _create_yaml_light(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_yaml_light(knx_module.xknx, config),
unique_id=self._device_unique_id(),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_color_mode = next(iter(self.supported_color_modes))
self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN]
self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = self._device_unique_id()
def _device_unique_id(self) -> str:
"""Return unique id for this device."""

View File

@@ -46,12 +46,13 @@ class KNXNotify(KnxYamlEntity, NotifyEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX notification."""
self._device = _create_notification_instance(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_notification_instance(knx_module.xknx, config),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a notification to knx bus."""

View File

@@ -109,16 +109,19 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX number."""
self._device = NumericValue(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=NumericValue(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.sensor_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
@@ -131,7 +134,6 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
dpt_info["sensor_device_class"],
),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_mode = config[CONF_MODE]
self._attr_native_max_value = config.get(
NumberConf.MAX,
@@ -149,7 +151,6 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
CONF_UNIT_OF_MEASUREMENT,
dpt_info["unit"],
)
self._attr_unique_id = str(self._device.sensor_value.group_address)
self._device.sensor_value.value = max(0, self._attr_native_min_value)

View File

@@ -83,18 +83,19 @@ class KnxYamlScene(_KnxScene, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize KNX scene."""
self._device = XknxScene(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
)
super().__init__(
knx_module=knx_module,
device=XknxScene(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
unique_id=(
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)

View File

@@ -199,16 +199,22 @@ class KNXPlatformSchema(ABC):
}
COMMON_ENTITY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=""): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
class BinarySensorSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX binary sensors."""
PLATFORM = Platform.BINARY_SENSOR
DEFAULT_NAME = "KNX Binary Sensor"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_IGNORE_INTERNAL_STATE, default=False): cv.boolean,
vol.Optional(CONF_INVERT, default=False): cv.boolean,
@@ -218,7 +224,6 @@ class BinarySensorSchema(KNXPlatformSchema):
),
vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_RESET_AFTER): cv.positive_float,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
)
@@ -230,7 +235,6 @@ class ButtonSchema(KNXPlatformSchema):
PLATFORM = Platform.BUTTON
CONF_VALUE = "value"
DEFAULT_NAME = "KNX Button"
payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`"
length_or_type_msg = (
@@ -238,9 +242,8 @@ class ButtonSchema(KNXPlatformSchema):
)
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_validator,
vol.Exclusive(
CONF_PAYLOAD, "payload_or_value", msg=payload_or_value_msg
@@ -254,7 +257,6 @@ class ButtonSchema(KNXPlatformSchema):
vol.Exclusive(
CONF_TYPE, "length_or_type", msg=length_or_type_msg
): object,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -322,7 +324,6 @@ class ClimateSchema(KNXPlatformSchema):
CONF_SWING_HORIZONTAL_ADDRESS = "swing_horizontal_address"
CONF_SWING_HORIZONTAL_STATE_ADDRESS = "swing_horizontal_state_address"
DEFAULT_NAME = "KNX Climate"
DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010"
DEFAULT_SETPOINT_SHIFT_MAX = 6
DEFAULT_SETPOINT_SHIFT_MIN = -6
@@ -331,9 +332,8 @@ class ClimateSchema(KNXPlatformSchema):
DEFAULT_FAN_SPEED_MODE = "percent"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(
ClimateConf.SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
): vol.All(int, vol.Range(min=0, max=32)),
@@ -399,7 +399,6 @@ class ClimateSchema(KNXPlatformSchema):
): vol.Coerce(HVACMode),
vol.Optional(ClimateConf.MIN_TEMP): vol.Coerce(float),
vol.Optional(ClimateConf.MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator,
vol.Optional(ClimateConf.FAN_MAX_STEP, default=3): cv.byte,
@@ -433,12 +432,10 @@ class CoverSchema(KNXPlatformSchema):
CONF_ANGLE_STATE_ADDRESS = "angle_state_address"
DEFAULT_TRAVEL_TIME = 25
DEFAULT_NAME = "KNX Cover"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_MOVE_LONG_ADDRESS): ga_list_validator,
vol.Optional(CONF_MOVE_SHORT_ADDRESS): ga_list_validator,
vol.Optional(CONF_STOP_ADDRESS): ga_list_validator,
@@ -456,7 +453,6 @@ class CoverSchema(KNXPlatformSchema):
vol.Optional(CoverConf.INVERT_POSITION, default=False): cv.boolean,
vol.Optional(CoverConf.INVERT_ANGLE, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -481,16 +477,12 @@ class DateSchema(KNXPlatformSchema):
PLATFORM = Platform.DATE
DEFAULT_NAME = "KNX Date"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -500,16 +492,12 @@ class DateTimeSchema(KNXPlatformSchema):
PLATFORM = Platform.DATETIME
DEFAULT_NAME = "KNX DateTime"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -571,12 +559,9 @@ class FanSchema(KNXPlatformSchema):
CONF_SWITCH_ADDRESS = "switch_address"
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
DEFAULT_NAME = "KNX Fan"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
@@ -584,7 +569,6 @@ class FanSchema(KNXPlatformSchema):
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
}
),
@@ -629,7 +613,6 @@ class LightSchema(KNXPlatformSchema):
CONF_MIN_KELVIN = "min_kelvin"
CONF_MAX_KELVIN = "max_kelvin"
DEFAULT_NAME = "KNX Light"
DEFAULT_COLOR_TEMP_MODE = "absolute"
DEFAULT_MIN_KELVIN = 2700 # 370 mireds
DEFAULT_MAX_KELVIN = 6000 # 166 mireds
@@ -661,9 +644,8 @@ class LightSchema(KNXPlatformSchema):
)
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_BRIGHTNESS_ADDRESS): ga_list_validator,
@@ -713,7 +695,6 @@ class LightSchema(KNXPlatformSchema):
vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -759,14 +740,10 @@ class NotifySchema(KNXPlatformSchema):
PLATFORM = Platform.NOTIFY
DEFAULT_NAME = "KNX Notify"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator,
vol.Required(KNX_ADDRESS): ga_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -775,12 +752,10 @@ class NumberSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX numbers."""
PLATFORM = Platform.NUMBER
DEFAULT_NAME = "KNX Number"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_MODE, default=NumberMode.AUTO): vol.Coerce(
NumberMode
@@ -793,7 +768,6 @@ class NumberSchema(KNXPlatformSchema):
vol.Optional(NumberConf.STEP): cv.positive_float,
vol.Optional(CONF_DEVICE_CLASS): NUMBER_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
_number_limit_sub_validator,
@@ -807,15 +781,12 @@ class SceneSchema(KNXPlatformSchema):
CONF_SCENE_NUMBER = "scene_number"
DEFAULT_NAME = "KNX SCENE"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Required(SceneConf.SCENE_NUMBER): vol.All(
vol.Coerce(int), vol.Range(min=1, max=64)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -827,12 +798,10 @@ class SelectSchema(KNXPlatformSchema):
CONF_OPTION = "option"
CONF_OPTIONS = "options"
DEFAULT_NAME = "KNX Select"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Required(CONF_PAYLOAD_LENGTH): vol.All(
@@ -846,7 +815,6 @@ class SelectSchema(KNXPlatformSchema):
],
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
select_options_sub_validator,
@@ -861,12 +829,10 @@ class SensorSchema(KNXPlatformSchema):
CONF_ALWAYS_CALLBACK = "always_callback"
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_SYNC_STATE = CONF_SYNC_STATE
DEFAULT_NAME = "KNX Sensor"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean,
vol.Optional(CONF_SENSOR_STATE_CLASS): STATE_CLASSES_SCHEMA,
@@ -874,7 +840,6 @@ class SensorSchema(KNXPlatformSchema):
vol.Required(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
_sensor_attribute_sub_validator,
@@ -889,16 +854,13 @@ class SwitchSchema(KNXPlatformSchema):
CONF_INVERT = CONF_INVERT
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
DEFAULT_NAME = "KNX Switch"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_INVERT, default=False): cv.boolean,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_DEVICE_CLASS): SWITCH_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -908,17 +870,13 @@ class TextSchema(KNXPlatformSchema):
PLATFORM = Platform.TEXT
DEFAULT_NAME = "KNX Text"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator,
vol.Optional(CONF_MODE, default=TextMode.TEXT): vol.Coerce(TextMode),
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -928,16 +886,12 @@ class TimeSchema(KNXPlatformSchema):
PLATFORM = Platform.TIME
DEFAULT_NAME = "KNX Time"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -962,27 +916,21 @@ class WeatherSchema(KNXPlatformSchema):
CONF_KNX_AIR_PRESSURE_ADDRESS = "address_air_pressure"
CONF_KNX_HUMIDITY_ADDRESS = "address_humidity"
DEFAULT_NAME = "KNX Weather Station"
ENTITY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_BEARING_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_BEARING_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): ga_list_validator,
}
)

View File

@@ -65,9 +65,12 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX select."""
self._device = _create_raw_value(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_raw_value(knx_module.xknx, config),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._option_payloads: dict[str, int] = {
option[SelectSchema.CONF_OPTION]: option[CONF_PAYLOAD]
@@ -75,8 +78,6 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
}
self._attr_options = list(self._option_payloads)
self._attr_current_option = None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
async def async_added_to_hass(self) -> None:
"""Restore last state."""

View File

@@ -202,16 +202,19 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
self._device = XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.sensor_value.group_address_state),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
@@ -220,7 +223,6 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
CONF_DEVICE_CLASS,
dpt_info["sensor_device_class"],
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_extra_state_attributes = {}
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_native_unit_of_measurement = config.get(
@@ -231,7 +233,6 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
CONF_STATE_CLASS,
dpt_info["sensor_state_class"],
)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
class KnxUiSensor(_KnxSensor, KnxUiEntity):

View File

@@ -107,20 +107,21 @@ class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX switch."""
self._device = XknxSwitch(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
invert=config[SwitchSchema.CONF_INVERT],
)
super().__init__(
knx_module=knx_module,
device=XknxSwitch(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
invert=config[SwitchSchema.CONF_INVERT],
),
unique_id=str(self._device.switch.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_unique_id = str(self._device.switch.group_address)
class KnxUiSwitch(_KnxSwitch, KnxUiEntity):

View File

@@ -112,20 +112,21 @@ class KnxYamlText(_KnxText, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX text."""
self._device = XknxNotification(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=XknxNotification(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_mode = config[CONF_MODE]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiText(_KnxText, KnxUiEntity):

View File

@@ -105,20 +105,21 @@ class KnxYamlTime(_KNXTime, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX time."""
self._device = XknxTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiTime(_KNXTime, KnxUiEntity):

View File

@@ -85,12 +85,13 @@ class KNXWeather(KnxYamlEntity, WeatherEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
self._device = _create_weather(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_weather(knx_module.xknx, config),
unique_id=str(self._device._temperature.group_address_state), # noqa: SLF001
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_unique_id = str(self._device._temperature.group_address_state) # noqa: SLF001
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
@property
def native_temperature(self) -> float | None:

View File

@@ -617,8 +617,10 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
pyrolytic = 323
descale = 326
evaporate_water = 327
rinse = 333
shabbat_program = 335
yom_tov = 336
hydroclean = 341
drying = 357, 2028
heat_crockery = 358
prove_dough = 359, 2023
@@ -723,7 +725,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
belgian_sponge_cake = 624
goose_unstuffed = 625
rack_of_lamb_with_vegetables = 634
yorkshire_pudding = 635
yorkshire_pudding = 635, 2352
meat_loaf = 636
defrost_meat = 647
defrost_vegetables = 654
@@ -1123,7 +1125,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
wholegrain_rice = 3376
parboiled_rice_steam_cooking = 3380
parboiled_rice_rapid_steam_cooking = 3381
basmati_rice_steam_cooking = 3383
basmati_rice_steam_cooking = 3382, 3383
basmati_rice_rapid_steam_cooking = 3384
jasmine_rice_steam_cooking = 3386
jasmine_rice_rapid_steam_cooking = 3387
@@ -1131,7 +1133,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
huanghuanian_rapid_steam_cooking = 3390
simiao_steam_cooking = 3392
simiao_rapid_steam_cooking = 3393
long_grain_rice_general_steam_cooking = 3395
long_grain_rice_general_steam_cooking = 3394, 3395
long_grain_rice_general_rapid_steam_cooking = 3396
chongming_steam_cooking = 3398
chongming_rapid_steam_cooking = 3399

View File

@@ -560,6 +560,7 @@
"hot_water": "Hot water",
"huanghuanian_rapid_steam_cooking": "Huanghuanian (rapid steam cooking)",
"huanghuanian_steam_cooking": "Huanghuanian (steam cooking)",
"hydroclean": "HydroClean",
"hygiene": "Hygiene",
"intensive": "Intensive",
"intensive_bake": "Intensive bake",

View File

@@ -13,6 +13,7 @@
"documentation": "https://www.home-assistant.io/integrations/opendisplay",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["opendisplay"],
"quality_scale": "silver",
"requirements": ["py-opendisplay==5.5.0"]
}

View File

@@ -13,5 +13,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["oralb_ble"],
"requirements": ["oralb-ble==1.0.2"]
"requirements": ["oralb-ble==1.1.0"]
}

View File

@@ -20,12 +20,18 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import (
RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
)
from .entity import RoborockCoordinatedEntityA01, RoborockEntity, RoborockEntityV1
from .entity import (
RoborockCoordinatedEntityA01,
RoborockCoordinatedEntityB01Q10,
RoborockEntity,
RoborockEntityV1,
)
_LOGGER = logging.getLogger(__name__)
@@ -97,6 +103,14 @@ ZEO_BUTTON_DESCRIPTIONS = [
]
Q10_BUTTON_DESCRIPTIONS = [
ButtonEntityDescription(
key="empty_dustbin",
translation_key="empty_dustbin",
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
@@ -139,6 +153,15 @@ async def async_setup_entry(
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
for description in ZEO_BUTTON_DESCRIPTIONS
),
(
RoborockQ10EmptyDustbinButtonEntity(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.b01_q10
if isinstance(coordinator, RoborockB01Q10UpdateCoordinator)
for description in Q10_BUTTON_DESCRIPTIONS
),
)
)
@@ -233,3 +256,37 @@ class RoborockButtonEntityA01(RoborockCoordinatedEntityA01, ButtonEntity):
) from err
finally:
await self.coordinator.async_request_refresh()
class RoborockQ10EmptyDustbinButtonEntity(
RoborockCoordinatedEntityB01Q10, ButtonEntity
):
"""A class to define Q10 empty dustbin button entity."""
entity_description: ButtonEntityDescription
coordinator: RoborockB01Q10UpdateCoordinator
def __init__(
self,
coordinator: RoborockB01Q10UpdateCoordinator,
entity_description: ButtonEntityDescription,
) -> None:
"""Create a Q10 empty dustbin button entity."""
self.entity_description = entity_description
super().__init__(
f"{entity_description.key}_{coordinator.duid_slug}",
coordinator,
)
async def async_press(self, **kwargs: Any) -> None:
"""Press the button to empty dustbin."""
try:
await self.coordinator.api.vacuum.empty_dustbin()
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "empty_dustbin",
},
) from err

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==4.25.0",
"python-roborock==4.26.3",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -84,6 +84,9 @@
}
},
"button": {
"empty_dustbin": {
"name": "Empty dustbin"
},
"pause": {
"name": "Pause"
},

View File

@@ -208,6 +208,16 @@ CAPABILITY_TO_SENSORS: dict[
supported_states_attributes=Attribute.SUPPORTED_COOKTOP_OPERATING_STATE,
)
},
Capability.SAMSUNG_CE_CLEAN_STATION_STICK_STATUS: {
Attribute.STATUS: SmartThingsBinarySensorEntityDescription(
key=Attribute.STATUS,
component_translation_key={
"station": "stick_cleaner_status",
},
exists_fn=lambda component, _: component == "station",
is_on_key="attached",
)
},
Capability.SAMSUNG_CE_MICROFIBER_FILTER_STATUS: {
Attribute.STATUS: SmartThingsBinarySensorEntityDescription(
key=Attribute.STATUS,

View File

@@ -85,6 +85,9 @@
"robot_cleaner_dust_bag": {
"name": "Dust bag full"
},
"stick_cleaner_status": {
"name": "Stick cleaner in station"
},
"sub_remote_control": {
"name": "Upper washer remote control"
},

View File

@@ -105,6 +105,7 @@ SENSORS: list[SmSensorEntityDescription] = [
),
]
EXTRA_SENSOR = SmSensorEntityDescription(
key="zigbee_temperature_2",
translation_key="zigbee_temperature",
@@ -115,6 +116,15 @@ EXTRA_SENSOR = SmSensorEntityDescription(
value_fn=lambda x: x.zb_temp2,
)
PSRAM_SENSOR = SmSensorEntityDescription(
key="psram_usage",
translation_key="psram_usage",
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.KILOBYTES,
entity_registry_enabled_default=False,
value_fn=lambda x: x.psram_usage,
)
UPTIME: list[SmSensorEntityDescription] = [
SmSensorEntityDescription(
key="core_uptime",
@@ -156,6 +166,9 @@ async def async_setup_entry(
if coordinator.data.sensors.zb_temp2 is not None:
entities.append(SmSensorEntity(coordinator, EXTRA_SENSOR))
if coordinator.data.info.u_device:
entities.append(SmSensorEntity(coordinator, PSRAM_SENSOR))
async_add_entities(entities)

View File

@@ -104,6 +104,9 @@
"fs_usage": {
"name": "Filesystem usage"
},
"psram_usage": {
"name": "PSRAM usage"
},
"ram_usage": {
"name": "RAM usage"
},

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/starlink",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["starlink-grpc-core==1.2.3"]
"requirements": ["starlink-grpc-core==1.2.4"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["aiotedee"],
"quality_scale": "platinum",
"requirements": ["aiotedee==0.2.25"]
"requirements": ["aiotedee==0.2.27"]
}

View File

@@ -0,0 +1,17 @@
"""Integration for temperature triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "temperature"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"changed": {
"trigger": "mdi:thermometer"
},
"crossed_threshold": {
"trigger": "mdi:thermometer"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "temperature",
"name": "Temperature",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/temperature",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,76 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above",
"below": "Below",
"between": "Between",
"outside": "Outside"
}
}
},
"title": "Temperature",
"triggers": {
"changed": {
"description": "Triggers when the temperature changes.",
"fields": {
"above": {
"description": "Only trigger when temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when temperature is below this value.",
"name": "Below"
},
"unit": {
"description": "All values will be converted to this unit when evaluating the trigger.",
"name": "Unit of measurement"
}
},
"name": "Temperature changed"
},
"crossed_threshold": {
"description": "Triggers when the temperature crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::temperature::common::trigger_behavior_description%]",
"name": "[%key:component::temperature::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "The lower limit of the threshold.",
"name": "Lower limit"
},
"threshold_type": {
"description": "The type of threshold to use.",
"name": "Threshold type"
},
"unit": {
"description": "[%key:component::temperature::triggers::changed::fields::unit::description%]",
"name": "[%key:component::temperature::triggers::changed::fields::unit::name%]"
},
"upper_limit": {
"description": "The upper limit of the threshold.",
"name": "Upper limit"
}
},
"name": "Temperature crossed threshold"
}
}
}

View File

@@ -0,0 +1,83 @@
"""Provides triggers for temperature."""
from __future__ import annotations
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.water_heater import (
ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
DOMAIN as WATER_HEATER_DOMAIN,
)
from homeassistant.components.weather import (
ATTR_WEATHER_TEMPERATURE,
ATTR_WEATHER_TEMPERATURE_UNIT,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerWithUnitBase,
Trigger,
)
from homeassistant.util.unit_conversion import TemperatureConverter
TEMPERATURE_DOMAIN_SPECS = {
CLIMATE_DOMAIN: NumericalDomainSpec(
value_source=CLIMATE_ATTR_CURRENT_TEMPERATURE,
),
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.TEMPERATURE,
),
WATER_HEATER_DOMAIN: NumericalDomainSpec(
value_source=WATER_HEATER_ATTR_CURRENT_TEMPERATURE
),
WEATHER_DOMAIN: NumericalDomainSpec(
value_source=ATTR_WEATHER_TEMPERATURE,
),
}
class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
"""Mixin for temperature triggers providing entity filtering, value extraction, and unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = TEMPERATURE_DOMAIN_SPECS
_unit_converter = TemperatureConverter
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of an entity from its state."""
if state.domain == SENSOR_DOMAIN:
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if state.domain == WEATHER_DOMAIN:
return state.attributes.get(ATTR_WEATHER_TEMPERATURE_UNIT)
# Climate and water_heater: show_temp converts to system unit
return self._hass.config.units.temperature_unit
class TemperatureChangedTrigger(
_TemperatureTriggerMixin, EntityNumericalStateChangedTriggerWithUnitBase
):
"""Trigger for temperature value changes across multiple domains."""
class TemperatureCrossedThresholdTrigger(
_TemperatureTriggerMixin, EntityNumericalStateCrossedThresholdTriggerWithUnitBase
):
"""Trigger for temperature value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": TemperatureChangedTrigger,
"crossed_threshold": TemperatureCrossedThresholdTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for temperature."""
return TRIGGERS

View File

@@ -0,0 +1,77 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "°C"
- "°F"
- domain: sensor
device_class: temperature
- domain: number
device_class: temperature
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
.trigger_unit: &trigger_unit
required: false
selector:
select:
options:
- "°C"
- "°F"
.trigger_target: &trigger_target
entity:
- domain: sensor
device_class: temperature
- domain: climate
- domain: water_heater
- domain: weather
changed:
target: *trigger_target
fields:
above: *number_or_entity
below: *number_or_entity
unit: *trigger_unit
crossed_threshold:
target: *trigger_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
unit: *trigger_unit

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["wolf_comm"],
"requirements": ["wolf-comm==0.0.23"]
"requirements": ["wolf-comm==0.0.48"]
}

View File

@@ -25,5 +25,5 @@
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["xiaomi-ble==1.6.0"]
"requirements": ["xiaomi-ble==1.10.0"]
}

View File

@@ -39,7 +39,7 @@ class DomainSpec:
class NumericalDomainSpec(DomainSpec):
"""DomainSpec with an optional value converter for numerical triggers."""
value_converter: Callable[[Any], float] | None = None
value_converter: Callable[[float], float] | None = None
"""Optional converter for numerical values (e.g. uint8 → percentage)."""

View File

@@ -26,6 +26,7 @@ import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ABOVE,
CONF_ALIAS,
CONF_BELOW,
@@ -64,6 +65,7 @@ from homeassistant.loader import (
)
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.unit_conversion import BaseUnitConverter
from homeassistant.util.yaml import load_yaml_dict
from . import config_validation as cv, selector
@@ -519,7 +521,7 @@ def _validate_range[_T: dict[str, Any]](
) -> Callable[[_T], _T]:
"""Generate range validator."""
def _validate_range(value: _T) -> _T:
def _validate_range_impl(value: _T) -> _T:
above = value.get(lower_limit)
below = value.get(upper_limit)
@@ -539,7 +541,28 @@ def _validate_range[_T: dict[str, Any]](
return value
return _validate_range
return _validate_range_impl
CONF_UNIT: Final = "unit"
def _validate_unit_set_if_range_numerical[_T: dict[str, Any]](
lower_limit: str, upper_limit: str
) -> Callable[[_T], _T]:
"""Validate that unit is set if upper or lower limit is numerical."""
def _validate_unit_set_if_range_numerical_impl(options: _T) -> _T:
if (
any(
opt in options and not isinstance(options[opt], str)
for opt in (lower_limit, upper_limit)
)
) and options.get(CONF_UNIT) is None:
raise vol.Invalid("Unit must be specified when using numerical thresholds.")
return options
return _validate_unit_set_if_range_numerical_impl
_NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema(
@@ -576,38 +599,107 @@ NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
def _get_numerical_value(
hass: HomeAssistant, entity_or_float: float | str
) -> float | None:
"""Get numerical value from float or entity state."""
if isinstance(entity_or_float, str):
if not (state := hass.states.get(entity_or_float)):
# Entity not found
return None
try:
return float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
return None
return entity_or_float
class EntityNumericalStateTriggerBase(EntityTriggerBase[NumericalDomainSpec]):
"""Base class for numerical state and state attribute triggers."""
def _get_tracked_value(self, state: State) -> Any:
def _get_numerical_value(self, entity_or_float: float | str) -> float | None:
"""Get numerical value from float or entity state."""
if isinstance(entity_or_float, str):
if not (state := self._hass.states.get(entity_or_float)):
# Entity not found
return None
try:
return float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
return None
return entity_or_float
def _get_tracked_value(self, state: State) -> float | None:
"""Get the tracked numerical value from a state."""
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
return state.state
return state.attributes.get(domain_spec.value_source)
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
def _get_converter(self, state: State) -> Callable[[Any], float]:
try:
return float(raw_value)
except TypeError, ValueError:
# 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 float
return lambda x: x
class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
"""Base class for numerical state and state attribute triggers."""
_base_unit: str # Base unit for the tracked value
_manual_limit_unit: str | None # Unit of above/below limits when numbers
_unit_converter: type[BaseUnitConverter]
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the trigger."""
super().__init__(hass, config)
self._manual_limit_unit = self._options.get(CONF_UNIT)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the unit of an entity from its state."""
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
def _get_numerical_value(self, entity_or_float: float | str) -> float | None:
"""Get numerical value from float or entity state."""
if isinstance(entity_or_float, (int, float)):
return self._unit_converter.convert(
entity_or_float, self._manual_limit_unit, self._base_unit
)
if not (state := self._hass.states.get(entity_or_float)):
# Entity not found
return None
try:
value = float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
return None
try:
return self._unit_converter.convert(
value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit
)
except HomeAssistantError:
# Unit conversion failed (i.e. incompatible units), treat as invalid number
return None
def _get_tracked_value(self, state: State) -> float | None:
"""Get the tracked numerical value from a state."""
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
try:
value = float(raw_value)
except TypeError, ValueError:
# Entity state is not a valid number
return None
try:
return self._unit_converter.convert(
value, self._get_entity_unit(state), self._base_unit
)
except HomeAssistantError:
# Unit conversion failed (i.e. incompatible units), treat as invalid number
return None
class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
@@ -629,7 +721,7 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return self._get_tracked_value(from_state) != self._get_tracked_value(to_state) # type: ignore[no-any-return]
return self._get_tracked_value(from_state) != self._get_tracked_value(to_state)
def is_valid_state(self, state: State) -> bool:
"""Check if the new state or state attribute matches the expected one."""
@@ -637,14 +729,10 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
if (_attribute_value := self._get_tracked_value(state)) is None:
return False
try:
current_value = self._get_converter(state)(_attribute_value)
except TypeError, ValueError:
# Value is not a valid number, don't trigger
return False
current_value = self._get_converter(state)(_attribute_value)
if self._above is not None:
if (above := _get_numerical_value(self._hass, self._above)) is None:
if (above := self._get_numerical_value(self._above)) is None:
# Entity not found or invalid number, don't trigger
return False
if current_value <= above:
@@ -652,7 +740,7 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
return False
if self._below is not None:
if (below := _get_numerical_value(self._hass, self._below)) is None:
if (below := self._get_numerical_value(self._below)) is None:
# Entity not found or invalid number, don't trigger
return False
if current_value >= below:
@@ -662,6 +750,37 @@ class EntityNumericalStateChangedTriggerBase(EntityNumericalStateTriggerBase):
return True
def make_numerical_state_changed_with_unit_schema(
unit_converter: type[BaseUnitConverter],
) -> vol.Schema:
"""Factory for numerical state trigger schema with unit option."""
return ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): vol.All(
{
vol.Optional(CONF_ABOVE): _number_or_entity,
vol.Optional(CONF_BELOW): _number_or_entity,
vol.Optional(CONF_UNIT): vol.In(unit_converter.VALID_UNITS),
},
_validate_range(CONF_ABOVE, CONF_BELOW),
_validate_unit_set_if_range_numerical(CONF_ABOVE, CONF_BELOW),
)
}
)
class EntityNumericalStateChangedTriggerWithUnitBase(
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
):
"""Trigger for numerical state and state attribute changes."""
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Create a schema."""
super().__init_subclass__(**kwargs)
cls._schema = make_numerical_state_changed_with_unit_schema(cls._unit_converter)
CONF_LOWER_LIMIT = "lower_limit"
CONF_UPPER_LIMIT = "upper_limit"
CONF_THRESHOLD_TYPE = "threshold_type"
@@ -744,16 +863,12 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
def is_valid_state(self, state: State) -> bool:
"""Check if the new state attribute matches the expected one."""
if self._lower_limit is not None:
if (
lower_limit := _get_numerical_value(self._hass, self._lower_limit)
) is None:
if (lower_limit := self._get_numerical_value(self._lower_limit)) is None:
# Entity not found or invalid number, don't trigger
return False
if self._upper_limit is not None:
if (
upper_limit := _get_numerical_value(self._hass, self._upper_limit)
) is None:
if (upper_limit := self._get_numerical_value(self._upper_limit)) is None:
# Entity not found or invalid number, don't trigger
return False
@@ -761,11 +876,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
if (_attribute_value := self._get_tracked_value(state)) is None:
return False
try:
current_value = self._get_converter(state)(_attribute_value)
except TypeError, ValueError:
# Value is not a valid number, don't trigger
return False
current_value = self._get_converter(state)(_attribute_value)
# Note: We do not need to check for lower_limit/upper_limit being None here
# because of the validation done in the schema.
@@ -781,6 +892,50 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
return not between
def make_numerical_state_crossed_threshold_with_unit_schema(
unit_converter: type[BaseUnitConverter],
) -> vol.Schema:
"""Trigger for numerical state and state attribute changes.
This trigger only fires when the observed attribute changes from not within to within
the defined threshold.
"""
return ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): vol.All(
{
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
),
vol.Optional(CONF_LOWER_LIMIT): _number_or_entity,
vol.Optional(CONF_UPPER_LIMIT): _number_or_entity,
vol.Required(CONF_THRESHOLD_TYPE): vol.Coerce(ThresholdType),
vol.Optional(CONF_UNIT): vol.In(unit_converter.VALID_UNITS),
},
_validate_range(CONF_LOWER_LIMIT, CONF_UPPER_LIMIT),
_validate_limits_for_threshold_type,
_validate_unit_set_if_range_numerical(
CONF_LOWER_LIMIT, CONF_UPPER_LIMIT
),
)
}
)
class EntityNumericalStateCrossedThresholdTriggerWithUnitBase(
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
):
"""Trigger for numerical state and state attribute changes."""
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Create a schema."""
super().__init_subclass__(**kwargs)
cls._schema = make_numerical_state_crossed_threshold_with_unit_schema(
cls._unit_converter
)
def _normalize_domain_specs(
domain_specs: Mapping[str, DomainSpec] | str,
) -> Mapping[str, DomainSpec]:

View File

@@ -35,7 +35,7 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.10.2
habluetooth==5.11.1
hass-nabucasa==2.0.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1

14
requirements_all.txt generated
View File

@@ -422,7 +422,7 @@ aiosyncthing==0.7.1
aiotankerkoenig==0.5.1
# homeassistant.components.tedee
aiotedee==0.2.25
aiotedee==0.2.27
# homeassistant.components.tractive
aiotractive==1.0.0
@@ -1176,7 +1176,7 @@ ha-silabs-firmware-client==0.3.0
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.10.2
habluetooth==5.11.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1732,7 +1732,7 @@ openwrt-ubus-rpc==0.0.2
opower==0.17.1
# homeassistant.components.oralb
oralb-ble==1.0.2
oralb-ble==1.1.0
# homeassistant.components.oru
oru==0.1.11
@@ -2660,7 +2660,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==4.25.0
python-roborock==4.26.3
# homeassistant.components.smarttub
python-smarttub==0.0.47
@@ -3014,7 +3014,7 @@ starline==0.1.5
starlingbank==3.2
# homeassistant.components.starlink
starlink-grpc-core==1.2.3
starlink-grpc-core==1.2.4
# homeassistant.components.statsd
statsd==3.2.1
@@ -3310,7 +3310,7 @@ wirelesstagpy==0.8.1
wled==0.21.0
# homeassistant.components.wolflink
wolf-comm==0.0.23
wolf-comm==0.0.48
# homeassistant.components.wsdot
wsdot==0.0.1
@@ -3319,7 +3319,7 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.6.0
xiaomi-ble==1.10.0
# homeassistant.components.knx
xknx==3.15.0

View File

@@ -407,7 +407,7 @@ aiosyncthing==0.7.1
aiotankerkoenig==0.5.1
# homeassistant.components.tedee
aiotedee==0.2.25
aiotedee==0.2.27
# homeassistant.components.tractive
aiotractive==1.0.0
@@ -1046,7 +1046,7 @@ ha-silabs-firmware-client==0.3.0
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.10.2
habluetooth==5.11.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1509,7 +1509,7 @@ openwebifpy==4.3.1
opower==0.17.1
# homeassistant.components.oralb
oralb-ble==1.0.2
oralb-ble==1.1.0
# homeassistant.components.orvibo
orvibo==1.1.2
@@ -2256,7 +2256,7 @@ python-pooldose==0.8.6
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==4.25.0
python-roborock==4.26.3
# homeassistant.components.smarttub
python-smarttub==0.0.47
@@ -2547,7 +2547,7 @@ srpenergy==1.3.6
starline==0.1.5
# homeassistant.components.starlink
starlink-grpc-core==1.2.3
starlink-grpc-core==1.2.4
# homeassistant.components.statsd
statsd==3.2.1
@@ -2792,7 +2792,7 @@ wiim==0.1.0
wled==0.21.0
# homeassistant.components.wolflink
wolf-comm==0.0.23
wolf-comm==0.0.48
# homeassistant.components.wsdot
wsdot==0.0.1
@@ -2801,7 +2801,7 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.6.0
xiaomi-ble==1.10.0
# homeassistant.components.knx
xknx==3.15.0

View File

@@ -120,6 +120,7 @@ NO_IOT_CLASS = [
"system_health",
"system_log",
"tag",
"temperature",
"timer",
"trace",
"web_rtc",

View File

@@ -2149,6 +2149,7 @@ NO_QUALITY_SCALE = [
"system_health",
"system_log",
"tag",
"temperature",
"timer",
"trace",
"usage_prediction",

View File

@@ -518,54 +518,75 @@ def parametrize_trigger_states(
def parametrize_numerical_attribute_changed_trigger_states(
trigger: str, state: str, attribute: str
trigger: str,
state: str,
attribute: str,
*,
trigger_options: dict[str, Any] | None = None,
required_filter_attributes: dict | None = None,
unit_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical changed triggers."""
trigger_options = trigger_options or {}
unit_attributes = unit_attributes or {}
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={},
trigger_options={**trigger_options},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 50}),
(state, {attribute: 100}),
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
other_states=[(state, {attribute: None})],
other_states=[(state, {attribute: None} | unit_attributes)],
required_filter_attributes=required_filter_attributes,
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
trigger_options={CONF_ABOVE: 10, **trigger_options},
target_states=[
(state, {attribute: 50}),
(state, {attribute: 100}),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 0}),
(state, {attribute: None} | unit_attributes),
(state, {attribute: 0} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
retrigger_on_target_state=True,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
trigger_options={CONF_BELOW: 90, **trigger_options},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 50}),
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 100}),
(state, {attribute: None} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
retrigger_on_target_state=True,
),
]
def parametrize_numerical_attribute_crossed_threshold_trigger_states(
trigger: str, state: str, attribute: str
trigger: str,
state: str,
attribute: str,
*,
trigger_options: dict[str, Any] | None = None,
required_filter_attributes: dict | None = None,
unit_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical crossed threshold triggers."""
trigger_options = trigger_options or {}
unit_attributes = unit_attributes or {}
return [
*parametrize_trigger_states(
trigger=trigger,
@@ -573,16 +594,18 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
**trigger_options,
},
target_states=[
(state, {attribute: 50}),
(state, {attribute: 60}),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 60} | unit_attributes),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 0}),
(state, {attribute: 100}),
(state, {attribute: None} | unit_attributes),
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
*parametrize_trigger_states(
trigger=trigger,
@@ -590,52 +613,62 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
**trigger_options,
},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 100}),
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 50}),
(state, {attribute: 60}),
(state, {attribute: None} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 60} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
**trigger_options,
},
target_states=[
(state, {attribute: 50}),
(state, {attribute: 100}),
(state, {attribute: 50} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 0}),
(state, {attribute: None} | unit_attributes),
(state, {attribute: 0} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
**trigger_options,
},
target_states=[
(state, {attribute: 0}),
(state, {attribute: 50}),
(state, {attribute: 0} | unit_attributes),
(state, {attribute: 50} | unit_attributes),
],
other_states=[
(state, {attribute: None}),
(state, {attribute: 100}),
(state, {attribute: None} | unit_attributes),
(state, {attribute: 100} | unit_attributes),
],
required_filter_attributes=required_filter_attributes,
),
]
def parametrize_numerical_state_value_changed_trigger_states(
trigger: str, device_class: str
trigger: str,
*,
device_class: str,
trigger_options: dict[str, Any] | None = None,
unit_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical state-value changed triggers.
@@ -646,30 +679,37 @@ def parametrize_numerical_state_value_changed_trigger_states(
from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415
required_filter_attributes = {ATTR_DEVICE_CLASS: device_class}
trigger_options = trigger_options or {}
unit_attributes = unit_attributes or {}
return [
*parametrize_trigger_states(
trigger=trigger,
trigger_options={},
target_states=["0", "50", "100"],
other_states=["none"],
trigger_options=trigger_options,
target_states=[
("0", unit_attributes),
("50", unit_attributes),
("100", unit_attributes),
],
other_states=[("none", unit_attributes)],
required_filter_attributes=required_filter_attributes,
retrigger_on_target_state=True,
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_ABOVE: 10},
target_states=["50", "100"],
other_states=["none", "0"],
trigger_options={CONF_ABOVE: 10} | trigger_options,
target_states=[("50", unit_attributes), ("100", unit_attributes)],
other_states=[("none", unit_attributes), ("0", unit_attributes)],
required_filter_attributes=required_filter_attributes,
retrigger_on_target_state=True,
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger=trigger,
trigger_options={CONF_BELOW: 90},
target_states=["0", "50"],
other_states=["none", "100"],
trigger_options={CONF_BELOW: 90} | trigger_options,
target_states=[("0", unit_attributes), ("50", unit_attributes)],
other_states=[("none", unit_attributes), ("100", unit_attributes)],
required_filter_attributes=required_filter_attributes,
retrigger_on_target_state=True,
trigger_from_none=False,
@@ -678,7 +718,11 @@ def parametrize_numerical_state_value_changed_trigger_states(
def parametrize_numerical_state_value_crossed_threshold_trigger_states(
trigger: str, device_class: str
trigger: str,
*,
device_class: str,
trigger_options: dict[str, Any] | None = None,
unit_attributes: dict | None = None,
) -> list[tuple[str, dict[str, Any], list[TriggerStateDescription]]]:
"""Parametrize states and expected service call counts for numerical state-value crossed threshold triggers.
@@ -689,6 +733,9 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
from homeassistant.const import ATTR_DEVICE_CLASS # noqa: PLC0415
required_filter_attributes = {ATTR_DEVICE_CLASS: device_class}
trigger_options = trigger_options or {}
unit_attributes = unit_attributes or {}
return [
*parametrize_trigger_states(
trigger=trigger,
@@ -696,9 +743,14 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
**trigger_options,
},
target_states=["50", "60"],
other_states=["none", "0", "100"],
target_states=[("50", unit_attributes), ("60", unit_attributes)],
other_states=[
("none", unit_attributes),
("0", unit_attributes),
("100", unit_attributes),
],
required_filter_attributes=required_filter_attributes,
trigger_from_none=False,
),
@@ -708,9 +760,14 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
**trigger_options,
},
target_states=["0", "100"],
other_states=["none", "50", "60"],
target_states=[("0", unit_attributes), ("100", unit_attributes)],
other_states=[
("none", unit_attributes),
("50", unit_attributes),
("60", unit_attributes),
],
required_filter_attributes=required_filter_attributes,
trigger_from_none=False,
),
@@ -719,9 +776,10 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
CONF_LOWER_LIMIT: 10,
**trigger_options,
},
target_states=["50", "100"],
other_states=["none", "0"],
target_states=[("50", unit_attributes), ("100", unit_attributes)],
other_states=[("none", unit_attributes), ("0", unit_attributes)],
required_filter_attributes=required_filter_attributes,
trigger_from_none=False,
),
@@ -730,9 +788,10 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
trigger_options={
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
CONF_UPPER_LIMIT: 90,
**trigger_options,
},
target_states=["0", "50"],
other_states=["none", "100"],
target_states=[("0", unit_attributes), ("50", unit_attributes)],
other_states=[("none", unit_attributes), ("100", unit_attributes)],
required_filter_attributes=required_filter_attributes,
trigger_from_none=False,
),

View File

@@ -0,0 +1,375 @@
# serializer version: 1
# name: test_diagnostics
dict({
'entry': dict({
'created_at': '2026-03-20T21:22:23+00:00',
'data': dict({
'api_key': '**REDACTED**',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'google_weather',
'minor_version': 1,
'modified_at': '2026-03-20T21:22:23+00:00',
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
dict({
'data': dict({
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
}),
'subentry_id': 'home-subentry-id',
'subentry_type': 'location',
'title': 'Home',
'unique_id': None,
}),
]),
'title': 'Google Weather',
'unique_id': None,
'version': 1,
}),
'subentries': dict({
'home-subentry-id': dict({
'daily_forecast_data': dict({
'forecast_days': list([
dict({
'daytime_forecast': dict({
'cloud_cover': 53,
'ice_thickness': None,
'interval': dict({
'end_time': '2025-02-11T03:00:00Z',
'start_time': '2025-02-10T15:00:00Z',
}),
'precipitation': dict({
'probability': dict({
'percent': 5,
'type': 'RAIN',
}),
'qpf': dict({
'quantity': 0.0,
'unit': 'MILLIMETERS',
}),
'snow_qpf': None,
}),
'relative_humidity': 54,
'thunderstorm_probability': 0,
'uv_index': 3,
'weather_condition': dict({
'description': dict({
'language_code': 'en',
'text': 'Partly sunny',
}),
'icon_base_uri': 'https://maps.gstatic.com/weather/v1/party_cloudy',
'type': 'PARTLY_CLOUDY',
}),
'wind': dict({
'direction': dict({
'cardinal': 'WEST',
'degrees': 280,
}),
'gust': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 14.0,
}),
'speed': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 6.0,
}),
}),
}),
'display_date': dict({
'day': 10,
'month': 2,
'year': 2025,
}),
'feels_like_max_temperature': dict({
'degrees': 13.3,
'unit': 'CELSIUS',
}),
'feels_like_min_temperature': dict({
'degrees': 1.5,
'unit': 'CELSIUS',
}),
'ice_thickness': dict({
'thickness': 0.0,
'unit': 'MILLIMETERS',
}),
'interval': dict({
'end_time': '2025-02-11T15:00:00Z',
'start_time': '2025-02-10T15:00:00Z',
}),
'max_heat_index': dict({
'degrees': 13.3,
'unit': 'CELSIUS',
}),
'max_temperature': dict({
'degrees': 13.3,
'unit': 'CELSIUS',
}),
'min_temperature': dict({
'degrees': 1.5,
'unit': 'CELSIUS',
}),
'moon_events': dict({
'moon_phase': 'WAXING_GIBBOUS',
'moonrise_times': list([
'2025-02-10T23:54:17.713157984Z',
]),
'moonset_times': list([
'2025-02-10T14:13:58.625181191Z',
]),
}),
'nighttime_forecast': dict({
'cloud_cover': 70,
'ice_thickness': None,
'interval': dict({
'end_time': '2025-02-11T15:00:00Z',
'start_time': '2025-02-11T03:00:00Z',
}),
'precipitation': dict({
'probability': dict({
'percent': 10,
'type': 'RAIN_AND_SNOW',
}),
'qpf': dict({
'quantity': 0.0,
'unit': 'MILLIMETERS',
}),
'snow_qpf': None,
}),
'relative_humidity': 85,
'thunderstorm_probability': 0,
'uv_index': 0,
'weather_condition': dict({
'description': dict({
'language_code': 'en',
'text': 'Partly cloudy',
}),
'icon_base_uri': 'https://maps.gstatic.com/weather/v1/partly_clear',
'type': 'PARTLY_CLOUDY',
}),
'wind': dict({
'direction': dict({
'cardinal': 'SOUTH_SOUTHWEST',
'degrees': 201,
}),
'gust': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 14.0,
}),
'speed': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 6.0,
}),
}),
}),
'sun_events': dict({
'sunrise_time': '2025-02-10T15:02:35.703929582Z',
'sunset_time': '2025-02-11T01:43:00.762932858Z',
}),
}),
]),
'next_page_token': None,
'time_zone': dict({
'id': 'America/Los_Angeles',
'version': None,
}),
}),
'hourly_forecast_data': dict({
'forecast_hours': list([
dict({
'air_pressure': dict({
'mean_sea_level_millibars': 1019.13,
}),
'cloud_cover': 0,
'dew_point': dict({
'degrees': 2.7,
'unit': 'CELSIUS',
}),
'display_date_time': dict({
'day': 5,
'hours': 15,
'minutes': None,
'month': 2,
'nanos': None,
'seconds': None,
'time_zone': None,
'utc_offset': '-28800s',
'year': 2025,
}),
'feels_like_temperature': dict({
'degrees': 12.0,
'unit': 'CELSIUS',
}),
'heat_index': dict({
'degrees': 12.7,
'unit': 'CELSIUS',
}),
'ice_thickness': dict({
'thickness': 0.0,
'unit': 'MILLIMETERS',
}),
'interval': dict({
'end_time': '2025-02-06T00:00:00Z',
'start_time': '2025-02-05T23:00:00Z',
}),
'is_daytime': True,
'precipitation': dict({
'probability': dict({
'percent': 0,
'type': 'RAIN',
}),
'qpf': dict({
'quantity': 0.0,
'unit': 'MILLIMETERS',
}),
'snow_qpf': None,
}),
'relative_humidity': 51,
'temperature': dict({
'degrees': 12.7,
'unit': 'CELSIUS',
}),
'thunderstorm_probability': 0,
'uv_index': 1,
'visibility': dict({
'distance': 16.0,
'unit': 'KILOMETERS',
}),
'weather_condition': dict({
'description': dict({
'language_code': 'en',
'text': 'Sunny',
}),
'icon_base_uri': 'https://maps.gstatic.com/weather/v1/sunny',
'type': 'CLEAR',
}),
'wet_bulb_temperature': dict({
'degrees': 7.7,
'unit': 'CELSIUS',
}),
'wind': dict({
'direction': dict({
'cardinal': 'NORTH_NORTHWEST',
'degrees': 335,
}),
'gust': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 19.0,
}),
'speed': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 10.0,
}),
}),
'wind_chill': dict({
'degrees': 12.0,
'unit': 'CELSIUS',
}),
}),
]),
'next_page_token': None,
'time_zone': dict({
'id': 'America/Los_Angeles',
'version': None,
}),
}),
'observation_data': dict({
'air_pressure': dict({
'mean_sea_level_millibars': 1019.16,
}),
'cloud_cover': 0,
'current_conditions_history': dict({
'max_temperature': dict({
'degrees': 14.3,
'unit': 'CELSIUS',
}),
'min_temperature': dict({
'degrees': 3.7,
'unit': 'CELSIUS',
}),
'qpf': dict({
'quantity': 0.0,
'unit': 'MILLIMETERS',
}),
'temperature_change': dict({
'degrees': -0.6,
'unit': 'CELSIUS',
}),
}),
'current_time': '2025-01-28T22:04:12.025273178Z',
'dew_point': dict({
'degrees': 1.1,
'unit': 'CELSIUS',
}),
'feels_like_temperature': dict({
'degrees': 13.1,
'unit': 'CELSIUS',
}),
'heat_index': dict({
'degrees': 13.7,
'unit': 'CELSIUS',
}),
'is_daytime': True,
'precipitation': dict({
'probability': dict({
'percent': 0,
'type': 'RAIN',
}),
'qpf': dict({
'quantity': 0.0,
'unit': 'MILLIMETERS',
}),
'snow_qpf': None,
}),
'relative_humidity': 42,
'temperature': dict({
'degrees': 13.7,
'unit': 'CELSIUS',
}),
'thunderstorm_probability': 0,
'time_zone': dict({
'id': 'America/Los_Angeles',
'version': None,
}),
'uv_index': 1,
'visibility': dict({
'distance': 16.0,
'unit': 'KILOMETERS',
}),
'weather_condition': dict({
'description': dict({
'language_code': 'en',
'text': 'Sunny',
}),
'icon_base_uri': 'https://maps.gstatic.com/weather/v1/sunny',
'type': 'CLEAR',
}),
'wind': dict({
'direction': dict({
'cardinal': 'NORTH_NORTHWEST',
'degrees': 335,
}),
'gust': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 18.0,
}),
'speed': dict({
'unit': 'KILOMETERS_PER_HOUR',
'value': 8.0,
}),
}),
'wind_chill': dict({
'degrees': 13.1,
'unit': 'CELSIUS',
}),
}),
}),
}),
})
# ---

View File

@@ -0,0 +1,37 @@
"""Tests for the diagnostics data provided by the Google Weather integration."""
from unittest.mock import AsyncMock
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
@pytest.fixture(autouse=True)
def freeze_the_time():
"""Freeze the time."""
with freeze_time("2026-03-20 21:22:23", tz_offset=0):
yield
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
) == snapshot(exclude=props("entry_id"))

View File

@@ -1,12 +1,23 @@
"""Test humidifier trigger."""
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from typing import Any
import pytest
import voluptuous as vol
from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.components.humidifier.trigger import CONF_MODE
from homeassistant.const import (
ATTR_MODE,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import async_validate_trigger_config
from tests.components.common import (
TriggerStateDescription,
@@ -29,6 +40,7 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"trigger_key",
[
"humidifier.mode_changed",
"humidifier.started_drying",
"humidifier.started_humidifying",
"humidifier.turned_off",
@@ -105,6 +117,17 @@ async def test_humidifier_state_trigger_behavior_any(
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
),
*parametrize_trigger_states(
trigger="humidifier.mode_changed",
trigger_options={CONF_MODE: ["eco", "sleep"]},
target_states=[
(STATE_ON, {ATTR_MODE: "eco"}),
(STATE_ON, {ATTR_MODE: "sleep"}),
],
other_states=[
(STATE_ON, {ATTR_MODE: "normal"}),
],
),
],
)
async def test_humidifier_state_attribute_trigger_behavior_any(
@@ -195,6 +218,17 @@ async def test_humidifier_state_trigger_behavior_first(
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
),
*parametrize_trigger_states(
trigger="humidifier.mode_changed",
trigger_options={CONF_MODE: ["eco", "sleep"]},
target_states=[
(STATE_ON, {ATTR_MODE: "eco"}),
(STATE_ON, {ATTR_MODE: "sleep"}),
],
other_states=[
(STATE_ON, {ATTR_MODE: "normal"}),
],
),
],
)
async def test_humidifier_state_attribute_trigger_behavior_first(
@@ -285,6 +319,17 @@ async def test_humidifier_state_trigger_behavior_last(
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
),
*parametrize_trigger_states(
trigger="humidifier.mode_changed",
trigger_options={CONF_MODE: ["eco", "sleep"]},
target_states=[
(STATE_ON, {ATTR_MODE: "eco"}),
(STATE_ON, {ATTR_MODE: "sleep"}),
],
other_states=[
(STATE_ON, {ATTR_MODE: "normal"}),
],
),
],
)
async def test_humidifier_state_attribute_trigger_behavior_last(
@@ -310,3 +355,53 @@ async def test_humidifier_state_attribute_trigger_behavior_last(
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger", "trigger_options", "expected_result"),
[
# Valid configurations
(
"humidifier.mode_changed",
{CONF_MODE: ["eco", "sleep"]},
does_not_raise(),
),
(
"humidifier.mode_changed",
{CONF_MODE: "eco"},
does_not_raise(),
),
# Invalid configurations
(
"humidifier.mode_changed",
# Empty mode list
{CONF_MODE: []},
pytest.raises(vol.Invalid),
),
(
"humidifier.mode_changed",
# Missing CONF_MODE
{},
pytest.raises(vol.Invalid),
),
],
)
async def test_humidifier_mode_changed_trigger_validation(
hass: HomeAssistant,
trigger: str,
trigger_options: dict[str, Any],
expected_result: AbstractContextManager,
) -> None:
"""Test humidifier mode_changed trigger config validation."""
with expected_result:
await async_validate_trigger_config(
hass,
[
{
"platform": trigger,
CONF_TARGET: {CONF_ENTITY_ID: "humidifier.test"},
CONF_OPTIONS: trigger_options,
}
],
)

View File

@@ -11,6 +11,7 @@ from homeassistant.components.climate import (
from homeassistant.components.humidifier import (
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY
from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
@@ -81,10 +82,10 @@ async def test_humidity_triggers_gated_by_labs_flag(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"humidity.changed", "humidity"
"humidity.changed", device_class=SensorDeviceClass.HUMIDITY
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"humidity.crossed_threshold", "humidity"
"humidity.crossed_threshold", device_class=SensorDeviceClass.HUMIDITY
),
],
)
@@ -122,7 +123,7 @@ async def test_humidity_trigger_sensor_behavior_any(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"humidity.crossed_threshold", "humidity"
"humidity.crossed_threshold", device_class=SensorDeviceClass.HUMIDITY
),
],
)
@@ -160,7 +161,7 @@ async def test_humidity_trigger_sensor_crossed_threshold_behavior_first(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"humidity.crossed_threshold", "humidity"
"humidity.crossed_threshold", device_class=SensorDeviceClass.HUMIDITY
),
],
)

View File

@@ -5474,6 +5474,7 @@
'hens_eggs_size_xl_soft',
'huanghuanian_rapid_steam_cooking',
'huanghuanian_steam_cooking',
'hydroclean',
'intensive_bake',
'iridescent_shark_fillet',
'jasmine_rice_rapid_steam_cooking',
@@ -5675,6 +5676,7 @@
'rhubarb_chunks',
'rice_pudding_rapid_steam_cooking',
'rice_pudding_steam_cooking',
'rinse',
'risotto',
'roast_beef_low_temperature_cooking',
'roast_beef_roast',
@@ -6085,6 +6087,7 @@
'hens_eggs_size_xl_soft',
'huanghuanian_rapid_steam_cooking',
'huanghuanian_steam_cooking',
'hydroclean',
'intensive_bake',
'iridescent_shark_fillet',
'jasmine_rice_rapid_steam_cooking',
@@ -6286,6 +6289,7 @@
'rhubarb_chunks',
'rice_pudding_rapid_steam_cooking',
'rice_pudding_steam_cooking',
'rinse',
'risotto',
'roast_beef_low_temperature_cooking',
'roast_beef_roast',
@@ -9268,6 +9272,7 @@
'hens_eggs_size_xl_soft',
'huanghuanian_rapid_steam_cooking',
'huanghuanian_steam_cooking',
'hydroclean',
'intensive_bake',
'iridescent_shark_fillet',
'jasmine_rice_rapid_steam_cooking',
@@ -9469,6 +9474,7 @@
'rhubarb_chunks',
'rice_pudding_rapid_steam_cooking',
'rice_pudding_steam_cooking',
'rinse',
'risotto',
'roast_beef_low_temperature_cooking',
'roast_beef_roast',
@@ -9879,6 +9885,7 @@
'hens_eggs_size_xl_soft',
'huanghuanian_rapid_steam_cooking',
'huanghuanian_steam_cooking',
'hydroclean',
'intensive_bake',
'iridescent_shark_fillet',
'jasmine_rice_rapid_steam_cooking',
@@ -10080,6 +10087,7 @@
'rhubarb_chunks',
'rice_pudding_rapid_steam_cooking',
'rice_pudding_steam_cooking',
'rinse',
'risotto',
'roast_beef_low_temperature_cooking',
'roast_beef_roast',

View File

@@ -26,7 +26,7 @@ async def test_async_step_bluetooth_valid_device(hass: HomeAssistant) -> None:
result["flow_id"], user_input={}
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Smart Series 7000 48BE"
assert result2["title"] == "Triumph D36 48BE"
assert result2["data"] == {}
assert result2["result"].unique_id == "78:DB:2F:C2:48:BE"
@@ -91,7 +91,7 @@ async def test_async_step_user_with_found_devices(hass: HomeAssistant) -> None:
user_input={"address": "78:DB:2F:C2:48:BE"},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Smart Series 7000 48BE"
assert result2["title"] == "Triumph D36 48BE"
assert result2["data"] == {}
assert result2["result"].unique_id == "78:DB:2F:C2:48:BE"
@@ -121,7 +121,7 @@ async def test_async_step_user_replace_ignored(hass: HomeAssistant) -> None:
user_input={"address": "78:DB:2F:C2:48:BE"},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Smart Series 7000 48BE"
assert result2["title"] == "Triumph D36 48BE"
assert result2["data"] == {}
assert result2["result"].unique_id == "78:DB:2F:C2:48:BE"
@@ -240,7 +240,7 @@ async def test_async_step_user_takes_precedence_over_discovery(
user_input={"address": "78:DB:2F:C2:48:BE"},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Smart Series 7000 48BE"
assert result2["title"] == "Triumph D36 48BE"
assert result2["data"] == {}
assert result2["result"].unique_id == "78:DB:2F:C2:48:BE"

View File

@@ -47,10 +47,10 @@ async def test_sensors(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 9
toothbrush_sensor = hass.states.get("sensor.smart_series_7000_48be")
toothbrush_sensor = hass.states.get("sensor.triumph_d36_48be")
toothbrush_sensor_attrs = toothbrush_sensor.attributes
assert toothbrush_sensor.state == "running"
assert toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "Smart Series 7000 48BE"
assert toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "Triumph D36 48BE"
assert ATTR_ASSUMED_STATE not in toothbrush_sensor_attrs
assert await hass.config_entries.async_unload(entry.entry_id)
@@ -76,7 +76,7 @@ async def test_sensors(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
# All of these devices are sleepy so we should still be available
toothbrush_sensor = hass.states.get("sensor.smart_series_7000_48be")
toothbrush_sensor = hass.states.get("sensor.triumph_d36_48be")
assert toothbrush_sensor.state == "running"
@@ -155,9 +155,9 @@ async def test_sensors_battery(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 7
bat_sensor = hass.states.get("sensor.io_series_6_7_1dcf_battery")
bat_sensor = hass.states.get("sensor.io_series_1dcf_battery")
assert bat_sensor.state == "49"
assert bat_sensor.name == "IO Series 6/7 1DCF Battery"
assert bat_sensor.name == "IO Series 1DCF Battery"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,4 +1,54 @@
# serializer version: 1
# name: test_buttons[button.roborock_q10_s5_empty_dustbin-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.roborock_q10_s5_empty_dustbin',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Empty dustbin',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Empty dustbin',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'empty_dustbin',
'unique_id': 'empty_dustbin_q10_duid',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[button.roborock_q10_s5_empty_dustbin-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Roborock Q10 S5+ Empty dustbin',
}),
'context': <ANY>,
'entity_id': 'button.roborock_q10_s5_empty_dustbin',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.roborock_s7_2_reset_air_filter_consumable-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -272,3 +272,55 @@ async def test_press_a01_button_failure(
washing_machine.zeo.set_value.assert_called_once()
assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00"
@pytest.mark.freeze_time("2023-10-30 08:50:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_press_q10_empty_dustbin_button_success(
hass: HomeAssistant,
bypass_api_client_fixture: None,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test pressing Q10 empty dustbin button entity."""
entity_id = "button.roborock_q10_s5_empty_dustbin"
assert hass.states.get(entity_id) is not None
await hass.services.async_call(
"button",
SERVICE_PRESS,
blocking=True,
target={"entity_id": entity_id},
)
assert fake_q10_vacuum.b01_q10_properties is not None
fake_q10_vacuum.b01_q10_properties.vacuum.empty_dustbin.assert_called_once()
assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00"
@pytest.mark.freeze_time("2023-10-30 08:50:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_press_q10_empty_dustbin_button_failure(
hass: HomeAssistant,
bypass_api_client_fixture: None,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test failure while pressing Q10 empty dustbin button entity."""
entity_id = "button.roborock_q10_s5_empty_dustbin"
assert fake_q10_vacuum.b01_q10_properties is not None
fake_q10_vacuum.b01_q10_properties.vacuum.empty_dustbin.side_effect = (
RoborockException
)
assert hass.states.get(entity_id) is not None
with pytest.raises(HomeAssistantError, match="Error while calling empty_dustbin"):
await hass.services.async_call(
"button",
SERVICE_PRESS,
blocking=True,
target={"entity_id": entity_id},
)
fake_q10_vacuum.b01_q10_properties.vacuum.empty_dustbin.assert_called_once()
assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00"

View File

@@ -2023,6 +2023,56 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_vc_stick_01001][binary_sensor.stick_vacuum_stick_cleaner_in_station-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.stick_vacuum_stick_cleaner_in_station',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Stick cleaner in station',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Stick cleaner in station',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'stick_cleaner_status',
'unique_id': 'e1f93c0c-6fe0-c65a-a314-c8f7b163c86b_station_samsungce.cleanStationStickStatus_status_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_vc_stick_01001][binary_sensor.stick_vacuum_stick_cleaner_in_station-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Stick vacuum Stick cleaner in station',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.stick_vacuum_stick_cleaner_in_station',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[da_wm_dw_000001][binary_sensor.dishwasher_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -6,6 +6,7 @@ from pysmlight import Info, Sensors
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.smlight.const import DOMAIN
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
@@ -113,3 +114,55 @@ async def test_zigbee_type_sensors(
state = hass.states.get("sensor.mock_title_zigbee_type_2")
assert state
assert state.state == "router"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_psram_usage_sensor(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test PSRAM usage sensor creation for u-devices."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = Info(
MAC="AA:BB:CC:DD:EE:FF",
model="SLZB-MR3U",
u_device=True,
)
mock_smlight_client.get_sensors.return_value = Sensors(psram_usage=156)
await setup_integration(hass, mock_config_entry)
entity_id = entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff_psram_usage"
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == "156"
assert state.attributes["unit_of_measurement"] == "kB"
async def test_psram_usage_sensor_not_created(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_smlight_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test PSRAM usage sensor is not created for non-u devices."""
mock_smlight_client.get_info.side_effect = None
mock_smlight_client.get_info.return_value = Info(
MAC="AA:BB:CC:DD:EE:FF",
model="SLZB-MR3",
u_device=False,
)
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.mock_title_psram_usage") is None
entity_id = entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, "aa:bb:cc:dd:ee:ff_psram_usage"
)
assert entity_id is None

View File

@@ -0,0 +1 @@
"""Tests for the temperature integration."""

View File

@@ -0,0 +1,931 @@
"""Test temperature trigger."""
from typing import Any
import pytest
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE as CLIMATE_ATTR_CURRENT_TEMPERATURE,
HVACMode,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.water_heater import (
ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
)
from homeassistant.components.weather import (
ATTR_WEATHER_TEMPERATURE,
ATTR_WEATHER_TEMPERATURE_UNIT,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ENTITY_ID,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components.common import (
TriggerStateDescription,
arm_trigger,
assert_trigger_behavior_any,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
parametrize_numerical_attribute_changed_trigger_states,
parametrize_numerical_attribute_crossed_threshold_trigger_states,
parametrize_numerical_state_value_changed_trigger_states,
parametrize_numerical_state_value_crossed_threshold_trigger_states,
parametrize_target_entities,
target_entities,
)
_TEMPERATURE_TRIGGER_OPTIONS = {"unit": UnitOfTemperature.CELSIUS}
_SENSOR_UNIT_ATTRIBUTES = {
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
}
_WEATHER_UNIT_ATTRIBUTES = {
ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.CELSIUS,
}
@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.fixture
async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple climate entities associated with different targets."""
return await target_entities(hass, "climate")
@pytest.fixture
async def target_water_heaters(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple water_heater entities associated with different targets."""
return await target_entities(hass, "water_heater")
@pytest.fixture
async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple weather entities associated with different targets."""
return await target_entities(hass, "weather")
@pytest.mark.parametrize(
"trigger_key",
[
"temperature.changed",
"temperature.crossed_threshold",
],
)
async def test_temperature_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the temperature triggers are gated by the labs flag."""
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
# --- Sensor domain tests (value in state.state) ---
@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(
"temperature.changed",
device_class=SensorDeviceClass.TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_SENSOR_UNIT_ATTRIBUTES,
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
device_class=SensorDeviceClass.TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_SENSOR_UNIT_ATTRIBUTES,
),
],
)
async def test_temperature_trigger_sensor_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
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 temperature trigger fires for sensor entities with device_class temperature."""
await assert_trigger_behavior_any(
hass,
service_calls=service_calls,
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(
"temperature.crossed_threshold",
device_class=SensorDeviceClass.TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_SENSOR_UNIT_ATTRIBUTES,
),
],
)
async def test_temperature_trigger_sensor_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
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 temperature crossed_threshold trigger fires on the first sensor state change."""
await assert_trigger_behavior_first(
hass,
service_calls=service_calls,
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(
"temperature.crossed_threshold",
device_class=SensorDeviceClass.TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_SENSOR_UNIT_ATTRIBUTES,
),
],
)
async def test_temperature_trigger_sensor_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
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 temperature crossed_threshold trigger fires when the last sensor changes state."""
await assert_trigger_behavior_last(
hass,
service_calls=service_calls,
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,
)
# --- Climate domain tests (value in current_temperature attribute) ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"temperature.changed",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
],
)
async def test_temperature_trigger_climate_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: 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 temperature trigger fires for climate entities."""
await assert_trigger_behavior_any(
hass,
service_calls=service_calls,
target_entities=target_climates,
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("climate"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
],
)
async def test_temperature_trigger_climate_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: 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 temperature crossed_threshold trigger fires on the first climate state change."""
await assert_trigger_behavior_first(
hass,
service_calls=service_calls,
target_entities=target_climates,
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("climate"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
HVACMode.AUTO,
CLIMATE_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
],
)
async def test_temperature_trigger_climate_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: 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 temperature crossed_threshold trigger fires when the last climate changes state."""
await assert_trigger_behavior_last(
hass,
service_calls=service_calls,
target_entities=target_climates,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
# --- Water heater domain tests (value in current_temperature attribute) ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("water_heater"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"temperature.changed",
"eco",
WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
"eco",
WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
],
)
async def test_temperature_trigger_water_heater_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_water_heaters: 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 temperature trigger fires for water_heater entities."""
await assert_trigger_behavior_any(
hass,
service_calls=service_calls,
target_entities=target_water_heaters,
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("water_heater"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
"eco",
WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
],
)
async def test_temperature_trigger_water_heater_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_water_heaters: 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 temperature crossed_threshold trigger fires on the first water_heater state change."""
await assert_trigger_behavior_first(
hass,
service_calls=service_calls,
target_entities=target_water_heaters,
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("water_heater"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
"eco",
WATER_HEATER_ATTR_CURRENT_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
],
)
async def test_temperature_trigger_water_heater_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_water_heaters: 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 temperature crossed_threshold trigger fires when the last water_heater changes state."""
await assert_trigger_behavior_last(
hass,
service_calls=service_calls,
target_entities=target_water_heaters,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
# --- Weather domain tests (value in temperature attribute) ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("weather"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"temperature.changed",
"sunny",
ATTR_WEATHER_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_WEATHER_UNIT_ATTRIBUTES,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
"sunny",
ATTR_WEATHER_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_WEATHER_UNIT_ATTRIBUTES,
),
],
)
async def test_temperature_trigger_weather_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_weathers: 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 temperature trigger fires for weather entities."""
await assert_trigger_behavior_any(
hass,
service_calls=service_calls,
target_entities=target_weathers,
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("weather"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
"sunny",
ATTR_WEATHER_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_WEATHER_UNIT_ATTRIBUTES,
),
],
)
async def test_temperature_trigger_weather_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_weathers: 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 temperature crossed_threshold trigger fires on the first weather state change."""
await assert_trigger_behavior_first(
hass,
service_calls=service_calls,
target_entities=target_weathers,
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("weather"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"temperature.crossed_threshold",
"sunny",
ATTR_WEATHER_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
unit_attributes=_WEATHER_UNIT_ATTRIBUTES,
),
],
)
async def test_temperature_trigger_weather_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_weathers: 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 temperature crossed_threshold trigger fires when the last weather changes state."""
await assert_trigger_behavior_last(
hass,
service_calls=service_calls,
target_entities=target_weathers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
# --- Device class exclusion test ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
(
"trigger_key",
"trigger_options",
"sensor_initial",
"sensor_target",
),
[
(
"temperature.changed",
{},
"20",
"25",
),
(
"temperature.crossed_threshold",
{"threshold_type": "above", "lower_limit": 10, "unit": "°C"},
"5",
"20",
),
],
)
async def test_temperature_trigger_excludes_non_temperature_sensor(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
trigger_options: dict[str, Any],
sensor_initial: str,
sensor_target: str,
) -> None:
"""Test temperature trigger does not fire for sensor entities without device_class temperature."""
entity_id_temperature = "sensor.test_temperature"
entity_id_humidity = "sensor.test_humidity"
temp_attrs = {
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
}
humidity_attrs = {ATTR_DEVICE_CLASS: "humidity"}
# Set initial states
hass.states.async_set(entity_id_temperature, sensor_initial, temp_attrs)
hass.states.async_set(entity_id_humidity, sensor_initial, humidity_attrs)
await hass.async_block_till_done()
await arm_trigger(
hass,
trigger_key,
trigger_options,
{
CONF_ENTITY_ID: [
entity_id_temperature,
entity_id_humidity,
]
},
)
# Temperature sensor changes - should trigger
hass.states.async_set(entity_id_temperature, sensor_target, temp_attrs)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_temperature
service_calls.clear()
# Humidity sensor changes - should NOT trigger (wrong device class)
hass.states.async_set(entity_id_humidity, sensor_target, humidity_attrs)
await hass.async_block_till_done()
assert len(service_calls) == 0
# --- Unit conversion tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_temperature_trigger_unit_conversion_sensor_celsius_to_fahrenheit(
hass: HomeAssistant,
service_calls: list[ServiceCall],
) -> None:
"""Test temperature trigger converts sensor value from °C to °F for threshold comparison."""
entity_id = "sensor.test_temp"
# Sensor reports in °C, trigger configured in °F with threshold above 70°F
hass.states.async_set(
entity_id,
"20",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
await hass.async_block_till_done()
await arm_trigger(
hass,
"temperature.crossed_threshold",
{
"threshold_type": "above",
"lower_limit": 70,
"unit": "°F",
},
{CONF_ENTITY_ID: [entity_id]},
)
# 20°C = 68°F, which is below 70°F - should NOT trigger
hass.states.async_set(
entity_id,
"20",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# 22°C = 71.6°F, which is above 70°F - should trigger
hass.states.async_set(
entity_id,
"22",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_temperature_trigger_unit_conversion_sensor_fahrenheit_to_celsius(
hass: HomeAssistant,
service_calls: list[ServiceCall],
) -> None:
"""Test temperature trigger converts sensor value from °F to °C for threshold comparison."""
entity_id = "sensor.test_temp"
# Sensor reports in °F, trigger configured in °C with threshold above 25°C
hass.states.async_set(
entity_id,
"70",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
await arm_trigger(
hass,
"temperature.crossed_threshold",
{
"threshold_type": "above",
"lower_limit": 25,
"unit": "°C",
},
{CONF_ENTITY_ID: [entity_id]},
)
# 70°F = 21.1°C, which is below 25°C - should NOT trigger
hass.states.async_set(
entity_id,
"70",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# 80°F = 26.7°C, which is above 25°C - should trigger
hass.states.async_set(
entity_id,
"80",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_temperature_trigger_unit_conversion_changed(
hass: HomeAssistant,
service_calls: list[ServiceCall],
) -> None:
"""Test temperature changed trigger with unit conversion and above/below limits."""
entity_id = "sensor.test_temp"
# Sensor reports in °C, trigger configured in °F: above 68°F (20°C), below 77°F (25°C)
hass.states.async_set(
entity_id,
"18",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
await hass.async_block_till_done()
await arm_trigger(
hass,
"temperature.changed",
{
"above": 68,
"below": 77,
"unit": "°F",
},
{CONF_ENTITY_ID: [entity_id]},
)
# 18°C = 64.4°F, below 68°F - should NOT trigger
hass.states.async_set(
entity_id,
"19",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# 22°C = 71.6°F, between 68°F and 77°F - should trigger
hass.states.async_set(
entity_id,
"22",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# 26°C = 78.8°F, above 77°F - should NOT trigger
hass.states.async_set(
entity_id,
"26",
{
ATTR_DEVICE_CLASS: "temperature",
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
async def test_temperature_trigger_unit_conversion_weather(
hass: HomeAssistant,
service_calls: list[ServiceCall],
) -> None:
"""Test temperature trigger with unit conversion for weather entities."""
entity_id = "weather.test"
# Weather reports temperature in °F, trigger configured in °C with threshold above 25°C
hass.states.async_set(
entity_id,
"sunny",
{
ATTR_WEATHER_TEMPERATURE: 70,
ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
await arm_trigger(
hass,
"temperature.crossed_threshold",
{
"threshold_type": "above",
"lower_limit": 25,
"unit": "°C",
},
{CONF_ENTITY_ID: [entity_id]},
)
# 70°F = 21.1°C, below 25°C - should NOT trigger
hass.states.async_set(
entity_id,
"sunny",
{
ATTR_WEATHER_TEMPERATURE: 70,
ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# 80°F = 26.7°C, above 25°C - should trigger
hass.states.async_set(
entity_id,
"sunny",
{
ATTR_WEATHER_TEMPERATURE: 80,
ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()

View File

@@ -71,7 +71,7 @@ def mock_wolflink() -> Generator[MagicMock]:
wolflink.fetch_parameters.return_value = [
EnergyParameter(
6002800000, "Energy Parameter", "Heating", 6005200000, 2000
6002800000, "Energy Parameter", "Heating", 6005200000, 2000, True
),
ListItemParameter(
8002800000,
@@ -80,22 +80,35 @@ def mock_wolflink() -> Generator[MagicMock]:
[ListItem("0", "Aus"), ListItem("1", "Ein")],
8005200000,
3001,
True,
),
PowerParameter(
5002800000, "Power Parameter", "Heating", 5005200000, 1000, True
),
Pressure(
4002800000, "Pressure Parameter", "Heating", 4005200000, 1000, True
),
Temperature(
3002800000, "Temperature Parameter", "Solar", 3005200000, 1000, True
),
PowerParameter(5002800000, "Power Parameter", "Heating", 5005200000, 1000),
Pressure(4002800000, "Pressure Parameter", "Heating", 4005200000, 1000),
Temperature(3002800000, "Temperature Parameter", "Solar", 3005200000, 1000),
PercentageParameter(
2002800000, "Percentage Parameter", "Solar", 2005200000, 1000
2002800000, "Percentage Parameter", "Solar", 2005200000, 1000, True
),
HoursParameter(
7002800000, "Hours Parameter", "Heating", 7005200000, 1000, True
),
SimpleParameter(
1002800000, "Simple Parameter", "DHW", 1005200000, 1000, True
),
HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000),
SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000),
FrequencyParameter(
9002800000, "Frequency Parameter", "Heating", 9005200000, 1000
9002800000, "Frequency Parameter", "Heating", 9005200000, 1000, True
),
RPMParameter(
1000280001, "RPM Parameter", "Heating", 10005200000, 7000, True
),
FlowParameter(
1100280001, "Flow Parameter", "Heating", 11005200000, 8000, True
),
RPMParameter(1000280001, "RPM Parameter", "Heating", 10005200000, 7000),
FlowParameter(1100280001, "Flow Parameter", "Heating", 11005200000, 8000),
HoursParameter(7002800000, "Hours Parameter", "Heating", 7005200000, 1000),
SimpleParameter(1002800000, "Simple Parameter", "DHW", 1005200000, 1000),
]
wolflink.fetch_value.return_value = [

View File

@@ -17,6 +17,7 @@ from homeassistant.components.tag import DOMAIN as TAG_DOMAIN
from homeassistant.components.text import DOMAIN as TEXT_DOMAIN
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ABOVE,
CONF_BELOW,
CONF_ENTITY_ID,
@@ -25,6 +26,7 @@ from homeassistant.const import (
CONF_TARGET,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -45,8 +47,11 @@ from homeassistant.helpers.automation import (
from homeassistant.helpers.trigger import (
CONF_LOWER_LIMIT,
CONF_THRESHOLD_TYPE,
CONF_UNIT,
CONF_UPPER_LIMIT,
DATA_PLUGGABLE_ACTIONS,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityTriggerBase,
PluggableAction,
Trigger,
@@ -64,6 +69,7 @@ from homeassistant.helpers.trigger import (
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import Integration, async_get_integration
from homeassistant.setup import async_setup_component
from homeassistant.util.unit_conversion import TemperatureConverter
from homeassistant.util.yaml.loader import parse_yaml
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
@@ -1174,31 +1180,38 @@ async def test_subscribe_triggers_no_triggers(
("trigger_options", "expected_result"),
[
# Test validating climate.target_temperature_changed
# Valid configurations
# Valid: no limits at all
(
{},
does_not_raise(),
),
# Valid: numerical limits
(
{CONF_ABOVE: 10},
does_not_raise(),
),
(
{CONF_ABOVE: "sensor.test"},
does_not_raise(),
),
(
{CONF_BELOW: 90},
does_not_raise(),
),
(
{CONF_ABOVE: 10, CONF_BELOW: 90},
does_not_raise(),
),
# Valid: entity references
(
{CONF_ABOVE: "sensor.test"},
does_not_raise(),
),
(
{CONF_BELOW: "sensor.test"},
does_not_raise(),
),
(
{CONF_ABOVE: 10, CONF_BELOW: 90},
{CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"},
does_not_raise(),
),
# Valid: Mix of numerical limits and entity references
(
{CONF_ABOVE: "sensor.test", CONF_BELOW: 90},
does_not_raise(),
@@ -1207,10 +1220,6 @@ async def test_subscribe_triggers_no_triggers(
{CONF_ABOVE: 10, CONF_BELOW: "sensor.test"},
does_not_raise(),
),
(
{CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"},
does_not_raise(),
),
# Test verbose choose selector options
(
{CONF_ABOVE: {"active_choice": "entity", "entity": "sensor.test"}},
@@ -1276,6 +1285,147 @@ async def test_numerical_state_attribute_changed_trigger_config_validation(
)
def _make_with_unit_changed_trigger_class() -> type[
EntityNumericalStateChangedTriggerWithUnitBase
]:
"""Create a concrete WithUnit changed trigger class for testing."""
class _TestChangedTrigger(
EntityNumericalStateChangedTriggerWithUnitBase,
):
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")}
_unit_converter = TemperatureConverter
return _TestChangedTrigger
@pytest.mark.parametrize(
("trigger_options", "expected_result"),
[
# Valid: no limits at all
(
{},
does_not_raise(),
),
# Valid: unit provided with numerical limits
(
{CONF_ABOVE: 10, CONF_UNIT: UnitOfTemperature.CELSIUS},
does_not_raise(),
),
(
{CONF_BELOW: 90, CONF_UNIT: UnitOfTemperature.FAHRENHEIT},
does_not_raise(),
),
(
{
CONF_ABOVE: 10,
CONF_BELOW: 90,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
does_not_raise(),
),
# Valid: no unit needed when using entity references
(
{CONF_ABOVE: "sensor.test"},
does_not_raise(),
),
(
{CONF_BELOW: "sensor.test"},
does_not_raise(),
),
(
{CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"},
does_not_raise(),
),
# Valid: unit only needed for numerical limits, not entity references
(
{
CONF_ABOVE: "sensor.test",
CONF_BELOW: 90,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
does_not_raise(),
),
(
{
CONF_ABOVE: 10,
CONF_BELOW: "sensor.test",
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
does_not_raise(),
),
# Invalid: numerical limit without unit
(
{CONF_ABOVE: 10},
pytest.raises(vol.Invalid),
),
(
{CONF_BELOW: 90},
pytest.raises(vol.Invalid),
),
(
{CONF_ABOVE: 10, CONF_BELOW: 90},
pytest.raises(vol.Invalid),
),
# Invalid: one numerical limit without unit (other is entity)
(
{CONF_ABOVE: 10, CONF_BELOW: "sensor.test"},
pytest.raises(vol.Invalid),
),
(
{CONF_ABOVE: "sensor.test", CONF_BELOW: 90},
pytest.raises(vol.Invalid),
),
# Invalid: invalid unit value
(
{CONF_ABOVE: 10, CONF_UNIT: "invalid_unit"},
pytest.raises(vol.Invalid),
),
# Invalid: Must use valid entity id
(
{CONF_ABOVE: "cat", CONF_BELOW: "dog"},
pytest.raises(vol.Invalid),
),
# Invalid: above must be smaller than below
(
{CONF_ABOVE: 90, CONF_BELOW: 10, CONF_UNIT: UnitOfTemperature.CELSIUS},
pytest.raises(vol.Invalid),
),
# Invalid: invalid choose selector option
(
{CONF_BELOW: {"active_choice": "cat", "cat": 90}},
pytest.raises(vol.Invalid),
),
],
)
async def test_numerical_state_attribute_changed_with_unit_trigger_config_validation(
hass: HomeAssistant,
trigger_options: dict[str, Any],
expected_result: AbstractContextManager,
) -> None:
"""Test numerical state attribute change with unit trigger config validation."""
trigger_cls = _make_with_unit_changed_trigger_class()
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {"test_trigger": trigger_cls}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
with expected_result:
await async_validate_trigger_config(
hass,
[
{
"platform": "test.test_trigger",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: trigger_options,
}
],
)
async def test_numerical_state_attribute_changed_error_handling(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
@@ -1389,6 +1539,302 @@ async def test_numerical_state_attribute_changed_error_handling(
assert len(service_calls) == 0
async def test_numerical_state_attribute_changed_with_unit_error_handling(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test numerical state attribute change with unit conversion error handling."""
trigger_cls = _make_with_unit_changed_trigger_class()
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {"attribute_changed": trigger_cls}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
# Entity reports in °F, trigger configured in °C with above 20°C, below 30°C
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 68, # 68°F = 20°C
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"trigger": {
CONF_PLATFORM: "test.attribute_changed",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: {
CONF_ABOVE: 20,
CONF_BELOW: 30,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
},
"action": {
"service": "test.numerical_automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
},
{
"trigger": {
CONF_PLATFORM: "test.attribute_changed",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: {
CONF_ABOVE: "sensor.above",
CONF_BELOW: "sensor.below",
},
},
"action": {
"service": "test.entity_automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
},
]
},
)
assert len(service_calls) == 0
# 77°F = 25°C, within range (above 20, below 30) - should trigger numerical
# Entity automation won't trigger because sensor.above/below don't exist yet
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 77,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].service == "numerical_automation"
service_calls.clear()
# 59°F = 15°C, below 20°C - should NOT trigger
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 59,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# 95°F = 35°C, above 30°C - should NOT trigger
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 95,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Set up entity limits referencing sensors that report in °F
hass.states.async_set(
"sensor.above",
"68", # 68°F = 20°C
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
hass.states.async_set(
"sensor.below",
"86", # 86°F = 30°C
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
# 77°F = 25°C, between 20°C and 30°C - should trigger both automations
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 77,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 2
assert {call.service for call in service_calls} == {
"numerical_automation",
"entity_automation",
}
service_calls.clear()
# Test the trigger does not fire when the attribute value is missing
hass.states.async_set("test.test_entity", "on", {})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the attribute value is invalid
for value in ("cat", None):
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": value,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the unit is incompatible
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 50,
ATTR_UNIT_OF_MEASUREMENT: "invalid_unit",
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the above sensor does not exist
hass.states.async_remove("sensor.above")
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
hass.states.async_set(
"test.test_entity",
"on",
{"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the above sensor state is not numeric
for invalid_value in ("cat", None):
hass.states.async_set(
"sensor.above",
invalid_value,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 50,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the above sensor's unit is incompatible
hass.states.async_set(
"sensor.above",
"68", # 68°F = 20°C
{ATTR_UNIT_OF_MEASUREMENT: "invalid_unit"},
)
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
hass.states.async_set(
"test.test_entity",
"on",
{"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Reset the above sensor state to a valid numeric value
hass.states.async_set(
"sensor.above",
"68", # 68°F = 20°C
{ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
# Test the trigger does not fire when the below sensor does not exist
hass.states.async_remove("sensor.below")
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
hass.states.async_set(
"test.test_entity",
"on",
{"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the below sensor state is not numeric
for invalid_value in ("cat", None):
hass.states.async_set("sensor.below", invalid_value)
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 50,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the below sensor's unit is incompatible
hass.states.async_set(
"sensor.below",
"68", # 68°F = 20°C
{ATTR_UNIT_OF_MEASUREMENT: "invalid_unit"},
)
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": None,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
hass.states.async_set(
"test.test_entity",
"on",
{"test_attribute": 50, ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_options", "expected_result"),
[
@@ -1586,6 +2032,367 @@ async def test_numerical_state_attribute_crossed_threshold_trigger_config_valida
)
def _make_with_unit_crossed_threshold_trigger_class() -> type[
EntityNumericalStateCrossedThresholdTriggerWithUnitBase
]:
"""Create a concrete WithUnit crossed threshold trigger class for testing."""
class _TestCrossedThresholdTrigger(
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
):
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {"test": NumericalDomainSpec(value_source="test_attribute")}
_unit_converter = TemperatureConverter
return _TestCrossedThresholdTrigger
@pytest.mark.parametrize(
("trigger_options", "expected_result"),
[
# Valid: unit provided with numerical limits
(
{
CONF_THRESHOLD_TYPE: "above",
CONF_LOWER_LIMIT: 10,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: "below",
CONF_UPPER_LIMIT: 90,
CONF_UNIT: UnitOfTemperature.FAHRENHEIT,
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: "between",
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
does_not_raise(),
),
# Valid: no unit needed when using entity references
(
{
CONF_THRESHOLD_TYPE: "above",
CONF_LOWER_LIMIT: "sensor.test",
},
does_not_raise(),
),
(
{
CONF_THRESHOLD_TYPE: "between",
CONF_LOWER_LIMIT: "sensor.test",
CONF_UPPER_LIMIT: "sensor.test",
},
does_not_raise(),
),
# Invalid: numerical limit without unit
(
{CONF_THRESHOLD_TYPE: "above", CONF_LOWER_LIMIT: 10},
pytest.raises(vol.Invalid),
),
(
{
CONF_THRESHOLD_TYPE: "between",
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: 90,
},
pytest.raises(vol.Invalid),
),
# Invalid: one numerical limit without unit (other is entity)
(
{
CONF_THRESHOLD_TYPE: "between",
CONF_LOWER_LIMIT: 10,
CONF_UPPER_LIMIT: "sensor.test",
},
pytest.raises(vol.Invalid),
),
# Invalid: invalid unit value
(
{
CONF_THRESHOLD_TYPE: "above",
CONF_LOWER_LIMIT: 10,
CONF_UNIT: "invalid_unit",
},
pytest.raises(vol.Invalid),
),
# Invalid: missing threshold type (shared validation)
(
{},
pytest.raises(vol.Invalid),
),
],
)
async def test_numerical_state_attribute_crossed_threshold_with_unit_trigger_config_validation(
hass: HomeAssistant,
trigger_options: dict[str, Any],
expected_result: AbstractContextManager,
) -> None:
"""Test numerical state attribute crossed threshold with unit trigger config validation."""
trigger_cls = _make_with_unit_crossed_threshold_trigger_class()
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {"test_trigger": trigger_cls}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
with expected_result:
await async_validate_trigger_config(
hass,
[
{
"platform": "test.test_trigger",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: trigger_options,
}
],
)
async def test_numerical_state_attribute_crossed_threshold_error_handling(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test numerical state attribute crossed threshold error handling."""
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{"test": NumericalDomainSpec(value_source="test_attribute")}
),
}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
hass.states.async_set("test.test_entity", "on", {"test_attribute": 0})
options = {
CONF_OPTIONS: {
CONF_THRESHOLD_TYPE: "between",
CONF_LOWER_LIMIT: "sensor.lower",
CONF_UPPER_LIMIT: "sensor.upper",
},
}
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "test.crossed_threshold",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
}
| options,
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
assert len(service_calls) == 0
# Test the trigger works
hass.states.async_set("sensor.lower", "10")
hass.states.async_set("sensor.upper", "90")
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Test the trigger does not fire again when still within limits
hass.states.async_set("test.test_entity", "on", {"test_attribute": 51})
await hass.async_block_till_done()
assert len(service_calls) == 0
service_calls.clear()
# Test the trigger does not fire when the from-state is unknown or unavailable
for from_state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
hass.states.async_set("test.test_entity", from_state)
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does fire when the attribute value is changing from None
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Test the trigger does not fire when the attribute value is outside the limits
for value in (5, 95):
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the attribute value is missing
hass.states.async_set("test.test_entity", "on", {})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the attribute value is invalid
for value in ("cat", None):
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the lower sensor does not exist
hass.states.async_remove("sensor.lower")
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the lower sensor state is not numeric
for invalid_value in ("cat", None):
hass.states.async_set("sensor.lower", invalid_value)
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Reset the lower sensor state to a valid numeric value
hass.states.async_set("sensor.lower", "10")
# Test the trigger does not fire when the upper sensor does not exist
hass.states.async_remove("sensor.upper")
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test the trigger does not fire when the upper sensor state is not numeric
for invalid_value in ("cat", None):
hass.states.async_set("sensor.upper", invalid_value)
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(service_calls) == 0
async def test_numerical_state_attribute_crossed_threshold_with_unit_error_handling(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test numerical state attribute crossed threshold with unit conversion."""
trigger_cls = _make_with_unit_crossed_threshold_trigger_class()
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {"crossed_threshold": trigger_cls}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
# Entity reports in °F, trigger configured in °C: above 25°C
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 68, # 68°F = 20°C, below threshold
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
options = {
CONF_OPTIONS: {
CONF_THRESHOLD_TYPE: "above",
CONF_LOWER_LIMIT: 25,
CONF_UNIT: UnitOfTemperature.CELSIUS,
},
}
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "test.crossed_threshold",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
}
| options,
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
assert len(service_calls) == 0
# 80.6°F = 27°C, above 25°C threshold - should trigger
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 80.6,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Still above threshold - should NOT trigger (already crossed)
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 82,
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Drop below threshold and cross again
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 68, # 20°C, below 25°C
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 80.6, # 27°C, above 25°C again
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT,
},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
service_calls.clear()
# Test with incompatible unit - should NOT trigger
hass.states.async_set(
"test.test_entity",
"on",
{
"test_attribute": 50,
ATTR_UNIT_OF_MEASUREMENT: "invalid_unit",
},
)
await hass.async_block_till_done()
assert len(service_calls) == 0
def _make_trigger(
hass: HomeAssistant, domain_specs: Mapping[str, DomainSpec]
) -> EntityTriggerBase:

View File

@@ -86,6 +86,7 @@
'system_health',
'system_log',
'tag',
'temperature',
'text',
'time',
'timer',
@@ -190,6 +191,7 @@
'system_health',
'system_log',
'tag',
'temperature',
'text',
'time',
'timer',