Compare commits

..

26 Commits

Author SHA1 Message Date
Erik
562fcd0e34 Update translations 2026-03-23 20:45:56 +01:00
Erik
8c39011ca4 Update snapshots 2026-03-23 20:44:27 +01:00
Erik
fc0117775d Adjust translations 2026-03-23 19:05:09 +01:00
Erik
3cfac4ce54 Add support to trigger on number entities 2026-03-23 16:28:45 +01:00
Erik
bf7f9621bf Add power triggers 2026-03-23 12:50:40 +01:00
Erik Montnemery
3529aff4b1 Revert "Add turned off and turned on triggers to input boolean (#158824)" (#166240) 2026-03-23 08:46:03 +01:00
Matrix
16e314ccf1 Bump yolink-api to 0.6.3 (#166232) 2026-03-23 08:34:37 +01:00
Erik Montnemery
d634fbcad7 Add unit of measurement handling to numeric climate triggers (#166211) 2026-03-23 08:29:01 +01:00
Ray Xue
b84ca80d55 Add Linptech PS1BB pressure sensor support to xiaomi_ble (#166095) 2026-03-23 00:21:28 +01:00
David Bonnes
41c2c621f0 Bump evohome-async to 1.2.0 (#166227) 2026-03-23 00:10:38 +01:00
Peter Grauvogel
b230e62868 Bump greenplanet-energy-api from 0.1.4 to 0.1.10 (#166217) 2026-03-22 22:08:06 +01:00
Ludovic BOUÉ
12528ec128 Update python-roborock to 5.0.0 (#166219) 2026-03-22 13:38:31 -07:00
Manu
7f4a7670a2 Bump pyrate-limiter to 4.1.0 (#166221) 2026-03-22 13:38:19 -07:00
Erwin Douna
9bdc1b777e Add async_setup and yarl to Immich coordinator (#165900)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-22 21:36:16 +01:00
Stathogon
995e982d7f Add shutdown button for VMs in ProxmoxVE (#165890) 2026-03-22 21:29:59 +01:00
MarkGodwin
b92698e3d5 Bump tplink-omada-client to fix breaking changes in Omada API (#166206) 2026-03-22 19:44:38 +01:00
Ludovic BOUÉ
225052b932 feat(roborock): Remove unnecessary type check for Q10 update coordinator in button setup (#166214) 2026-03-22 19:42:18 +01:00
Raphael Hehl
34ae51677f Add a reauthentication flow to the UniFi Access integration (#165859)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-22 18:09:46 +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
78 changed files with 3821 additions and 541 deletions

4
CODEOWNERS generated
View File

@@ -1309,6 +1309,8 @@ build.json @home-assistant/supervisor
/tests/components/poolsense/ @haemishkyd
/homeassistant/components/portainer/ @erwindouna
/tests/components/portainer/ @erwindouna
/homeassistant/components/power/ @home-assistant/core
/tests/components/power/ @home-assistant/core
/homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerfox_local/ @klaasnicolaas
@@ -1703,6 +1705,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,8 @@ DEFAULT_INTEGRATIONS = {
"humidity",
"motion",
"occupancy",
"power",
"temperature",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

@@ -155,7 +155,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"gate",
"humidifier",
"humidity",
"input_boolean",
"lawn_mower",
"light",
"lock",
@@ -163,12 +162,14 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"motion",
"occupancy",
"person",
"power",
"remote",
"scene",
"schedule",
"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

@@ -462,6 +462,10 @@
"below": {
"description": "Trigger when the target 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": "Climate-control device target temperature changed"
@@ -481,6 +485,10 @@
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"unit": {
"description": "[%key:component::climate::triggers::target_temperature_changed::fields::unit::description%]",
"name": "[%key:component::climate::triggers::target_temperature_changed::fields::unit::name%]"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"

View File

@@ -2,12 +2,15 @@
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
@@ -16,6 +19,7 @@ from homeassistant.helpers.trigger import (
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
@@ -44,6 +48,33 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
self._to_states = set(self._options[CONF_HVAC_MODE])
class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
"""Mixin for climate target temperature triggers with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetTemperatureChangedTrigger(
_ClimateTargetTemperatureTriggerMixin,
EntityNumericalStateChangedTriggerWithUnitBase,
):
"""Trigger for climate target temperature value changes."""
class ClimateTargetTemperatureCrossedThresholdTrigger(
_ClimateTargetTemperatureTriggerMixin,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
):
"""Trigger for climate target temperature value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -53,17 +84,15 @@ TRIGGERS: dict[str, type[Trigger]] = {
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
),
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
),
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,

View File

@@ -14,7 +14,29 @@
- last
- any
.number_or_entity: &number_or_entity
.number_or_entity_humidity: &number_or_entity_humidity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "%"
- domain: sensor
device_class: humidity
- domain: number
device_class: humidity
translation_key: number_or_entity
.number_or_entity_temperature: &number_or_entity_temperature
required: false
selector:
choose:
@@ -27,12 +49,24 @@
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
- domain: input_number
unit_of_measurement:
- "°C"
- "°F"
- domain: sensor
device_class: temperature
- domain: number
device_class: temperature
translation_key: number_or_entity
.trigger_unit_temperature: &trigger_unit_temperature
required: false
selector:
select:
options:
- "°C"
- "°F"
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
@@ -69,27 +103,29 @@ hvac_mode_changed:
target_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
above: *number_or_entity_humidity
below: *number_or_entity_humidity
target_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
lower_limit: *number_or_entity_humidity
upper_limit: *number_or_entity_humidity
target_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
above: *number_or_entity_temperature
below: *number_or_entity_temperature
unit: *trigger_unit_temperature
target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
lower_limit: *number_or_entity_temperature
upper_limit: *number_or_entity_temperature
unit: *trigger_unit_temperature

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.1.3"]
"requirements": ["evohome-async==1.2.0"]
}

View File

@@ -124,15 +124,17 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
until = dt_util.as_utc(until) if until else None
if operation_mode == STATE_ON:
await self.coordinator.call_client_api(self._evo_device.on(until=until))
await self.coordinator.call_client_api(
self._evo_device.set_on(until=until)
)
else: # STATE_OFF
await self.coordinator.call_client_api(
self._evo_device.off(until=until)
self._evo_device.set_off(until=until)
)
async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on."""
await self.coordinator.call_client_api(self._evo_device.off())
await self.coordinator.call_client_api(self._evo_device.set_off())
async def async_turn_away_mode_off(self) -> None:
"""Turn away mode off."""
@@ -140,8 +142,8 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
await self.coordinator.call_client_api(self._evo_device.on())
await self.coordinator.call_client_api(self._evo_device.set_on())
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.coordinator.call_client_api(self._evo_device.off())
await self.coordinator.call_client_api(self._evo_device.set_off())

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["greenplanet-energy-api==0.1.4"],
"requirements": ["greenplanet-energy-api==0.1.10"],
"single_config_entry": true
}

View File

@@ -2,22 +2,9 @@
from __future__ import annotations
from aioimmich import Immich
from aioimmich.const import CONNECT_ERRORS
from aioimmich.exceptions import ImmichUnauthorizedError
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -38,30 +25,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
"""Set up Immich from a config entry."""
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
immich = Immich(
session,
entry.data[CONF_API_KEY],
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data[CONF_SSL],
"home-assistant",
)
try:
user_info = await immich.users.async_get_my_user()
except ImmichUnauthorizedError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except CONNECT_ERRORS as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
coordinator = ImmichDataUpdateCoordinator(hass, entry, immich, user_info.is_admin)
coordinator = ImmichDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -16,11 +16,19 @@ from aioimmich.server.models import (
ImmichServerVersionCheck,
)
from awesomeversion import AwesomeVersion
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -46,24 +54,49 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
config_entry: ImmichConfigEntry
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, api: Immich, is_admin: bool
) -> None:
def __init__(self, hass: HomeAssistant, config_entry: ImmichConfigEntry) -> None:
"""Initialize the data update coordinator."""
self.api = api
self.is_admin = is_admin
self.configuration_url = (
f"{'https' if entry.data[CONF_SSL] else 'http'}://"
f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}"
self.api = Immich(
async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]),
config_entry.data[CONF_API_KEY],
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.data[CONF_SSL],
"home-assistant",
)
self.is_admin = False
self.configuration_url = str(
URL.build(
scheme="https" if config_entry.data[CONF_SSL] else "http",
host=config_entry.data[CONF_HOST],
port=config_entry.data[CONF_PORT],
)
)
super().__init__(
hass,
_LOGGER,
config_entry=entry,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=60),
)
async def _async_setup(self) -> None:
"""Handle setup of the coordinator."""
try:
user_info = await self.api.users.async_get_my_user()
except ImmichUnauthorizedError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except CONNECT_ERRORS as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
self.is_admin = user_info.is_admin
async def _async_update_data(self) -> ImmichData:
"""Update data via internal method."""
try:

View File

@@ -20,13 +20,5 @@
"turn_on": {
"service": "mdi:toggle-switch"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:toggle-switch-off"
},
"turned_on": {
"trigger": "mdi:toggle-switch"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted toggles to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::input_boolean::title%]",
@@ -21,15 +17,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"reload": {
"description": "Reloads helpers from the YAML-configuration.",
@@ -48,27 +35,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Input boolean",
"triggers": {
"turned_off": {
"description": "Triggers after one or more toggles turn off.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned off"
},
"turned_on": {
"description": "Triggers after one or more toggles turn on.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned on"
}
}
"title": "Input boolean"
}

View File

@@ -1,17 +0,0 @@
"""Provides triggers for input booleans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for input booleans."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: input_boolean
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

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,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

@@ -90,5 +90,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.0.2"]
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.1.0"]
}

View File

@@ -0,0 +1,17 @@
"""Integration for power 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 = "power"
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:flash"
},
"crossed_threshold": {
"trigger": "mdi:flash"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "power",
"name": "Power",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/power",
"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": "Power",
"triggers": {
"changed": {
"description": "Triggers after one or more power values change.",
"fields": {
"above": {
"description": "Only trigger when power is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when power 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": "Power changed"
},
"crossed_threshold": {
"description": "Triggers after one or more power values cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::power::common::trigger_behavior_description%]",
"name": "[%key:component::power::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::power::triggers::changed::fields::unit::description%]",
"name": "[%key:component::power::triggers::changed::fields::unit::name%]"
},
"upper_limit": {
"description": "The upper limit of the threshold.",
"name": "Upper limit"
}
},
"name": "Power crossed threshold"
}
}
}

View File

@@ -0,0 +1,52 @@
"""Provides triggers for power."""
from __future__ import annotations
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerWithUnitBase,
Trigger,
)
from homeassistant.util.unit_conversion import PowerConverter
POWER_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER),
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER),
}
class _PowerTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
"""Mixin for power triggers providing entity filtering, value extraction, and unit conversion."""
_base_unit = UnitOfPower.WATT
_domain_specs = POWER_DOMAIN_SPECS
_unit_converter = PowerConverter
class PowerChangedTrigger(
_PowerTriggerMixin, EntityNumericalStateChangedTriggerWithUnitBase
):
"""Trigger for power value changes."""
class PowerCrossedThresholdTrigger(
_PowerTriggerMixin, EntityNumericalStateCrossedThresholdTriggerWithUnitBase
):
"""Trigger for power value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": PowerChangedTrigger,
"crossed_threshold": PowerCrossedThresholdTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for power."""
return TRIGGERS

View File

@@ -0,0 +1,87 @@
.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:
- "mW"
- "W"
- "kW"
- "MW"
- "GW"
- "TW"
- "BTU/h"
- domain: sensor
device_class: power
- domain: number
device_class: power
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:
- "mW"
- "W"
- "kW"
- "MW"
- "GW"
- "TW"
- "BTU/h"
.trigger_target: &trigger_target
entity:
- domain: number
device_class: power
- domain: sensor
device_class: power
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

@@ -125,6 +125,14 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="shutdown",
translation_key="shutdown",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.shutdown.post()
),
entity_category=EntityCategory.CONFIG,
),
)
CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (

View File

@@ -7,6 +7,9 @@
"reset": {
"default": "mdi:restart"
},
"shutdown": {
"default": "mdi:power"
},
"start": {
"default": "mdi:play"
},

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,14 @@ 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
for description in Q10_BUTTON_DESCRIPTIONS
),
)
)
@@ -233,3 +255,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.26.2",
"python-roborock==5.0.0",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

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

View File

@@ -80,23 +80,23 @@ Q7_STATE_CODE_TO_STATE = {
}
Q10_STATE_CODE_TO_STATE = {
YXDeviceState.SLEEP_STATE: VacuumActivity.IDLE,
YXDeviceState.STANDBY_STATE: VacuumActivity.IDLE,
YXDeviceState.CLEANING_STATE: VacuumActivity.CLEANING,
YXDeviceState.TO_CHARGE_STATE: VacuumActivity.RETURNING,
YXDeviceState.REMOTEING_STATE: VacuumActivity.CLEANING,
YXDeviceState.CHARGING_STATE: VacuumActivity.DOCKED,
YXDeviceState.PAUSE_STATE: VacuumActivity.PAUSED,
YXDeviceState.FAULT_STATE: VacuumActivity.ERROR,
YXDeviceState.UPGRADE_STATE: VacuumActivity.DOCKED,
YXDeviceState.DUSTING: VacuumActivity.DOCKED,
YXDeviceState.CREATING_MAP_STATE: VacuumActivity.CLEANING,
YXDeviceState.RE_LOCATION_STATE: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_SWEEPING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_MOPING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_SWEEP_AND_MOPING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_TRANSITIONING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_WAIT_CHARGE: VacuumActivity.DOCKED,
YXDeviceState.SLEEPING: VacuumActivity.IDLE,
YXDeviceState.IDLE: VacuumActivity.IDLE,
YXDeviceState.CLEANING: VacuumActivity.CLEANING,
YXDeviceState.RETURNING_HOME: VacuumActivity.RETURNING,
YXDeviceState.REMOTE_CONTROL_ACTIVE: VacuumActivity.CLEANING,
YXDeviceState.CHARGING: VacuumActivity.DOCKED,
YXDeviceState.PAUSED: VacuumActivity.PAUSED,
YXDeviceState.ERROR: VacuumActivity.ERROR,
YXDeviceState.UPDATING: VacuumActivity.DOCKED,
YXDeviceState.EMPTYING_THE_BIN: VacuumActivity.DOCKED,
YXDeviceState.MAPPING: VacuumActivity.CLEANING,
YXDeviceState.RELOCATING: VacuumActivity.CLEANING,
YXDeviceState.SWEEPING: VacuumActivity.CLEANING,
YXDeviceState.MOPPING: VacuumActivity.CLEANING,
YXDeviceState.SWEEP_AND_MOP: VacuumActivity.CLEANING,
YXDeviceState.TRANSITIONING: VacuumActivity.CLEANING,
YXDeviceState.WAITING_TO_CHARGE: VacuumActivity.DOCKED,
}
PARALLEL_UPDATES = 0

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": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["tplink-omada-client==1.5.3"]
"requirements": ["tplink-omada-client==1.5.6"]
}

View File

@@ -6,7 +6,7 @@ from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiCli
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
@@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry)
try:
await client.authenticate()
except ApiAuthError as err:
raise ConfigEntryNotReady(
raise ConfigEntryAuthFailed(
f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}"
) from err
except ApiConnectionError as err:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -66,3 +67,48 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirm."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
session = async_get_clientsession(
self.hass, verify_ssl=reauth_entry.data[CONF_VERIFY_SSL]
)
client = UnifiAccessApiClient(
host=reauth_entry.data[CONF_HOST],
api_token=user_input[CONF_API_TOKEN],
session=session,
verify_ssl=reauth_entry.data[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError:
errors["base"] = "invalid_auth"
except ApiConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
description_placeholders={CONF_HOST: reauth_entry.data[CONF_HOST]},
errors=errors,
)

View File

@@ -30,6 +30,7 @@ from unifi_access_api.models.websocket import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -116,7 +117,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
self.client.get_emergency_status(),
)
except ApiAuthError as err:
raise UpdateFailed(f"Authentication failed: {err}") from err
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except ApiConnectionError as err:
raise UpdateFailed(f"Error connecting to API: {err}") from err
except ApiError as err:

View File

@@ -34,7 +34,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,15 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"data_description": {
"api_token": "[%key:component::unifi_access::config::step::user::data_description::api_token%]"
},
"description": "The API token for UniFi Access at {host} is invalid. Please provide a new token."
},
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",

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

@@ -177,6 +177,24 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
),
# Pressure present duration (in seconds) for Pressure Sensor
(
ExtendedSensorDeviceClass.PRESSURE_PRESENT_DURATION,
Units.TIME_SECONDS,
): SensorEntityDescription(
key=str(ExtendedSensorDeviceClass.PRESSURE_PRESENT_DURATION),
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.MEASUREMENT,
),
# Pressure not present duration (in seconds) for Pressure Sensor
(
ExtendedSensorDeviceClass.PRESSURE_NOT_PRESENT_DURATION,
Units.TIME_SECONDS,
): SensorEntityDescription(
key=str(ExtendedSensorDeviceClass.PRESSURE_NOT_PRESENT_DURATION),
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.MEASUREMENT,
),
# Low frequency impedance sensor (ohm)
(ExtendedSensorDeviceClass.IMPEDANCE_LOW, Units.OHM): SensorEntityDescription(
key=str(ExtendedSensorDeviceClass.IMPEDANCE_LOW),

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/yolink",
"integration_type": "hub",
"iot_class": "cloud_push",
"requirements": ["yolink-api==0.6.1"]
"requirements": ["yolink-api==0.6.3"]
}

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
@@ -82,7 +84,7 @@ from .target import (
async_track_target_selector_state_change_event,
)
from .template import Template
from .typing import ConfigType, TemplateVarsType
from .typing import UNDEFINED, ConfigType, TemplateVarsType, UndefinedType
_LOGGER = logging.getLogger(__name__)
@@ -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,120 @@ 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:
_valid_unit: str | None | UndefinedType = UNDEFINED
def _is_valid_unit(self, unit: str | None) -> bool:
"""Check if the given unit is valid for this trigger."""
if isinstance(self._valid_unit, UndefinedType):
return True
return unit == self._valid_unit
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
if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
# Entity unit does not match the expected unit
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)
if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
return None
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 +734,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 +742,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 +753,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 +763,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 +876,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 +889,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 +905,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]:
@@ -861,6 +1029,7 @@ def make_entity_origin_state_trigger(
def make_entity_numerical_state_changed_trigger(
domain_specs: Mapping[str, NumericalDomainSpec],
valid_unit: str | None | UndefinedType = UNDEFINED,
) -> type[EntityNumericalStateChangedTriggerBase]:
"""Create a trigger for numerical state value change."""
@@ -868,12 +1037,14 @@ def make_entity_numerical_state_changed_trigger(
"""Trigger for numerical state value changes."""
_domain_specs = domain_specs
_valid_unit = valid_unit
return CustomTrigger
def make_entity_numerical_state_crossed_threshold_trigger(
domain_specs: Mapping[str, NumericalDomainSpec],
valid_unit: str | None | UndefinedType = UNDEFINED,
) -> type[EntityNumericalStateCrossedThresholdTriggerBase]:
"""Create a trigger for numerical state value crossing a threshold."""
@@ -881,6 +1052,7 @@ def make_entity_numerical_state_crossed_threshold_trigger(
"""Trigger for numerical state value crossing a threshold."""
_domain_specs = domain_specs
_valid_unit = valid_unit
return CustomTrigger

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

18
requirements_all.txt generated
View File

@@ -945,7 +945,7 @@ eufylife-ble-client==0.1.8
# evdev==1.9.3
# homeassistant.components.evohome
evohome-async==1.1.3
evohome-async==1.2.0
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -1140,7 +1140,7 @@ greeclimate==2.1.1
greeneye_monitor==3.0.3
# homeassistant.components.green_planet_energy
greenplanet-energy-api==0.1.4
greenplanet-energy-api==0.1.10
# homeassistant.components.greenwave
greenwavereality==0.5.1
@@ -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
@@ -2427,7 +2427,7 @@ pyrail==0.4.1
pyrainbird==6.1.1
# homeassistant.components.playstation_network
pyrate-limiter==4.0.2
pyrate-limiter==4.1.0
# homeassistant.components.recswitch
pyrecswitch==1.0.2
@@ -2660,7 +2660,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==4.26.2
python-roborock==5.0.0
# homeassistant.components.smarttub
python-smarttub==0.0.47
@@ -3136,7 +3136,7 @@ total-connect-client==2025.12.2
tp-connected==0.0.4
# homeassistant.components.tplink_omada
tplink-omada-client==1.5.3
tplink-omada-client==1.5.6
# homeassistant.components.transmission
transmission-rpc==7.0.3
@@ -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
@@ -3356,7 +3356,7 @@ yeelight==0.7.16
yeelightsunflower==0.0.10
# homeassistant.components.yolink
yolink-api==0.6.1
yolink-api==0.6.3
# homeassistant.components.youless
youless-api==2.2.0

View File

@@ -833,7 +833,7 @@ eternalegypt==0.0.18
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
evohome-async==1.1.3
evohome-async==1.2.0
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -1013,7 +1013,7 @@ greeclimate==2.1.1
greeneye_monitor==3.0.3
# homeassistant.components.green_planet_energy
greenplanet-energy-api==0.1.4
greenplanet-energy-api==0.1.10
# homeassistant.components.pure_energie
gridnet==5.0.1
@@ -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
@@ -2077,7 +2077,7 @@ pyrail==0.4.1
pyrainbird==6.1.1
# homeassistant.components.playstation_network
pyrate-limiter==4.0.2
pyrate-limiter==4.1.0
# homeassistant.components.risco
pyrisco==0.6.7
@@ -2256,7 +2256,7 @@ python-pooldose==0.8.6
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==4.26.2
python-roborock==5.0.0
# homeassistant.components.smarttub
python-smarttub==0.0.47
@@ -2639,7 +2639,7 @@ toonapi==0.3.0
total-connect-client==2025.12.2
# homeassistant.components.tplink_omada
tplink-omada-client==1.5.3
tplink-omada-client==1.5.6
# homeassistant.components.transmission
transmission-rpc==7.0.3
@@ -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
@@ -2832,7 +2832,7 @@ yalexs==9.2.0
yeelight==0.7.16
# homeassistant.components.yolink
yolink-api==0.6.1
yolink-api==0.6.3
# homeassistant.components.youless
youless-api==2.2.0

View File

@@ -108,6 +108,7 @@ NO_IOT_CLASS = [
"onboarding",
"panel_custom",
"plant",
"power",
"profiler",
"proxy",
"python_script",
@@ -120,6 +121,7 @@ NO_IOT_CLASS = [
"system_health",
"system_log",
"tag",
"temperature",
"timer",
"trace",
"web_rtc",

View File

@@ -2138,6 +2138,7 @@ NO_QUALITY_SCALE = [
"occupancy",
"onboarding",
"panel_custom",
"power",
"proxy",
"python_script",
"raspberry_pi",
@@ -2149,6 +2150,7 @@ NO_QUALITY_SCALE = [
"system_health",
"system_log",
"tag",
"temperature",
"timer",
"trace",
"usage_prediction",

View File

@@ -18,6 +18,7 @@ from homeassistant.const import (
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.trigger import async_validate_trigger_config
@@ -36,6 +37,8 @@ from tests.components.common import (
target_entities,
)
_TEMPERATURE_TRIGGER_OPTIONS = {"unit": UnitOfTemperature.CELSIUS}
@pytest.fixture
async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
@@ -191,7 +194,10 @@ async def test_climate_state_trigger_behavior_any(
"climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY
),
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_temperature_changed", HVACMode.AUTO, ATTR_TEMPERATURE
"climate.target_temperature_changed",
HVACMode.AUTO,
ATTR_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
@@ -200,6 +206,7 @@ async def test_climate_state_trigger_behavior_any(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -318,6 +325,7 @@ async def test_climate_state_trigger_behavior_first(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",
@@ -436,6 +444,7 @@ async def test_climate_state_trigger_behavior_last(
"climate.target_temperature_crossed_threshold",
HVACMode.AUTO,
ATTR_TEMPERATURE,
trigger_options=_TEMPERATURE_TRIGGER_OPTIONS,
),
*parametrize_trigger_states(
trigger="climate.started_cooling",

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

@@ -49,13 +49,13 @@ LOG_FAIL_GATEWAY = (
"homeassistant.components.evohome",
logging.ERROR,
"Failed to fetch initial data: "
"Authenticator response is invalid: 502 Bad Gateway, response=None",
"Authenticator response is invalid: 502 Bad Gateway, response=<no response>",
)
LOG_FAIL_TOO_MANY = (
"homeassistant.components.evohome",
logging.ERROR,
"Failed to fetch initial data: "
"Authenticator response is invalid: 429 Too Many Requests, response=None",
"Authenticator response is invalid: 429 Too Many Requests, response=<no response>",
)
LOG_FGET_CONNECTION = (
@@ -70,14 +70,14 @@ LOG_FGET_GATEWAY = (
logging.ERROR,
"Failed to fetch initial data: "
"GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: "
"502 Bad Gateway, response=None",
"502 Bad Gateway",
)
LOG_FGET_TOO_MANY = (
"homeassistant.components.evohome",
logging.ERROR,
"Failed to fetch initial data: "
"GET https://tccna.resideo.com/WebAPI/emea/api/v1/userAccount: "
"429 Too Many Requests, response=None",
"429 Too Many Requests",
)

View File

@@ -76,7 +76,7 @@ async def test_set_operation_mode(
mock_fcn.assert_awaited_once_with()
# SERVICE_SET_OPERATION_MODE: off (until next scheduled setpoint)
with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn:
with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn:
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
@@ -96,7 +96,7 @@ async def test_set_operation_mode(
results.append(mock_fcn.await_args.kwargs)
# SERVICE_SET_OPERATION_MODE: on (until next scheduled setpoint)
with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn:
with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn:
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
@@ -137,7 +137,7 @@ async def test_set_away_mode(hass: HomeAssistant, evohome: EvohomeClient) -> Non
mock_fcn.assert_awaited_once_with()
# set_away_mode: on
with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn:
with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn:
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_AWAY_MODE,
@@ -156,7 +156,7 @@ async def test_turn_off(hass: HomeAssistant, evohome: EvohomeClient) -> None:
"""Test SERVICE_TURN_OFF of an evohome DHW zone."""
# turn_off
with patch("evohomeasync2.hotwater.HotWater.off") as mock_fcn:
with patch("evohomeasync2.hotwater.HotWater.set_off") as mock_fcn:
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_OFF,
@@ -174,7 +174,7 @@ async def test_turn_on(hass: HomeAssistant, evohome: EvohomeClient) -> None:
"""Test SERVICE_TURN_ON of an evohome DHW zone."""
# turn_on
with patch("evohomeasync2.hotwater.HotWater.on") as mock_fcn:
with patch("evohomeasync2.hotwater.HotWater.set_on") as mock_fcn:
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_ON,

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

@@ -287,7 +287,9 @@ async def mock_immich(
) -> AsyncGenerator[AsyncMock]:
"""Mock the Immich API."""
with (
patch("homeassistant.components.immich.Immich", autospec=True) as mock_immich,
patch(
"homeassistant.components.immich.coordinator.Immich", autospec=True
) as mock_immich,
patch("homeassistant.components.immich.config_flow.Immich", new=mock_immich),
):
client = mock_immich.return_value

View File

@@ -1,175 +0,0 @@
"""Test input boolean triggers."""
from typing import Any
import pytest
from homeassistant.components.input_boolean import DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
parametrize_target_entities,
parametrize_trigger_states,
target_entities,
)
@pytest.fixture
async def target_input_booleans(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple input_boolean entities associated with different targets."""
return await target_entities(hass, DOMAIN)
@pytest.mark.parametrize(
"trigger_key",
[
"input_boolean.turned_off",
"input_boolean.turned_on",
],
)
async def test_input_boolean_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the input_boolean triggers are gated by the labs flag."""
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="input_boolean.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="input_boolean.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_input_boolean_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_input_booleans: 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 that the input_boolean state trigger fires when any input_boolean state changes to a specific state."""
await assert_trigger_behavior_any(
hass,
service_calls=service_calls,
target_entities=target_input_booleans,
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(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="input_boolean.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="input_boolean.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_input_boolean_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_input_booleans: 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 that the input_boolean state trigger fires when the first input_boolean changes to a specific state."""
await assert_trigger_behavior_first(
hass,
service_calls=service_calls,
target_entities=target_input_booleans,
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(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="input_boolean.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="input_boolean.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_input_boolean_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_input_booleans: 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 that the input_boolean state trigger fires when the last input_boolean changes to a specific state."""
await assert_trigger_behavior_last(
hass,
service_calls=service_calls,
target_entities=target_input_booleans,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)

View File

@@ -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

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

View File

@@ -0,0 +1,312 @@
"""Test power trigger."""
from typing import Any
import pytest
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
parametrize_numerical_state_value_changed_trigger_states,
parametrize_numerical_state_value_crossed_threshold_trigger_states,
parametrize_target_entities,
target_entities,
)
_POWER_TRIGGER_OPTIONS = {"unit": UnitOfPower.WATT}
_POWER_UNIT_ATTRIBUTES = {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}
@pytest.fixture
async def target_numbers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple number entities associated with different targets."""
return await target_entities(hass, "number")
@pytest.fixture
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple sensor entities associated with different targets."""
return await target_entities(hass, "sensor")
@pytest.mark.parametrize(
"trigger_key",
[
"power.changed",
"power.crossed_threshold",
],
)
async def test_power_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the power triggers are gated by the labs flag."""
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"power.changed",
device_class=SensorDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"power.crossed_threshold",
device_class=SensorDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_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 power trigger fires for sensor entities with device_class power."""
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(
"power.crossed_threshold",
device_class=SensorDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_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 power 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(
"power.crossed_threshold",
device_class=SensorDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_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 power 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,
)
# --- Number entity tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_changed_trigger_states(
"power.changed",
device_class=NumberDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"power.crossed_threshold",
device_class=NumberDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_trigger_number_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test power trigger fires for number entities with device_class power."""
await assert_trigger_behavior_any(
hass,
service_calls=service_calls,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"power.crossed_threshold",
device_class=NumberDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_trigger_number_crossed_threshold_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test power crossed_threshold trigger fires on the first number state change."""
await assert_trigger_behavior_first(
hass,
service_calls=service_calls,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("number"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_state_value_crossed_threshold_trigger_states(
"power.crossed_threshold",
device_class=NumberDeviceClass.POWER,
trigger_options=_POWER_TRIGGER_OPTIONS,
unit_attributes=_POWER_UNIT_ATTRIBUTES,
),
],
)
async def test_power_trigger_number_crossed_threshold_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_numbers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test power crossed_threshold trigger fires when the last number changes state."""
await assert_trigger_behavior_last(
hass,
service_calls=service_calls,
target_entities=target_numbers,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)

View File

@@ -653,6 +653,56 @@
'state': 'unknown',
})
# ---
# name: test_all_button_entities[button.vm_db_shutdown-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': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.vm_db_shutdown',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Shutdown',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Shutdown',
'platform': 'proxmoxve',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'shutdown',
'unique_id': '1234_101_shutdown',
'unit_of_measurement': None,
})
# ---
# name: test_all_button_entities[button.vm_db_shutdown-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'vm-db Shutdown',
}),
'context': <ANY>,
'entity_id': 'button.vm_db_shutdown',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_button_entities[button.vm_db_start-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -904,6 +954,56 @@
'state': 'unknown',
})
# ---
# name: test_all_button_entities[button.vm_web_shutdown-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': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.vm_web_shutdown',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Shutdown',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Shutdown',
'platform': 'proxmoxve',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'shutdown',
'unique_id': '1234_100_shutdown',
'unit_of_measurement': None,
})
# ---
# name: test_all_button_entities[button.vm_web_shutdown-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'vm-web Shutdown',
}),
'context': <ANY>,
'entity_id': 'button.vm_web_shutdown',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_button_entities[button.vm_web_start-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -115,6 +115,7 @@ async def test_node_startall_stopall_buttons(
("button.vm_web_restart", 100, "reboot"),
("button.vm_web_hibernate", 100, "hibernate"),
("button.vm_web_reset", 100, "reset"),
("button.vm_web_shutdown", 100, "shutdown"),
],
)
async def test_vm_buttons(

View File

@@ -1570,8 +1570,8 @@ Q10_STATUS = Q10Status(
clean_time=120,
clean_area=15,
battery=100,
status=YXDeviceState.CHARGING_STATE,
status=YXDeviceState.CHARGING,
fan_level=YXFanLevel.BALANCED,
water_level=YXWaterLevel.MIDDLE,
water_level=YXWaterLevel.MEDIUM,
clean_count=1,
)

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

@@ -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

@@ -147,3 +147,72 @@ async def test_user_flow_different_host(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_reauth_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful reauthentication flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_TOKEN: "new-api-token"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_TOKEN] == "new-api-token"
assert mock_config_entry.data[CONF_HOST] == MOCK_HOST
assert mock_config_entry.data[CONF_VERIFY_SSL] is False
@pytest.mark.parametrize(
("exception", "error"),
[
(ApiConnectionError("Connection failed"), "cannot_connect"),
(ApiAuthError(), "invalid_auth"),
(RuntimeError("boom"), "unknown"),
],
)
async def test_reauth_flow_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
error: str,
) -> None:
"""Test reauthentication flow errors and recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
mock_client.authenticate.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_TOKEN: "new-api-token"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_client.authenticate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_TOKEN: "new-api-token"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"

View File

@@ -22,7 +22,7 @@ from unifi_access_api.models.websocket import (
WebsocketMessage,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
@@ -55,7 +55,7 @@ async def test_setup_entry(
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(ApiAuthError(), ConfigEntryState.SETUP_RETRY),
(ApiAuthError(), ConfigEntryState.SETUP_ERROR),
(ApiConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY),
],
)
@@ -74,16 +74,30 @@ async def test_setup_entry_error(
assert mock_config_entry.state is expected_state
if expected_state is ConfigEntryState.SETUP_ERROR:
assert any(
flow["context"]["source"] == SOURCE_REAUTH
for flow in hass.config_entries.flow.async_progress()
)
@pytest.mark.parametrize(
("failing_method", "exception"),
("failing_method", "exception", "expected_state"),
[
("get_doors", ApiAuthError()),
("get_doors", ApiConnectionError("Connection failed")),
("get_doors", ApiError("API error")),
("get_emergency_status", ApiAuthError()),
("get_emergency_status", ApiConnectionError("Connection failed")),
("get_emergency_status", ApiError("API error")),
("get_doors", ApiAuthError(), ConfigEntryState.SETUP_ERROR),
(
"get_doors",
ApiConnectionError("Connection failed"),
ConfigEntryState.SETUP_RETRY,
),
("get_doors", ApiError("API error"), ConfigEntryState.SETUP_RETRY),
("get_emergency_status", ApiAuthError(), ConfigEntryState.SETUP_ERROR),
(
"get_emergency_status",
ApiConnectionError("Connection failed"),
ConfigEntryState.SETUP_RETRY,
),
("get_emergency_status", ApiError("API error"), ConfigEntryState.SETUP_RETRY),
],
)
async def test_coordinator_update_error(
@@ -92,6 +106,7 @@ async def test_coordinator_update_error(
mock_client: MagicMock,
failing_method: str,
exception: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test coordinator handles update errors from get_doors or get_emergency_status."""
getattr(mock_client, failing_method).side_effect = exception
@@ -99,7 +114,13 @@ async def test_coordinator_update_error(
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_config_entry.state is expected_state
if expected_state is ConfigEntryState.SETUP_ERROR:
assert any(
flow["context"]["source"] == SOURCE_REAUTH
for flow in hass.config_entries.flow.async_progress()
)
async def test_unload_entry(

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 = [

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,7 @@
'occupancy',
'onboarding',
'person',
'power',
'remote',
'repairs',
'scene',
@@ -86,6 +87,7 @@
'system_health',
'system_log',
'tag',
'temperature',
'text',
'time',
'timer',
@@ -176,6 +178,7 @@
'occupancy',
'onboarding',
'person',
'power',
'remote',
'repairs',
'scene',
@@ -190,6 +193,7 @@
'system_health',
'system_log',
'tag',
'temperature',
'text',
'time',
'timer',