mirror of
https://github.com/home-assistant/core.git
synced 2025-12-21 07:18:03 +00:00
Compare commits
19 Commits
test-hassf
...
input_bool
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ec003124 | ||
|
|
0db9dcfd1c | ||
|
|
5b5850224a | ||
|
|
065b0eb5b2 | ||
|
|
6a1d86d5db | ||
|
|
f99a73ef28 | ||
|
|
0436d30062 | ||
|
|
24b6b5452b | ||
|
|
8b91ebfe30 | ||
|
|
37d3b73c1b | ||
|
|
c881d9809e | ||
|
|
85dfe3a107 | ||
|
|
d8a468833e | ||
|
|
5bbd56b8e6 | ||
|
|
d0411b6613 | ||
|
|
293fbebef2 | ||
|
|
ee0230f3b1 | ||
|
|
851fd467fe | ||
|
|
d10148a175 |
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.const import (
|
||||
CONF_EVENT_DATA,
|
||||
CONF_ID,
|
||||
CONF_MODE,
|
||||
CONF_OPTIONS,
|
||||
CONF_PATH,
|
||||
CONF_PLATFORM,
|
||||
CONF_TRIGGERS,
|
||||
@@ -131,9 +132,12 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"input_boolean",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"siren",
|
||||
"switch",
|
||||
"text",
|
||||
"update",
|
||||
@@ -1215,7 +1219,7 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
|
||||
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "calendar":
|
||||
return [trigger_conf[CONF_ENTITY_ID]]
|
||||
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "zone":
|
||||
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
|
||||
|
||||
@@ -2,29 +2,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_PLATFORM
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_OPTIONS
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_point_in_time,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import CalendarEntity, CalendarEvent
|
||||
from .const import DATA_COMPONENT, DOMAIN
|
||||
from .const import DATA_COMPONENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,13 +33,17 @@ EVENT_START = "start"
|
||||
EVENT_END = "end"
|
||||
UPDATE_INTERVAL = datetime.timedelta(minutes=15)
|
||||
|
||||
TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
|
||||
_OPTIONS_SCHEMA_DICT = {
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
|
||||
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
|
||||
}
|
||||
|
||||
_CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): DOMAIN,
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
|
||||
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
|
||||
}
|
||||
vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT,
|
||||
},
|
||||
)
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
@@ -169,14 +174,14 @@ class CalendarEventListener:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
job: HassJob[..., Coroutine[Any, Any, None] | Any],
|
||||
trigger_data: dict[str, Any],
|
||||
action_runner: TriggerActionRunner,
|
||||
trigger_payload: dict[str, Any],
|
||||
fetcher: QueuedEventFetcher,
|
||||
) -> None:
|
||||
"""Initialize CalendarEventListener."""
|
||||
self._hass = hass
|
||||
self._job = job
|
||||
self._trigger_data = trigger_data
|
||||
self._action_runner = action_runner
|
||||
self._trigger_payload = trigger_payload
|
||||
self._unsub_event: CALLBACK_TYPE | None = None
|
||||
self._unsub_refresh: CALLBACK_TYPE | None = None
|
||||
self._fetcher = fetcher
|
||||
@@ -233,15 +238,11 @@ class CalendarEventListener:
|
||||
while self._events and self._events[0].trigger_time <= now:
|
||||
queued_event = self._events.pop(0)
|
||||
_LOGGER.debug("Dispatching event: %s", queued_event.event)
|
||||
self._hass.async_run_hass_job(
|
||||
self._job,
|
||||
{
|
||||
"trigger": {
|
||||
**self._trigger_data,
|
||||
"calendar_event": queued_event.event.as_dict(),
|
||||
}
|
||||
},
|
||||
)
|
||||
payload = {
|
||||
**self._trigger_payload,
|
||||
"calendar_event": queued_event.event.as_dict(),
|
||||
}
|
||||
self._action_runner(payload, "calendar event state change")
|
||||
|
||||
async def _handle_refresh(self, now_utc: datetime.datetime) -> None:
|
||||
"""Handle core config update."""
|
||||
@@ -259,31 +260,69 @@ class CalendarEventListener:
|
||||
self._listen_next_calendar_event()
|
||||
|
||||
|
||||
async def async_attach_trigger(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
action: TriggerActionType,
|
||||
trigger_info: TriggerInfo,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach trigger for the specified calendar."""
|
||||
entity_id = config[CONF_ENTITY_ID]
|
||||
event_type = config[CONF_EVENT]
|
||||
offset = config[CONF_OFFSET]
|
||||
class EventTrigger(Trigger):
|
||||
"""Calendar event trigger."""
|
||||
|
||||
# Validate the entity id is valid
|
||||
get_entity(hass, entity_id)
|
||||
_options: dict[str, Any]
|
||||
|
||||
trigger_data = {
|
||||
**trigger_info["trigger_data"],
|
||||
"platform": DOMAIN,
|
||||
"event": event_type,
|
||||
"offset": offset,
|
||||
}
|
||||
listener = CalendarEventListener(
|
||||
hass,
|
||||
HassJob(action),
|
||||
trigger_data,
|
||||
queued_event_fetcher(event_fetcher(hass, entity_id), event_type, offset),
|
||||
)
|
||||
await listener.async_attach()
|
||||
return listener.async_detach
|
||||
@classmethod
|
||||
async def async_validate_complete_config(
|
||||
cls, hass: HomeAssistant, complete_config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate complete config."""
|
||||
complete_config = move_top_level_schema_fields_to_options(
|
||||
complete_config, _OPTIONS_SCHEMA_DICT
|
||||
)
|
||||
return await super().async_validate_complete_config(hass, complete_config)
|
||||
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, _CONFIG_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
self._options = config.options
|
||||
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
|
||||
entity_id = self._options[CONF_ENTITY_ID]
|
||||
event_type = self._options[CONF_EVENT]
|
||||
offset = self._options[CONF_OFFSET]
|
||||
|
||||
# Validate the entity id is valid
|
||||
get_entity(self._hass, entity_id)
|
||||
|
||||
trigger_data = {
|
||||
"event": event_type,
|
||||
"offset": offset,
|
||||
}
|
||||
listener = CalendarEventListener(
|
||||
self._hass,
|
||||
run_action,
|
||||
trigger_data,
|
||||
queued_event_fetcher(
|
||||
event_fetcher(self._hass, entity_id), event_type, offset
|
||||
),
|
||||
)
|
||||
await listener.async_attach()
|
||||
return listener.async_detach
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"_": EventTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for calendars."""
|
||||
return TRIGGERS
|
||||
|
||||
@@ -110,6 +110,12 @@
|
||||
"started_heating": {
|
||||
"trigger": "mdi:fire"
|
||||
},
|
||||
"target_temperature_changed": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"target_temperature_crossed_threshold": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"turned_off": {
|
||||
"trigger": "mdi:power-off"
|
||||
},
|
||||
|
||||
@@ -192,12 +192,26 @@
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"number_or_entity": {
|
||||
"choices": {
|
||||
"entity": "Entity",
|
||||
"number": "Number"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
},
|
||||
"trigger_threshold_type": {
|
||||
"options": {
|
||||
"above": "Above a value",
|
||||
"below": "Below a value",
|
||||
"between": "In a range",
|
||||
"outside": "Outside a range"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -342,6 +356,42 @@
|
||||
},
|
||||
"name": "Climate-control device started heating"
|
||||
},
|
||||
"target_temperature_changed": {
|
||||
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the target temperature is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the target temperature is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device target temperature changed"
|
||||
},
|
||||
"target_temperature_crossed_threshold": {
|
||||
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device target temperature crossed threshold"
|
||||
},
|
||||
"turned_off": {
|
||||
"description": "Triggers after one or more climate-control devices turn off.",
|
||||
"fields": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_OPTIONS
|
||||
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.trigger import (
|
||||
@@ -10,6 +10,8 @@ from homeassistant.helpers.trigger import (
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
make_entity_numerical_state_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
@@ -50,6 +52,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_TEMPERATURE
|
||||
),
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_TEMPERATURE
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
|
||||
"turned_on": make_entity_transition_trigger(
|
||||
DOMAIN,
|
||||
|
||||
@@ -14,6 +14,36 @@
|
||||
- last
|
||||
- any
|
||||
|
||||
.number_or_entity: &number_or_entity
|
||||
required: false
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain:
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
translation_key: number_or_entity
|
||||
|
||||
.trigger_threshold_type: &trigger_threshold_type
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- above
|
||||
- below
|
||||
- between
|
||||
- outside
|
||||
translation_key: trigger_threshold_type
|
||||
|
||||
started_cooling: *trigger_common
|
||||
started_drying: *trigger_common
|
||||
started_heating: *trigger_common
|
||||
@@ -34,3 +64,17 @@ hvac_mode_changed:
|
||||
- unavailable
|
||||
- unknown
|
||||
multiple: true
|
||||
|
||||
target_temperature_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
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
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251203.2"]
|
||||
"requirements": ["home-assistant-frontend==20251203.3"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,13 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:toggle-switch"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"trigger": "mdi:toggle-switch-off"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:toggle-switch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted input booleans to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::input_boolean::title%]",
|
||||
@@ -17,6 +21,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads helpers from the YAML-configuration.",
|
||||
@@ -35,5 +48,27 @@
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
"title": "Input boolean"
|
||||
"title": "Input boolean",
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers after one or more input booleans turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Input boolean turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers after one or more input booleans turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Input boolean turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
homeassistant/components/input_boolean/trigger.py
Normal file
17
homeassistant/components/input_boolean/trigger.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""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
|
||||
18
homeassistant/components/input_boolean/triggers.yaml
Normal file
18
homeassistant/components/input_boolean/triggers.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
.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
|
||||
@@ -168,6 +168,7 @@ SUPPORTED_PLATFORMS_UI: Final = {
|
||||
Platform.FAN,
|
||||
Platform.DATETIME,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
}
|
||||
|
||||
146
homeassistant/components/knx/dpt.py
Normal file
146
homeassistant/components/knx/dpt.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""KNX DPT serializer."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import cache
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric
|
||||
from xknx.dpt.dpt_16 import DPTString
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
|
||||
HaDptClass = Literal["numeric", "enum", "complex", "string"]
|
||||
|
||||
|
||||
class DPTInfo(TypedDict):
|
||||
"""DPT information."""
|
||||
|
||||
dpt_class: HaDptClass
|
||||
main: int
|
||||
sub: int | None
|
||||
name: str | None
|
||||
unit: str | None
|
||||
sensor_device_class: SensorDeviceClass | None
|
||||
sensor_state_class: SensorStateClass | None
|
||||
|
||||
|
||||
@cache
|
||||
def get_supported_dpts() -> Mapping[str, DPTInfo]:
|
||||
"""Return a mapping of supported DPTs with HA specific attributes."""
|
||||
dpts = {}
|
||||
for dpt_class in DPTBase.dpt_class_tree():
|
||||
dpt_number_str = dpt_class.dpt_number_str()
|
||||
ha_dpt_class = _ha_dpt_class(dpt_class)
|
||||
dpts[dpt_number_str] = DPTInfo(
|
||||
dpt_class=ha_dpt_class,
|
||||
main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests
|
||||
sub=dpt_class.dpt_sub_number,
|
||||
name=dpt_class.value_type,
|
||||
unit=dpt_class.unit,
|
||||
sensor_device_class=_sensor_device_classes.get(dpt_number_str),
|
||||
sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str),
|
||||
)
|
||||
return dpts
|
||||
|
||||
|
||||
def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass:
|
||||
"""Return the DPT class identifier string."""
|
||||
if issubclass(dpt_cls, DPTNumeric):
|
||||
return "numeric"
|
||||
if issubclass(dpt_cls, DPTEnum):
|
||||
return "enum"
|
||||
if issubclass(dpt_cls, DPTComplex):
|
||||
return "complex"
|
||||
if issubclass(dpt_cls, DPTString):
|
||||
return "string"
|
||||
raise ValueError("Unsupported DPT class")
|
||||
|
||||
|
||||
_sensor_device_classes: Mapping[str, SensorDeviceClass] = {
|
||||
"7.011": SensorDeviceClass.DISTANCE,
|
||||
"7.012": SensorDeviceClass.CURRENT,
|
||||
"7.013": SensorDeviceClass.ILLUMINANCE,
|
||||
"8.012": SensorDeviceClass.DISTANCE,
|
||||
"9.001": SensorDeviceClass.TEMPERATURE,
|
||||
"9.002": SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
"9.004": SensorDeviceClass.ILLUMINANCE,
|
||||
"9.005": SensorDeviceClass.WIND_SPEED,
|
||||
"9.006": SensorDeviceClass.PRESSURE,
|
||||
"9.007": SensorDeviceClass.HUMIDITY,
|
||||
"9.020": SensorDeviceClass.VOLTAGE,
|
||||
"9.021": SensorDeviceClass.CURRENT,
|
||||
"9.024": SensorDeviceClass.POWER,
|
||||
"9.025": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"9.027": SensorDeviceClass.TEMPERATURE,
|
||||
"9.028": SensorDeviceClass.WIND_SPEED,
|
||||
"9.029": SensorDeviceClass.ABSOLUTE_HUMIDITY,
|
||||
"12.1200": SensorDeviceClass.VOLUME,
|
||||
"12.1201": SensorDeviceClass.VOLUME,
|
||||
"13.002": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"13.010": SensorDeviceClass.ENERGY,
|
||||
"13.012": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
"13.013": SensorDeviceClass.ENERGY,
|
||||
"13.015": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
"13.016": SensorDeviceClass.ENERGY,
|
||||
"13.1200": SensorDeviceClass.VOLUME,
|
||||
"13.1201": SensorDeviceClass.VOLUME,
|
||||
"14.010": SensorDeviceClass.AREA,
|
||||
"14.019": SensorDeviceClass.CURRENT,
|
||||
"14.027": SensorDeviceClass.VOLTAGE,
|
||||
"14.028": SensorDeviceClass.VOLTAGE,
|
||||
"14.030": SensorDeviceClass.VOLTAGE,
|
||||
"14.031": SensorDeviceClass.ENERGY,
|
||||
"14.033": SensorDeviceClass.FREQUENCY,
|
||||
"14.037": SensorDeviceClass.ENERGY_STORAGE,
|
||||
"14.039": SensorDeviceClass.DISTANCE,
|
||||
"14.051": SensorDeviceClass.WEIGHT,
|
||||
"14.056": SensorDeviceClass.POWER,
|
||||
"14.057": SensorDeviceClass.POWER_FACTOR,
|
||||
"14.058": SensorDeviceClass.PRESSURE,
|
||||
"14.065": SensorDeviceClass.SPEED,
|
||||
"14.068": SensorDeviceClass.TEMPERATURE,
|
||||
"14.069": SensorDeviceClass.TEMPERATURE,
|
||||
"14.070": SensorDeviceClass.TEMPERATURE_DELTA,
|
||||
"14.076": SensorDeviceClass.VOLUME,
|
||||
"14.077": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"14.080": SensorDeviceClass.APPARENT_POWER,
|
||||
"14.1200": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"14.1201": SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
"29.010": SensorDeviceClass.ENERGY,
|
||||
"29.012": SensorDeviceClass.REACTIVE_ENERGY,
|
||||
}
|
||||
|
||||
_sensor_state_class_overrides: Mapping[str, SensorStateClass | None] = {
|
||||
"5.003": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngle
|
||||
"5.006": None, # DPTTariff
|
||||
"7.010": None, # DPTPropDataType
|
||||
"8.011": SensorStateClass.MEASUREMENT_ANGLE, # DPTRotationAngle
|
||||
"9.026": SensorStateClass.TOTAL_INCREASING, # DPTRainAmount
|
||||
"12.1200": SensorStateClass.TOTAL, # DPTVolumeLiquidLitre
|
||||
"12.1201": SensorStateClass.TOTAL, # DPTVolumeM3
|
||||
"13.010": SensorStateClass.TOTAL, # DPTActiveEnergy
|
||||
"13.011": SensorStateClass.TOTAL, # DPTApparantEnergy
|
||||
"13.012": SensorStateClass.TOTAL, # DPTReactiveEnergy
|
||||
"14.007": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngleDeg
|
||||
"14.037": SensorStateClass.TOTAL, # DPTHeatQuantity
|
||||
"14.051": SensorStateClass.TOTAL, # DPTMass
|
||||
"14.055": SensorStateClass.MEASUREMENT_ANGLE, # DPTPhaseAngleDeg
|
||||
"14.031": SensorStateClass.TOTAL_INCREASING, # DPTEnergy
|
||||
"17.001": None, # DPTSceneNumber
|
||||
"29.010": SensorStateClass.TOTAL, # DPTActiveEnergy8Byte
|
||||
"29.011": SensorStateClass.TOTAL, # DPTApparantEnergy8Byte
|
||||
"29.012": SensorStateClass.TOTAL, # DPTReactiveEnergy8Byte
|
||||
}
|
||||
|
||||
|
||||
def _get_sensor_state_class(
|
||||
ha_dpt_class: HaDptClass, dpt_number_str: str
|
||||
) -> SensorStateClass | None:
|
||||
"""Return the SensorStateClass for a given DPT."""
|
||||
if ha_dpt_class != "numeric":
|
||||
return None
|
||||
|
||||
return _sensor_state_class_overrides.get(
|
||||
dpt_number_str,
|
||||
SensorStateClass.MEASUREMENT,
|
||||
)
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.13.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.10.31.195356"
|
||||
"knx-frontend==2025.12.19.150946"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.core.connection_state import XknxConnectionState, XknxConnectionType
|
||||
from xknx.devices import Device as XknxDevice, Sensor as XknxSensor
|
||||
|
||||
@@ -25,20 +25,32 @@ from homeassistant.const import (
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_NAME,
|
||||
CONF_TYPE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, StateType
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import ATTR_SOURCE, KNX_MODULE_KEY
|
||||
from .entity import KnxYamlEntity
|
||||
from .const import ATTR_SOURCE, CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY
|
||||
from .dpt import get_supported_dpts
|
||||
from .entity import (
|
||||
KnxUiEntity,
|
||||
KnxUiEntityPlatformController,
|
||||
KnxYamlEntity,
|
||||
_KnxEntityBase,
|
||||
)
|
||||
from .knx_module import KNXModule
|
||||
from .schema import SensorSchema
|
||||
from .storage.const import CONF_ALWAYS_CALLBACK, CONF_ENTITY, CONF_GA_SENSOR
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
@@ -122,58 +134,41 @@ async def async_setup_entry(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensor(s) for KNX platform."""
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.SENSOR,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiSensor,
|
||||
),
|
||||
)
|
||||
|
||||
entities: list[SensorEntity] = []
|
||||
entities.extend(
|
||||
KNXSystemSensor(knx_module, description)
|
||||
for description in SYSTEM_ENTITY_DESCRIPTIONS
|
||||
)
|
||||
config: list[ConfigType] | None = knx_module.config_yaml.get(Platform.SENSOR)
|
||||
if config:
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.SENSOR):
|
||||
entities.extend(
|
||||
KNXSensor(knx_module, entity_config) for entity_config in config
|
||||
KnxYamlSensor(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.SENSOR):
|
||||
entities.extend(
|
||||
KnxUiSensor(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
|
||||
"""Return a KNX sensor to be used within XKNX."""
|
||||
return XknxSensor(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
|
||||
sync_state=config[SensorSchema.CONF_SYNC_STATE],
|
||||
always_callback=True,
|
||||
value_type=config[CONF_TYPE],
|
||||
)
|
||||
|
||||
|
||||
class KNXSensor(KnxYamlEntity, RestoreSensor):
|
||||
class _KnxSensor(RestoreSensor, _KnxEntityBase):
|
||||
"""Representation of a KNX sensor."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX sensor."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_sensor(knx_module.xknx, config),
|
||||
)
|
||||
if device_class := config.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = device_class
|
||||
else:
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, self._device.ha_device_class()
|
||||
)
|
||||
|
||||
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
|
||||
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
|
||||
self._attr_state_class = config.get(CONF_STATE_CLASS)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
if (
|
||||
@@ -198,6 +193,89 @@ class KNXSensor(KnxYamlEntity, RestoreSensor):
|
||||
super().after_update_callback(device)
|
||||
|
||||
|
||||
class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
|
||||
"""Representation of a KNX sensor configured from YAML."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX sensor."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxSensor(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
always_callback=True,
|
||||
value_type=config[CONF_TYPE],
|
||||
),
|
||||
)
|
||||
if device_class := config.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = device_class
|
||||
else:
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, self._device.ha_device_class()
|
||||
)
|
||||
|
||||
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
|
||||
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
|
||||
self._attr_state_class = config.get(CONF_STATE_CLASS)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
|
||||
class KnxUiSensor(_KnxSensor, KnxUiEntity):
|
||||
"""Representation of a KNX sensor configured from the UI."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX sensor."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR)
|
||||
assert dpt_string is not None # required for sensor
|
||||
dpt_info = get_supported_dpts()[dpt_string]
|
||||
|
||||
self._device = XknxSensor(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
always_callback=True,
|
||||
value_type=dpt_string,
|
||||
)
|
||||
|
||||
if device_class_override := knx_conf.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, device_class_override
|
||||
)
|
||||
else:
|
||||
self._attr_device_class = dpt_info["sensor_device_class"]
|
||||
|
||||
if state_class_override := knx_conf.get(CONF_STATE_CLASS):
|
||||
self._attr_state_class = try_parse_enum(
|
||||
SensorStateClass, state_class_override
|
||||
)
|
||||
else:
|
||||
self._attr_state_class = dpt_info["sensor_state_class"]
|
||||
|
||||
self._attr_native_unit_of_measurement = (
|
||||
knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"]
|
||||
)
|
||||
|
||||
self._attr_force_update = knx_conf.get(CONF_ALWAYS_CALLBACK, default=False)
|
||||
self._attr_extra_state_attributes = {}
|
||||
|
||||
|
||||
class KNXSystemSensor(SensorEntity):
|
||||
"""Representation of a KNX system sensor."""
|
||||
|
||||
|
||||
@@ -71,3 +71,6 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness"
|
||||
CONF_GA_WHITE_SWITCH: Final = "ga_white_switch"
|
||||
CONF_GA_HUE: Final = "ga_hue"
|
||||
CONF_GA_SATURATION: Final = "ga_saturation"
|
||||
|
||||
# Sensor
|
||||
CONF_ALWAYS_CALLBACK: Final = "always_callback"
|
||||
|
||||
@@ -5,11 +5,21 @@ from enum import StrEnum, unique
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import HVACMode
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
|
||||
DEVICE_CLASS_STATE_CLASSES,
|
||||
DEVICE_CLASS_UNITS,
|
||||
STATE_CLASS_UNITS,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
@@ -31,12 +41,15 @@ from ..const import (
|
||||
FanConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from ..dpt import get_supported_dpts
|
||||
from .const import (
|
||||
CONF_ALWAYS_CALLBACK,
|
||||
CONF_COLOR,
|
||||
CONF_COLOR_TEMP_MAX,
|
||||
CONF_COLOR_TEMP_MIN,
|
||||
CONF_DATA,
|
||||
CONF_DEVICE_INFO,
|
||||
CONF_DPT,
|
||||
CONF_ENTITY,
|
||||
CONF_GA_ACTIVE,
|
||||
CONF_GA_ANGLE,
|
||||
@@ -565,6 +578,114 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _validate_sensor_attributes(config: dict) -> dict:
|
||||
"""Validate that state_class is compatible with device_class and unit_of_measurement."""
|
||||
dpt = config[CONF_GA_SENSOR][CONF_DPT]
|
||||
dpt_metadata = get_supported_dpts()[dpt]
|
||||
state_class = config.get(
|
||||
CONF_SENSOR_STATE_CLASS,
|
||||
dpt_metadata["sensor_state_class"],
|
||||
)
|
||||
device_class = config.get(
|
||||
CONF_DEVICE_CLASS,
|
||||
dpt_metadata["sensor_device_class"],
|
||||
)
|
||||
unit_of_measurement = config.get(
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
dpt_metadata["unit"],
|
||||
)
|
||||
if (
|
||||
state_class
|
||||
and device_class
|
||||
and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None
|
||||
and state_class not in state_classes
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"State class '{state_class}' is not valid for device class '{device_class}'. "
|
||||
f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}",
|
||||
path=[CONF_SENSOR_STATE_CLASS],
|
||||
)
|
||||
if (
|
||||
device_class
|
||||
and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None
|
||||
and unit_of_measurement not in d_c_units
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. "
|
||||
f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}",
|
||||
path=(
|
||||
[CONF_DEVICE_CLASS]
|
||||
if CONF_DEVICE_CLASS in config
|
||||
else [CONF_UNIT_OF_MEASUREMENT]
|
||||
),
|
||||
)
|
||||
if (
|
||||
state_class
|
||||
and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None
|
||||
and unit_of_measurement not in s_c_units
|
||||
):
|
||||
raise vol.Invalid(
|
||||
f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. "
|
||||
f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}",
|
||||
path=(
|
||||
[CONF_SENSOR_STATE_CLASS]
|
||||
if CONF_SENSOR_STATE_CLASS in config
|
||||
else [CONF_UNIT_OF_MEASUREMENT]
|
||||
),
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
SENSOR_KNX_SCHEMA = AllSerializeFirst(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_SENSOR): GASelector(
|
||||
write=False, state_required=True, dpt=["numeric", "string"]
|
||||
),
|
||||
"section_advanced_options": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=sorted(
|
||||
{
|
||||
str(unit)
|
||||
for units in DEVICE_CLASS_UNITS.values()
|
||||
for unit in units
|
||||
if unit is not None
|
||||
}
|
||||
),
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="component.knx.selector.sensor_unit_of_measurement",
|
||||
custom_value=True,
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
cls.value
|
||||
for cls in SensorDeviceClass
|
||||
if cls != SensorDeviceClass.ENUM
|
||||
],
|
||||
translation_key="component.knx.selector.sensor_device_class",
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_SENSOR_STATE_CLASS): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=list(SensorStateClass),
|
||||
translation_key="component.knx.selector.sensor_state_class",
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_ALWAYS_CALLBACK): selector.BooleanSelector(),
|
||||
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(
|
||||
allow_false=True
|
||||
),
|
||||
},
|
||||
),
|
||||
_validate_sensor_attributes,
|
||||
)
|
||||
|
||||
KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
|
||||
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
|
||||
@@ -573,6 +694,7 @@ KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.DATETIME: DATETIME_KNX_SCHEMA,
|
||||
Platform.FAN: FAN_KNX_SCHEMA,
|
||||
Platform.LIGHT: LIGHT_KNX_SCHEMA,
|
||||
Platform.SENSOR: SENSOR_KNX_SCHEMA,
|
||||
Platform.SWITCH: SWITCH_KNX_SCHEMA,
|
||||
Platform.TIME: TIME_KNX_SCHEMA,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from ..dpt import HaDptClass, get_supported_dpts
|
||||
from ..validation import ga_validator, maybe_ga_validator, sync_state_validator
|
||||
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
|
||||
from .util import dpt_string_to_dict
|
||||
@@ -162,7 +163,7 @@ class GASelector(KNXSelectorBase):
|
||||
passive: bool = True,
|
||||
write_required: bool = False,
|
||||
state_required: bool = False,
|
||||
dpt: type[Enum] | None = None,
|
||||
dpt: type[Enum] | list[HaDptClass] | None = None,
|
||||
valid_dpt: str | Iterable[str] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the group address selector."""
|
||||
@@ -186,14 +187,17 @@ class GASelector(KNXSelectorBase):
|
||||
"passive": self.passive,
|
||||
}
|
||||
if self.dpt is not None:
|
||||
options["dptSelect"] = [
|
||||
{
|
||||
"value": item.value,
|
||||
"translation_key": item.value.replace(".", "_"),
|
||||
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
|
||||
}
|
||||
for item in self.dpt
|
||||
]
|
||||
if isinstance(self.dpt, list):
|
||||
options["dptClasses"] = self.dpt
|
||||
else:
|
||||
options["dptSelect"] = [
|
||||
{
|
||||
"value": item.value,
|
||||
"translation_key": item.value.replace(".", "_"),
|
||||
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
|
||||
}
|
||||
for item in self.dpt
|
||||
]
|
||||
if self.valid_dpt is not None:
|
||||
options["validDPTs"] = [dpt_string_to_dict(dpt) for dpt in self.valid_dpt]
|
||||
|
||||
@@ -254,7 +258,12 @@ class GASelector(KNXSelectorBase):
|
||||
def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None:
|
||||
"""Add DPT validator to the schema."""
|
||||
if self.dpt is not None:
|
||||
schema[vol.Required(CONF_DPT)] = vol.In({item.value for item in self.dpt})
|
||||
if isinstance(self.dpt, list):
|
||||
schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts())
|
||||
else:
|
||||
schema[vol.Required(CONF_DPT)] = vol.In(
|
||||
{item.value for item in self.dpt}
|
||||
)
|
||||
else:
|
||||
schema[vol.Remove(CONF_DPT)] = object
|
||||
|
||||
|
||||
@@ -154,6 +154,183 @@
|
||||
}
|
||||
},
|
||||
"config_panel": {
|
||||
"dpt": {
|
||||
"options": {
|
||||
"5": "Generic 1-byte unsigned integer",
|
||||
"5_001": "Percent (0 … 100)",
|
||||
"5_003": "Angle",
|
||||
"5_004": "Percent (0 … 255)",
|
||||
"5_005": "Decimal factor",
|
||||
"5_006": "Tariff",
|
||||
"5_010": "Counter (0 … 255)",
|
||||
"6": "Generic 1-byte signed integer",
|
||||
"6_001": "Percent (-128 … 127)",
|
||||
"6_010": "Counter (-128 … 127)",
|
||||
"7": "Generic 2-byte unsigned integer",
|
||||
"7_001": "Counter (0 … 65535)",
|
||||
"7_002": "Time period",
|
||||
"7_003": "Time period (10 ms)",
|
||||
"7_004": "Time period (100 ms)",
|
||||
"7_005": "[%key:component::knx::config_panel::dpt::options::7_002%]",
|
||||
"7_006": "[%key:component::knx::config_panel::dpt::options::7_002%]",
|
||||
"7_007": "[%key:component::knx::config_panel::dpt::options::7_002%]",
|
||||
"7_010": "Interface Object Property",
|
||||
"7_011": "Length",
|
||||
"7_012": "Electrical current",
|
||||
"7_013": "Brightness",
|
||||
"7_600": "Color temperature",
|
||||
"8": "Generic 2-byte signed integer",
|
||||
"8_001": "Counter (-32 768 … 32 767)",
|
||||
"8_002": "Delta time",
|
||||
"8_003": "Delta time (10 ms)",
|
||||
"8_004": "Delta time (100 ms)",
|
||||
"8_005": "[%key:component::knx::config_panel::dpt::options::8_002%]",
|
||||
"8_006": "[%key:component::knx::config_panel::dpt::options::8_002%]",
|
||||
"8_007": "[%key:component::knx::config_panel::dpt::options::8_002%]",
|
||||
"8_010": "Percent (-327.68 … 327.67)",
|
||||
"8_011": "Rotation angle",
|
||||
"8_012": "Length (Altitude)",
|
||||
"9": "Generic 2-byte floating point",
|
||||
"9_001": "Temperature",
|
||||
"9_002": "Temperature difference",
|
||||
"9_003": "Temperature change",
|
||||
"9_004": "Illuminance",
|
||||
"9_005": "Wind speed",
|
||||
"9_006": "Pressure (2-byte)",
|
||||
"9_007": "Humidity",
|
||||
"9_008": "Air quality",
|
||||
"9_009": "Air flow",
|
||||
"9_010": "Time",
|
||||
"9_011": "[%key:component::knx::config_panel::dpt::options::9_010%]",
|
||||
"9_020": "Voltage",
|
||||
"9_021": "Current",
|
||||
"9_022": "Power density",
|
||||
"9_023": "Temperature sensitivity",
|
||||
"9_024": "Power (2-byte)",
|
||||
"9_025": "Volume flow",
|
||||
"9_026": "Rain amount",
|
||||
"9_027": "[%key:component::knx::config_panel::dpt::options::9_001%]",
|
||||
"9_028": "[%key:component::knx::config_panel::dpt::options::9_005%]",
|
||||
"9_029": "Absolute humidity",
|
||||
"9_030": "Concentration",
|
||||
"9_60000": "Enthalpy",
|
||||
"12": "Generic 4-byte unsigned integer",
|
||||
"12_001": "Counter (0 … 4 294 967 295)",
|
||||
"12_100": "Time period (4-byte)",
|
||||
"12_101": "[%key:component::knx::config_panel::dpt::options::12_100%]",
|
||||
"12_102": "[%key:component::knx::config_panel::dpt::options::12_100%]",
|
||||
"12_1200": "Liquid volume",
|
||||
"12_1201": "Volume",
|
||||
"13": "Generic 4-byte signed integer",
|
||||
"13_001": "Counter (-2 147 483 648 … 2 147 483 647)",
|
||||
"13_002": "Flow rate",
|
||||
"13_010": "Active energy",
|
||||
"13_011": "Apparent energy",
|
||||
"13_012": "Reactive energy",
|
||||
"13_013": "[%key:component::knx::config_panel::dpt::options::13_010%]",
|
||||
"13_014": "[%key:component::knx::config_panel::dpt::options::13_011%]",
|
||||
"13_015": "[%key:component::knx::config_panel::dpt::options::13_012%]",
|
||||
"13_016": "[%key:component::knx::config_panel::dpt::options::13_010%]",
|
||||
"13_100": "Operating hours",
|
||||
"13_1200": "Delta liquid volume",
|
||||
"13_1201": "Delta volume",
|
||||
"14": "Generic 4-byte floating point",
|
||||
"14_000": "Acceleration",
|
||||
"14_001": "Angular acceleration",
|
||||
"14_002": "Activation energy",
|
||||
"14_003": "Activity (radioactive)",
|
||||
"14_004": "Amount of substance",
|
||||
"14_005": "Amplitude",
|
||||
"14_006": "Angle",
|
||||
"14_007": "[%key:component::knx::config_panel::dpt::options::14_006%]",
|
||||
"14_008": "Angular momentum",
|
||||
"14_009": "Angular velocity",
|
||||
"14_010": "Area",
|
||||
"14_011": "Capacitance",
|
||||
"14_012": "Charge density (surface)",
|
||||
"14_013": "Charge density (volume)",
|
||||
"14_014": "Compressibility",
|
||||
"14_015": "Conductance",
|
||||
"14_016": "Electrical conductivity",
|
||||
"14_017": "Density",
|
||||
"14_018": "Electric charge",
|
||||
"14_019": "Electric current",
|
||||
"14_020": "Electric current density",
|
||||
"14_021": "Electric dipole moment",
|
||||
"14_022": "Electric displacement",
|
||||
"14_023": "Electric field strength",
|
||||
"14_024": "Electric flux",
|
||||
"14_025": "Electric flux density",
|
||||
"14_026": "Electric polarization",
|
||||
"14_027": "Electric potential",
|
||||
"14_028": "Potential difference",
|
||||
"14_029": "Electromagnetic moment",
|
||||
"14_030": "Electromotive force",
|
||||
"14_031": "Energy",
|
||||
"14_032": "Force",
|
||||
"14_033": "Frequency",
|
||||
"14_034": "Angular frequency",
|
||||
"14_035": "Heat capacity",
|
||||
"14_036": "Heat flow rate",
|
||||
"14_037": "Heat quantity",
|
||||
"14_038": "Impedance",
|
||||
"14_039": "Length",
|
||||
"14_040": "Light quantity",
|
||||
"14_041": "Luminance",
|
||||
"14_042": "Luminous flux",
|
||||
"14_043": "Luminous intensity",
|
||||
"14_044": "Magnetic field strength",
|
||||
"14_045": "Magnetic flux",
|
||||
"14_046": "Magnetic flux density",
|
||||
"14_047": "Magnetic moment",
|
||||
"14_048": "Magnetic polarization",
|
||||
"14_049": "Magnetization",
|
||||
"14_050": "Magnetomotive force",
|
||||
"14_051": "Mass",
|
||||
"14_052": "Mass flux",
|
||||
"14_053": "Momentum",
|
||||
"14_054": "Phase angle",
|
||||
"14_055": "[%key:component::knx::config_panel::dpt::options::14_054%]",
|
||||
"14_056": "Power (4-byte)",
|
||||
"14_057": "Power factor",
|
||||
"14_058": "Pressure (4-byte)",
|
||||
"14_059": "Reactance",
|
||||
"14_060": "Resistance",
|
||||
"14_061": "Resistivity",
|
||||
"14_062": "Self inductance",
|
||||
"14_063": "Solid angle",
|
||||
"14_064": "Sound intensity",
|
||||
"14_065": "Speed",
|
||||
"14_066": "Stress",
|
||||
"14_067": "Surface tension",
|
||||
"14_068": "Common temperature",
|
||||
"14_069": "Absolute temperature",
|
||||
"14_070": "[%key:component::knx::config_panel::dpt::options::9_002%]",
|
||||
"14_071": "Thermal capacity",
|
||||
"14_072": "Thermal conductivity",
|
||||
"14_073": "Thermoelectric power",
|
||||
"14_074": "[%key:component::knx::config_panel::dpt::options::9_010%]",
|
||||
"14_075": "Torque",
|
||||
"14_076": "[%key:component::knx::config_panel::dpt::options::12_1201%]",
|
||||
"14_077": "Volume flux",
|
||||
"14_078": "Weight",
|
||||
"14_079": "Work",
|
||||
"14_080": "Apparent power",
|
||||
"14_1200": "Meter flow",
|
||||
"14_1201": "[%key:component::knx::config_panel::dpt::options::14_1200%]",
|
||||
"16_000": "String (ASCII)",
|
||||
"16_001": "String (Latin-1)",
|
||||
"17_001": "Scene number",
|
||||
"29": "Generic 8-byte signed integer",
|
||||
"29_010": "Active energy (8-byte)",
|
||||
"29_011": "Apparent energy (8-byte)",
|
||||
"29_012": "Reactive energy (8-byte)"
|
||||
},
|
||||
"selector": {
|
||||
"label": "Select a datapoint type",
|
||||
"no_selection": "No DPT selected"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"create": {
|
||||
"_": {
|
||||
@@ -597,6 +774,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"description": "Read-only entity for numeric or string datapoints. Temperature, percent etc.",
|
||||
"knx": {
|
||||
"always_callback": {
|
||||
"description": "Write each update to the state machine, even if the data is the same.",
|
||||
"label": "Force update"
|
||||
},
|
||||
"device_class": {
|
||||
"description": "Override the DPTs default device class.",
|
||||
"label": "Device class"
|
||||
},
|
||||
"ga_sensor": {
|
||||
"description": "Group address representing state.",
|
||||
"label": "State"
|
||||
},
|
||||
"section_advanced_options": {
|
||||
"description": "Override default DPT-based sensor attributes.",
|
||||
"title": "Overrides"
|
||||
},
|
||||
"state_class": {
|
||||
"description": "Override the DPTs default state class.",
|
||||
"label": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]"
|
||||
},
|
||||
"unit_of_measurement": {
|
||||
"description": "Override the DPTs default unit of measurement.",
|
||||
"label": "Unit of measurement"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"description": "The KNX switch platform is used as an interface to switching actuators.",
|
||||
"knx": {
|
||||
@@ -755,6 +961,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"sensor_device_class": {
|
||||
"options": {
|
||||
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
"area": "[%key:component::sensor::entity_component::area::name%]",
|
||||
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
|
||||
"battery": "[%key:component::sensor::entity_component::battery::name%]",
|
||||
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
|
||||
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
|
||||
"current": "[%key:component::sensor::entity_component::current::name%]",
|
||||
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
|
||||
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
|
||||
"date": "[%key:component::sensor::entity_component::date::name%]",
|
||||
"distance": "[%key:component::sensor::entity_component::distance::name%]",
|
||||
"duration": "[%key:component::sensor::entity_component::duration::name%]",
|
||||
"energy": "[%key:component::sensor::entity_component::energy::name%]",
|
||||
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
|
||||
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
|
||||
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
|
||||
"gas": "[%key:component::sensor::entity_component::gas::name%]",
|
||||
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
|
||||
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
|
||||
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
|
||||
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
|
||||
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
|
||||
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
|
||||
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
|
||||
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"ph": "[%key:component::sensor::entity_component::ph::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
|
||||
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
|
||||
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
|
||||
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
|
||||
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
|
||||
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
|
||||
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
|
||||
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
|
||||
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
|
||||
"volume": "[%key:component::sensor::entity_component::volume::name%]",
|
||||
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
|
||||
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
|
||||
"water": "[%key:component::sensor::entity_component::water::name%]",
|
||||
"weight": "[%key:component::sensor::entity_component::weight::name%]",
|
||||
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
|
||||
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
|
||||
}
|
||||
},
|
||||
"sensor_state_class": {
|
||||
"options": {
|
||||
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
|
||||
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
|
||||
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
|
||||
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"event_register": {
|
||||
"description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this action can be removed.",
|
||||
@@ -782,7 +1061,7 @@
|
||||
"name": "[%key:component::knx::services::send::fields::address::name%]"
|
||||
},
|
||||
"attribute": {
|
||||
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”.",
|
||||
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set, the state will be sent. Eg. for a light the state is either “on” or “off” - with attribute you can expose its “brightness”.",
|
||||
"name": "Entity attribute"
|
||||
},
|
||||
"default": {
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.typing import UNDEFINED
|
||||
from homeassistant.util.ulid import ulid_now
|
||||
|
||||
from .const import DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI
|
||||
from .dpt import get_supported_dpts
|
||||
from .storage.config_store import ConfigStoreException
|
||||
from .storage.const import CONF_DATA
|
||||
from .storage.entity_store_schema import (
|
||||
@@ -191,6 +192,7 @@ def ws_get_base_data(
|
||||
msg["id"],
|
||||
{
|
||||
"connection_info": connection_info,
|
||||
"dpt_metadata": get_supported_dpts(),
|
||||
"project_info": _project_info,
|
||||
"supported_platforms": sorted(SUPPORTED_PLATFORMS_UI),
|
||||
},
|
||||
|
||||
@@ -22,5 +22,19 @@
|
||||
"unlock": {
|
||||
"service": "mdi:lock-open-variant"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"jammed": {
|
||||
"trigger": "mdi:lock-alert"
|
||||
},
|
||||
"locked": {
|
||||
"trigger": "mdi:lock"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:lock-open-variant"
|
||||
},
|
||||
"unlocked": {
|
||||
"trigger": "mdi:lock-open-variant"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted locks to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"lock": "Lock {entity_name}",
|
||||
@@ -50,6 +54,15 @@
|
||||
"message": "The code for {entity_id} doesn't match pattern {code_format}."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"lock": {
|
||||
"description": "Locks a lock.",
|
||||
@@ -82,5 +95,47 @@
|
||||
"name": "Unlock"
|
||||
}
|
||||
},
|
||||
"title": "Lock"
|
||||
"title": "Lock",
|
||||
"triggers": {
|
||||
"jammed": {
|
||||
"description": "Triggers after one or more locks jam.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock jammed"
|
||||
},
|
||||
"locked": {
|
||||
"description": "Triggers after one or more locks lock.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock locked"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more locks open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock opened"
|
||||
},
|
||||
"unlocked": {
|
||||
"description": "Triggers after one or more locks unlock.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::lock::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::lock::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Lock unlocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
homeassistant/components/lock/trigger.py
Normal file
18
homeassistant/components/lock/trigger.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Provides triggers for locks."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
|
||||
from .const import DOMAIN, LockState
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"jammed": make_entity_target_state_trigger(DOMAIN, LockState.JAMMED),
|
||||
"locked": make_entity_target_state_trigger(DOMAIN, LockState.LOCKED),
|
||||
"opened": make_entity_target_state_trigger(DOMAIN, LockState.OPEN),
|
||||
"unlocked": make_entity_target_state_trigger(DOMAIN, LockState.UNLOCKED),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for locks."""
|
||||
return TRIGGERS
|
||||
20
homeassistant/components/lock/triggers.yaml
Normal file
20
homeassistant/components/lock/triggers.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: lock
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
jammed: *trigger_common
|
||||
locked: *trigger_common
|
||||
opened: *trigger_common
|
||||
unlocked: *trigger_common
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["renault_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["renault-api==0.5.1"]
|
||||
"requirements": ["renault-api==0.5.2"]
|
||||
}
|
||||
|
||||
@@ -14,5 +14,13 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:bullhorn"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"trigger": "mdi:bullhorn-outline"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:bullhorn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted sirens to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::siren::title%]",
|
||||
@@ -13,6 +17,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"toggle": {
|
||||
"description": "Toggles the siren on/off.",
|
||||
@@ -41,5 +54,27 @@
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
"title": "Siren"
|
||||
"title": "Siren",
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"description": "Triggers after one or more sirens turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::siren::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::siren::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Siren turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers after one or more sirens turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::siren::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::siren::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Siren turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
homeassistant/components/siren/trigger.py
Normal file
17
homeassistant/components/siren/trigger.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Provides triggers for sirens."""
|
||||
|
||||
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 sirens."""
|
||||
return TRIGGERS
|
||||
18
homeassistant/components/siren/triggers.yaml
Normal file
18
homeassistant/components/siren/triggers.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: siren
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
translation_key: trigger_behavior
|
||||
|
||||
turned_off: *trigger_common
|
||||
turned_on: *trigger_common
|
||||
@@ -31,6 +31,7 @@ from homeassistant.const import (
|
||||
CONF_VALUE_TEMPLATE,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
@@ -132,6 +133,15 @@ from .vacuum import (
|
||||
SERVICE_STOP,
|
||||
async_create_preview_vacuum,
|
||||
)
|
||||
from .weather import (
|
||||
CONF_CONDITION,
|
||||
CONF_FORECAST_DAILY,
|
||||
CONF_FORECAST_HOURLY,
|
||||
CONF_HUMIDITY,
|
||||
CONF_TEMPERATURE as CONF_WEATHER_TEMPERATURE,
|
||||
CONF_TEMPERATURE_UNIT,
|
||||
async_create_preview_weather,
|
||||
)
|
||||
|
||||
_SCHEMA_STATE: dict[vol.Marker, Any] = {
|
||||
vol.Required(CONF_STATE): selector.TemplateSelector(),
|
||||
@@ -394,6 +404,22 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
|
||||
vol.Optional(SERVICE_LOCATE): selector.ActionSelector(),
|
||||
}
|
||||
|
||||
if domain == Platform.WEATHER:
|
||||
schema |= {
|
||||
vol.Required(CONF_CONDITION): selector.TemplateSelector(),
|
||||
vol.Required(CONF_HUMIDITY): selector.TemplateSelector(),
|
||||
vol.Required(CONF_WEATHER_TEMPERATURE): selector.TemplateSelector(),
|
||||
vol.Optional(CONF_TEMPERATURE_UNIT): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[cls.value for cls in UnitOfTemperature],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
sort=True,
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_FORECAST_DAILY): selector.TemplateSelector(),
|
||||
vol.Optional(CONF_FORECAST_HOURLY): selector.TemplateSelector(),
|
||||
}
|
||||
|
||||
schema |= {
|
||||
vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
|
||||
vol.Optional(CONF_ADVANCED_OPTIONS): section(
|
||||
@@ -414,6 +440,15 @@ options_schema = partial(generate_schema, flow_type="options")
|
||||
config_schema = partial(generate_schema, flow_type="config")
|
||||
|
||||
|
||||
async def _get_forecast_description_place_holders(
|
||||
handler: SchemaCommonFlowHandler,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
"daily_link": "https://www.home-assistant.io/integrations/template/#daily-weather-forecast",
|
||||
"hourly_link": "https://www.home-assistant.io/integrations/template/#hourly-weather-forecast",
|
||||
}
|
||||
|
||||
|
||||
async def choose_options_step(options: dict[str, Any]) -> str:
|
||||
"""Return next step_id for options flow according to template_type."""
|
||||
return cast(str, options["template_type"])
|
||||
@@ -511,6 +546,7 @@ TEMPLATE_TYPES = [
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
Platform.VACUUM,
|
||||
Platform.WEATHER,
|
||||
]
|
||||
|
||||
CONFIG_FLOW = {
|
||||
@@ -589,6 +625,12 @@ CONFIG_FLOW = {
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.VACUUM),
|
||||
),
|
||||
Platform.WEATHER: SchemaFlowFormStep(
|
||||
config_schema(Platform.WEATHER),
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.WEATHER),
|
||||
description_placeholders=_get_forecast_description_place_holders,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -668,6 +710,12 @@ OPTIONS_FLOW = {
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.VACUUM),
|
||||
),
|
||||
Platform.WEATHER: SchemaFlowFormStep(
|
||||
options_schema(Platform.WEATHER),
|
||||
preview="template",
|
||||
validate_user_input=validate_user_input(Platform.WEATHER),
|
||||
description_placeholders=_get_forecast_description_place_holders,
|
||||
),
|
||||
}
|
||||
|
||||
CREATE_PREVIEW_ENTITY: dict[
|
||||
@@ -687,6 +735,7 @@ CREATE_PREVIEW_ENTITY: dict[
|
||||
Platform.SWITCH: async_create_preview_switch,
|
||||
Platform.UPDATE: async_create_preview_update,
|
||||
Platform.VACUUM: async_create_preview_vacuum,
|
||||
Platform.WEATHER: async_create_preview_weather,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
"sensor": "[%key:component::sensor::title%]",
|
||||
"switch": "[%key:component::switch::title%]",
|
||||
"update": "[%key:component::update::title%]",
|
||||
"vacuum": "[%key:component::vacuum::title%]"
|
||||
"vacuum": "[%key:component::vacuum::title%]",
|
||||
"weather": "[%key:component::weather::title%]"
|
||||
},
|
||||
"title": "Template helper"
|
||||
},
|
||||
@@ -507,6 +508,36 @@
|
||||
}
|
||||
},
|
||||
"title": "Template vacuum"
|
||||
},
|
||||
"weather": {
|
||||
"data": {
|
||||
"condition": "Condition",
|
||||
"device_id": "[%key:common::config_flow::data::device%]",
|
||||
"forecast_daily": "Forecast daily",
|
||||
"forecast_hourly": "Forecast hourly",
|
||||
"humidity": "Humidity",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"temperature": "Temperature",
|
||||
"temperature_unit": "Temperature unit"
|
||||
},
|
||||
"data_description": {
|
||||
"condition": "Defines a template to get the current weather condition",
|
||||
"device_id": "[%key:component::template::common::device_id_description%]",
|
||||
"forecast_daily": "Defines a template to get the [daily forecast data]({daily_link})",
|
||||
"forecast_hourly": "Defines a template to get the [hourly forecast data]({hourly_link})",
|
||||
"humidity": "Defines a template to get the current humidity",
|
||||
"temperature": "Defines a template to get the current temperature",
|
||||
"temperature_unit": "The temperature unit"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_options": {
|
||||
"data": {
|
||||
"availability": "[%key:component::template::common::availability%]"
|
||||
},
|
||||
"name": "[%key:component::template::common::advanced_options%]"
|
||||
}
|
||||
},
|
||||
"title": "Template weather"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -995,6 +1026,36 @@
|
||||
}
|
||||
},
|
||||
"title": "[%key:component::template::config::step::vacuum::title%]"
|
||||
},
|
||||
"weather": {
|
||||
"data": {
|
||||
"condition": "[%key:component::template::config::step::weather::data::condition%]",
|
||||
"device_id": "[%key:common::config_flow::data::device%]",
|
||||
"forecast_daily": "[%key:component::template::config::step::weather::data::forecast_daily%]",
|
||||
"forecast_hourly": "[%key:component::template::config::step::weather::data::forecast_hourly%]",
|
||||
"humidity": "[%key:component::template::config::step::weather::data::humidity%]",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"temperature": "[%key:component::template::config::step::weather::data::temperature%]",
|
||||
"temperature_unit": "[%key:component::template::config::step::weather::data::temperature_unit%]"
|
||||
},
|
||||
"data_description": {
|
||||
"condition": "[%key:component::template::config::step::weather::data_description::condition%]",
|
||||
"device_id": "[%key:component::template::common::device_id_description%]",
|
||||
"forecast_daily": "[%key:component::template::config::step::weather::data_description::forecast_daily%]",
|
||||
"forecast_hourly": "[%key:component::template::config::step::weather::data_description::forecast_hourly%]",
|
||||
"humidity": "[%key:component::template::config::step::weather::data_description::humidity%]",
|
||||
"temperature": "[%key:component::template::config::step::weather::data_description::temperature%]",
|
||||
"temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_options": {
|
||||
"data": {
|
||||
"availability": "[%key:component::template::common::availability%]"
|
||||
},
|
||||
"name": "[%key:component::template::common::advanced_options%]"
|
||||
}
|
||||
},
|
||||
"title": "[%key:component::template::config::step::weather::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ from .entity import (
|
||||
T,
|
||||
async_all_device_entities,
|
||||
)
|
||||
from .utils import async_ufp_instance_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -159,6 +160,7 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity):
|
||||
|
||||
entity_description: ProtectButtonEntityDescription
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
if self.entity_description.ufp_press is not None:
|
||||
|
||||
@@ -29,7 +29,7 @@ from .const import (
|
||||
)
|
||||
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
|
||||
from .entity import ProtectDeviceEntity
|
||||
from .utils import get_camera_base_name
|
||||
from .utils import async_ufp_instance_command, get_camera_base_name
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -260,10 +260,12 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
|
||||
"""Return the Stream Source."""
|
||||
return self._stream_source
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_enable_motion_detection(self) -> None:
|
||||
"""Call the job and enable motion detection."""
|
||||
await self.device.set_motion_detection(True)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_disable_motion_detection(self) -> None:
|
||||
"""Call the job and disable motion detection."""
|
||||
await self.device.set_motion_detection(False)
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .data import ProtectDeviceType, UFPConfigEntry
|
||||
from .entity import ProtectDeviceEntity
|
||||
from .utils import async_ufp_instance_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -71,6 +72,7 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
|
||||
updated_device.light_device_settings.led_level
|
||||
)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
@@ -100,6 +102,7 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
|
||||
),
|
||||
)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
_LOGGER.debug("Turning off light")
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .data import ProtectDeviceType, UFPConfigEntry
|
||||
from .entity import ProtectDeviceEntity
|
||||
from .utils import async_ufp_instance_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -85,12 +86,14 @@ class ProtectLock(ProtectDeviceEntity, LockEntity):
|
||||
elif lock_status != LockStatusType.OPEN:
|
||||
self._attr_available = False
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the lock."""
|
||||
_LOGGER.debug("Unlocking %s", self.device.display_name)
|
||||
return await self.device.open_lock()
|
||||
await self.device.open_lock()
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the lock."""
|
||||
_LOGGER.debug("Locking %s", self.device.display_name)
|
||||
return await self.device.close_lock()
|
||||
await self.device.close_lock()
|
||||
|
||||
@@ -28,6 +28,7 @@ from .entity import (
|
||||
T,
|
||||
async_all_device_entities,
|
||||
)
|
||||
from .utils import async_ufp_instance_command
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -297,6 +298,7 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
|
||||
super()._async_update_device_from_protect(device)
|
||||
self._attr_native_value = self.entity_description.get_ufp_value(self.device)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.entity_description.ufp_set(self.device, value)
|
||||
|
||||
@@ -41,7 +41,7 @@ from .entity import (
|
||||
T,
|
||||
async_all_device_entities,
|
||||
)
|
||||
from .utils import async_get_light_motion_current
|
||||
from .utils import async_get_light_motion_current, async_ufp_instance_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_KEY_LIGHT_MOTION = "light_motion"
|
||||
@@ -397,6 +397,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
|
||||
self._hass_to_unifi_options = {item["name"]: item["id"] for item in options}
|
||||
self._unifi_to_hass_options = {item["id"]: item["name"] for item in options}
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the Select Entity Option."""
|
||||
|
||||
|
||||
@@ -616,12 +616,18 @@
|
||||
"api_key_required": {
|
||||
"message": "API key is required. Please reauthenticate this integration to provide an API key."
|
||||
},
|
||||
"command_error": {
|
||||
"message": "Error communicating with UniFi Protect while sending command: {error}"
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "No device found for device id: {device_id}"
|
||||
},
|
||||
"no_users_found": {
|
||||
"message": "No users found, please check Protect permissions"
|
||||
},
|
||||
"not_authorized": {
|
||||
"message": "Not authorized to perform this action on the UniFi Protect controller"
|
||||
},
|
||||
"only_music_supported": {
|
||||
"message": "Only music media type is supported"
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ from .entity import (
|
||||
T,
|
||||
async_all_device_entities,
|
||||
)
|
||||
from .utils import async_ufp_instance_command
|
||||
|
||||
ATTR_PREV_MIC = "prev_mic_level"
|
||||
ATTR_PREV_RECORD = "prev_record_mode"
|
||||
@@ -438,10 +439,12 @@ class ProtectBaseSwitch(ProtectIsOnEntity):
|
||||
|
||||
entity_description: ProtectSwitchEntityDescription
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
await self.entity_description.ufp_set(self.device, True)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
await self.entity_description.ufp_set(self.device, False)
|
||||
@@ -500,12 +503,14 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch):
|
||||
if self.entity_id:
|
||||
self._update_previous_attr()
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
self._previous_mic_level = self.device.mic_volume
|
||||
self._previous_record_mode = self.device.recording_settings.mode
|
||||
await self.device.set_privacy(True, 0, RecordingMode.NEVER)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
extra_state = self.extra_state_attributes or {}
|
||||
|
||||
@@ -26,6 +26,7 @@ from .entity import (
|
||||
T,
|
||||
async_all_device_entities,
|
||||
)
|
||||
from .utils import async_ufp_instance_command
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -100,6 +101,7 @@ class ProtectDeviceText(ProtectDeviceEntity, TextEntity):
|
||||
super()._async_update_device_from_protect(device)
|
||||
self._attr_native_value = self.entity_description.get_ufp_value(self.device)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
"""Change the value."""
|
||||
await self.entity_description.ufp_set(self.device, value)
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator, Iterable
|
||||
from collections.abc import Callable, Coroutine, Generator, Iterable
|
||||
import contextlib
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from uiprotect import ProtectApiClient
|
||||
@@ -18,6 +19,7 @@ from uiprotect.data import (
|
||||
LightModeType,
|
||||
ProtectAdoptableDeviceModel,
|
||||
)
|
||||
from uiprotect.exceptions import ClientError, NotAuthorized
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -27,6 +29,7 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
|
||||
@@ -34,11 +37,13 @@ from .const import (
|
||||
CONF_ALL_UPDATES,
|
||||
CONF_OVERRIDE_CHOST,
|
||||
DEVICES_FOR_SUBSCRIBE,
|
||||
DOMAIN,
|
||||
ModelType,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .data import UFPConfigEntry
|
||||
from .entity import BaseProtectEntity
|
||||
|
||||
|
||||
@callback
|
||||
@@ -138,3 +143,31 @@ def get_camera_base_name(channel: CameraChannel) -> str:
|
||||
camera_name = f"{channel.name} resolution channel"
|
||||
|
||||
return camera_name
|
||||
|
||||
|
||||
def async_ufp_instance_command[_EntityT: "BaseProtectEntity", **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate UniFi Protect entity instance commands to handle exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches Protect errors,
|
||||
and re-raises them as HomeAssistantError with translations.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except NotAuthorized as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_authorized",
|
||||
) from err
|
||||
except ClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
return handler
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/yale",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["socketio", "engineio", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["yalexs-ble==3.2.2"]
|
||||
"requirements": ["yalexs-ble==3.2.4"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
@@ -16,7 +17,9 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_ABOVE,
|
||||
CONF_ALIAS,
|
||||
CONF_BELOW,
|
||||
CONF_ENABLED,
|
||||
CONF_ID,
|
||||
CONF_OPTIONS,
|
||||
@@ -504,6 +507,259 @@ class EntityTargetStateAttributeTriggerBase(EntityTriggerBase):
|
||||
return state.attributes.get(self._attribute) == self._attribute_to_state
|
||||
|
||||
|
||||
def _validate_range[_T: dict[str, Any]](
|
||||
lower_limit: str, upper_limit: str
|
||||
) -> Callable[[_T], _T]:
|
||||
"""Generate range validator."""
|
||||
|
||||
def _validate_range(value: _T) -> _T:
|
||||
above = value.get(lower_limit)
|
||||
below = value.get(upper_limit)
|
||||
|
||||
if above is None or below is None:
|
||||
return value
|
||||
|
||||
if isinstance(above, str) or isinstance(below, str):
|
||||
return value
|
||||
|
||||
if above > below:
|
||||
raise vol.Invalid(
|
||||
(
|
||||
f"A value can never be above {above} and below {below} at the same"
|
||||
" time. You probably want two different triggers."
|
||||
),
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
return _validate_range
|
||||
|
||||
|
||||
_NUMBER_OR_ENTITY_CHOOSE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("chosen_selector"): vol.In(["number", "entity"]),
|
||||
vol.Optional("entity"): cv.entity_id,
|
||||
vol.Optional("number"): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_number_or_entity(value: dict | float | str) -> float | str:
|
||||
"""Validate number or entity selector result."""
|
||||
if isinstance(value, dict):
|
||||
_NUMBER_OR_ENTITY_CHOOSE_SCHEMA(value)
|
||||
return value[value["chosen_selector"]] # type: ignore[no-any-return]
|
||||
return value
|
||||
|
||||
|
||||
_number_or_entity = vol.All(
|
||||
_validate_number_or_entity, vol.Any(vol.Coerce(float), cv.entity_id)
|
||||
)
|
||||
|
||||
NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): vol.All(
|
||||
{
|
||||
vol.Optional(CONF_ABOVE): _number_or_entity,
|
||||
vol.Optional(CONF_BELOW): _number_or_entity,
|
||||
},
|
||||
_validate_range(CONF_ABOVE, CONF_BELOW),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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 EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for numerical state attribute changes."""
|
||||
|
||||
_attribute: str
|
||||
_schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA
|
||||
|
||||
_above: None | float | str
|
||||
_below: None | float | str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._above = self._options.get(CONF_ABOVE)
|
||||
self._below = self._options.get(CONF_BELOW)
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and the state has changed."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return from_state.attributes.get(self._attribute) != to_state.attributes.get(
|
||||
self._attribute
|
||||
)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state attribute matches the expected one."""
|
||||
# Handle missing or None attribute case first to avoid expensive exceptions
|
||||
if (_attribute_value := state.attributes.get(self._attribute)) is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_value = float(_attribute_value)
|
||||
except (TypeError, ValueError):
|
||||
# Attribute is not a valid number, don't trigger
|
||||
return False
|
||||
|
||||
if self._above is not None:
|
||||
if (above := _get_numerical_value(self._hass, self._above)) is None:
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
if current_value <= above:
|
||||
# The number is not above the limit, don't trigger
|
||||
return False
|
||||
|
||||
if self._below is not None:
|
||||
if (below := _get_numerical_value(self._hass, self._below)) is None:
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
if current_value >= below:
|
||||
# The number is not below the limit, don't trigger
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
CONF_LOWER_LIMIT = "lower_limit"
|
||||
CONF_UPPER_LIMIT = "upper_limit"
|
||||
CONF_THRESHOLD_TYPE = "threshold_type"
|
||||
|
||||
|
||||
class ThresholdType(StrEnum):
|
||||
"""Numerical threshold types."""
|
||||
|
||||
ABOVE = "above"
|
||||
BELOW = "below"
|
||||
BETWEEN = "between"
|
||||
OUTSIDE = "outside"
|
||||
|
||||
|
||||
def _validate_limits_for_threshold_type(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate that the correct limits are provided for the selected threshold type."""
|
||||
threshold_type = value.get(CONF_THRESHOLD_TYPE)
|
||||
|
||||
if threshold_type == ThresholdType.ABOVE:
|
||||
if CONF_LOWER_LIMIT not in value:
|
||||
raise vol.Invalid("lower_limit is required for threshold_type 'above'")
|
||||
elif threshold_type == ThresholdType.BELOW:
|
||||
if CONF_UPPER_LIMIT not in value:
|
||||
raise vol.Invalid("upper_limit is required for threshold_type 'below'")
|
||||
elif threshold_type in (ThresholdType.BETWEEN, ThresholdType.OUTSIDE):
|
||||
if CONF_LOWER_LIMIT not in value or CONF_UPPER_LIMIT not in value:
|
||||
raise vol.Invalid(
|
||||
"Both lower_limit and upper_limit are required for"
|
||||
f" threshold_type '{threshold_type}'"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): vol.All(
|
||||
{
|
||||
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
||||
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
|
||||
),
|
||||
vol.Optional(CONF_LOWER_LIMIT): _number_or_entity,
|
||||
vol.Optional(CONF_UPPER_LIMIT): _number_or_entity,
|
||||
vol.Required(CONF_THRESHOLD_TYPE): ThresholdType,
|
||||
},
|
||||
_validate_range(CONF_LOWER_LIMIT, CONF_UPPER_LIMIT),
|
||||
_validate_limits_for_threshold_type,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for numerical state attribute changes.
|
||||
|
||||
This trigger only fires when the observed attribute changes from not within to within
|
||||
the defined threshold.
|
||||
"""
|
||||
|
||||
_attribute: str
|
||||
_schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA
|
||||
|
||||
_lower_limit: float | str | None = None
|
||||
_upper_limit: float | str | None = None
|
||||
_threshold_type: ThresholdType
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._lower_limit = self._options.get(CONF_LOWER_LIMIT)
|
||||
self._upper_limit = self._options.get(CONF_UPPER_LIMIT)
|
||||
self._threshold_type = self._options[CONF_THRESHOLD_TYPE]
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state is valid and the state has changed."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return not self.is_valid_state(from_state)
|
||||
|
||||
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:
|
||||
# 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:
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
|
||||
# Handle missing or None attribute case first to avoid expensive exceptions
|
||||
if (_attribute_value := state.attributes.get(self._attribute)) is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_value = float(_attribute_value)
|
||||
except (TypeError, ValueError):
|
||||
# Attribute is not a valid number, don't trigger
|
||||
return False
|
||||
|
||||
# Note: We do not need to check for lower_limit/upper_limit being None here
|
||||
# because of the validation done in the schema.
|
||||
if self._threshold_type == ThresholdType.ABOVE:
|
||||
return current_value > lower_limit # type: ignore[operator]
|
||||
if self._threshold_type == ThresholdType.BELOW:
|
||||
return current_value < upper_limit # type: ignore[operator]
|
||||
|
||||
# Mode is BETWEEN or OUTSIDE
|
||||
between = lower_limit < current_value < upper_limit # type: ignore[operator]
|
||||
if self._threshold_type == ThresholdType.BETWEEN:
|
||||
return between
|
||||
return not between
|
||||
|
||||
|
||||
def make_entity_target_state_trigger(
|
||||
domain: str, to_states: str | set[str]
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
@@ -552,6 +808,34 @@ def make_entity_origin_state_trigger(
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_numerical_state_attribute_changed_trigger(
|
||||
domain: str, attribute: str
|
||||
) -> type[EntityNumericalStateAttributeChangedTriggerBase]:
|
||||
"""Create a trigger for numerical state attribute change."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for numerical state attribute changes."""
|
||||
|
||||
_domain = domain
|
||||
_attribute = attribute
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
domain: str, attribute: str
|
||||
) -> type[EntityNumericalStateAttributeCrossedThresholdTriggerBase]:
|
||||
"""Create a trigger for numerical state attribute change."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateAttributeCrossedThresholdTriggerBase):
|
||||
"""Trigger for numerical state attribute changes."""
|
||||
|
||||
_domain = domain
|
||||
_attribute = attribute
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_target_state_attribute_trigger(
|
||||
domain: str, attribute: str, to_state: str
|
||||
) -> type[EntityTargetStateAttributeTriggerBase]:
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==5.8.0
|
||||
hass-nabucasa==1.7.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20251203.2
|
||||
home-assistant-frontend==20251203.3
|
||||
home-assistant-intents==2025.12.2
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
8
requirements_all.txt
generated
8
requirements_all.txt
generated
@@ -1210,7 +1210,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251203.2
|
||||
home-assistant-frontend==20251203.3
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.12.2
|
||||
@@ -1346,7 +1346,7 @@ kiwiki-client==0.1.1
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.10.31.195356
|
||||
knx-frontend==2025.12.19.150946
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -2726,7 +2726,7 @@ refoss-ha==1.2.5
|
||||
regenmaschine==2024.03.0
|
||||
|
||||
# homeassistant.components.renault
|
||||
renault-api==0.5.1
|
||||
renault-api==0.5.2
|
||||
|
||||
# homeassistant.components.renson
|
||||
renson-endura-delta==1.7.2
|
||||
@@ -3224,7 +3224,7 @@ yalesmartalarmclient==0.4.3
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
# homeassistant.components.yalexs_ble
|
||||
yalexs-ble==3.2.2
|
||||
yalexs-ble==3.2.4
|
||||
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
|
||||
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@@ -1068,7 +1068,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251203.2
|
||||
home-assistant-frontend==20251203.3
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.12.2
|
||||
@@ -1180,7 +1180,7 @@ kegtron-ble==1.0.2
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.10.31.195356
|
||||
knx-frontend==2025.12.19.150946
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -2286,7 +2286,7 @@ refoss-ha==1.2.5
|
||||
regenmaschine==2024.03.0
|
||||
|
||||
# homeassistant.components.renault
|
||||
renault-api==0.5.1
|
||||
renault-api==0.5.2
|
||||
|
||||
# homeassistant.components.renson
|
||||
renson-endura-delta==1.7.2
|
||||
@@ -2691,7 +2691,7 @@ yalesmartalarmclient==0.4.3
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
# homeassistant.components.yalexs_ble
|
||||
yalexs-ble==3.2.2
|
||||
yalexs-ble==3.2.4
|
||||
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
|
||||
@@ -171,6 +171,7 @@ def parametrize_trigger_states(
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
additional_attributes: dict | None = None,
|
||||
trigger_from_none: bool = True,
|
||||
retrigger_on_target_state: bool = False,
|
||||
) -> list[tuple[str, list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts.
|
||||
|
||||
@@ -180,6 +181,9 @@ def parametrize_trigger_states(
|
||||
Set `trigger_from_none` to False if the trigger is not expected to fire
|
||||
when the initial state is None.
|
||||
|
||||
Set `retrigger_on_target_state` to True if the trigger is expected to fire
|
||||
when the state changes to another target state.
|
||||
|
||||
Returns a list of tuples with (trigger, list of states),
|
||||
where states is a list of StateDescription dicts.
|
||||
"""
|
||||
@@ -214,7 +218,7 @@ def parametrize_trigger_states(
|
||||
"count": count,
|
||||
}
|
||||
|
||||
return [
|
||||
tests = [
|
||||
# Initial state None
|
||||
(
|
||||
trigger,
|
||||
@@ -260,6 +264,9 @@ def parametrize_trigger_states(
|
||||
state_with_attributes(target_state, 0),
|
||||
state_with_attributes(other_state, 0),
|
||||
state_with_attributes(target_state, 1),
|
||||
# Repeat target state to test retriggering
|
||||
state_with_attributes(target_state, 0),
|
||||
state_with_attributes(STATE_UNAVAILABLE, 0),
|
||||
)
|
||||
for target_state in target_states
|
||||
for other_state in other_states
|
||||
@@ -299,6 +306,34 @@ def parametrize_trigger_states(
|
||||
),
|
||||
]
|
||||
|
||||
if len(target_states) > 1:
|
||||
# If more than one target state, test state change between target states
|
||||
tests.append(
|
||||
(
|
||||
trigger,
|
||||
list(
|
||||
itertools.chain.from_iterable(
|
||||
(
|
||||
state_with_attributes(target_states[idx - 1], 0),
|
||||
state_with_attributes(
|
||||
target_state, 1 if retrigger_on_target_state else 0
|
||||
),
|
||||
state_with_attributes(other_state, 0),
|
||||
state_with_attributes(target_states[idx - 1], 1),
|
||||
state_with_attributes(
|
||||
target_state, 1 if retrigger_on_target_state else 0
|
||||
),
|
||||
state_with_attributes(STATE_UNAVAILABLE, 0),
|
||||
)
|
||||
for idx, target_state in enumerate(target_states[1:], start=1)
|
||||
for other_state in other_states
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return tests
|
||||
|
||||
|
||||
async def arm_trigger(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -14,9 +14,23 @@ from homeassistant.components.climate.const import (
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.components.climate.trigger import CONF_HVAC_MODE
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, CONF_OPTIONS, CONF_TARGET
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.trigger import async_validate_trigger_config
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
ThresholdType,
|
||||
async_validate_trigger_config,
|
||||
)
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
@@ -54,6 +68,8 @@ async def target_climates(hass: HomeAssistant) -> list[str]:
|
||||
"trigger_key",
|
||||
[
|
||||
"climate.hvac_mode_changed",
|
||||
"climate.target_temperature_changed",
|
||||
"climate.target_temperature_crossed_threshold",
|
||||
"climate.turned_off",
|
||||
"climate.turned_on",
|
||||
"climate.started_heating",
|
||||
@@ -136,6 +152,7 @@ def parametrize_climate_trigger_states(
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
additional_attributes: dict | None = None,
|
||||
trigger_from_none: bool = True,
|
||||
retrigger_on_target_state: bool = False,
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts."""
|
||||
trigger_options = trigger_options or {}
|
||||
@@ -147,10 +164,128 @@ def parametrize_climate_trigger_states(
|
||||
other_states=other_states,
|
||||
additional_attributes=additional_attributes,
|
||||
trigger_from_none=trigger_from_none,
|
||||
retrigger_on_target_state=retrigger_on_target_state,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def parametrize_xxx_changed_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_changed triggers."""
|
||||
return [
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[(HVACMode.AUTO, {attribute: None})],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_ABOVE: 10},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={CONF_BELOW: 90},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
retrigger_on_target_state=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_xxx_crossed_threshold_trigger_states(
|
||||
trigger: str, attribute: str
|
||||
) -> list[tuple[str, dict[str, Any], list[StateDescription]]]:
|
||||
"""Parametrize states and expected service call counts for xxx_crossed_threshold triggers."""
|
||||
return [
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 60}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 60}),
|
||||
],
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.ABOVE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
],
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger=trigger,
|
||||
trigger_options={
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BELOW,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
target_states=[
|
||||
(HVACMode.AUTO, {attribute: 0}),
|
||||
(HVACMode.AUTO, {attribute: 50}),
|
||||
],
|
||||
other_states=[
|
||||
(HVACMode.AUTO, {attribute: None}),
|
||||
(HVACMode.AUTO, {attribute: 100}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
@@ -230,19 +365,25 @@ async def test_climate_state_trigger_behavior_any(
|
||||
parametrize_target_entities("climate"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_xxx_changed_trigger_states(
|
||||
"climate.target_temperature_changed", ATTR_TEMPERATURE
|
||||
),
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_cooling",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_drying",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_heating",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
@@ -257,6 +398,7 @@ async def test_climate_state_attribute_trigger_behavior_any(
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the climate state trigger fires when any climate state changes to a specific state."""
|
||||
@@ -267,7 +409,7 @@ async def test_climate_state_attribute_trigger_behavior_any(
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {}, trigger_target_config)
|
||||
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
@@ -366,19 +508,22 @@ async def test_climate_state_trigger_behavior_first(
|
||||
parametrize_target_entities("climate"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_cooling",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_drying",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_heating",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
@@ -393,6 +538,7 @@ async def test_climate_state_attribute_trigger_behavior_first(
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[tuple[tuple[str, dict], int]],
|
||||
) -> None:
|
||||
"""Test that the climate state trigger fires when the first climate state changes to a specific state."""
|
||||
@@ -403,7 +549,9 @@ async def test_climate_state_attribute_trigger_behavior_first(
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
|
||||
await arm_trigger(
|
||||
hass, trigger, {"behavior": "first"} | trigger_options, trigger_target_config
|
||||
)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
@@ -500,19 +648,22 @@ async def test_climate_state_trigger_behavior_last(
|
||||
parametrize_target_entities("climate"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_xxx_crossed_threshold_trigger_states(
|
||||
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
|
||||
),
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_cooling",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_drying",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
*parametrize_climate_trigger_states(
|
||||
trigger="climate.started_heating",
|
||||
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
|
||||
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
|
||||
@@ -527,6 +678,7 @@ async def test_climate_state_attribute_trigger_behavior_last(
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[tuple[tuple[str, dict], int]],
|
||||
) -> None:
|
||||
"""Test that the climate state trigger fires when the last climate state changes to a specific state."""
|
||||
@@ -537,7 +689,9 @@ async def test_climate_state_attribute_trigger_behavior_last(
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
|
||||
await arm_trigger(
|
||||
hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config
|
||||
)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
|
||||
228
tests/components/input_boolean/test_trigger.py
Normal file
228
tests/components/input_boolean/test_trigger.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Test input boolean triggers."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.input_boolean import DOMAIN
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture(name="enable_experimental_triggers_conditions")
|
||||
def enable_experimental_triggers_conditions() -> Generator[None]:
|
||||
"""Enable experimental triggers and conditions."""
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_is_preview_feature_enabled",
|
||||
return_value=True,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_input_booleans(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple input_boolean entities associated with different targets."""
|
||||
return (await target_entities(hass, DOMAIN))["included"]
|
||||
|
||||
|
||||
@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 arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
|
||||
assert (
|
||||
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
|
||||
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
|
||||
"feature to be enabled in Home Assistant Labs settings (feature flag: "
|
||||
"'new_triggers_conditions')"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "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: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the input_boolean state trigger fires when any input_boolean state changes to a specific state."""
|
||||
other_entity_ids = set(target_input_booleans) - {entity_id}
|
||||
|
||||
# Set all input_booleans, including the tested one, to the initial state
|
||||
for eid in target_input_booleans:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Check if changing other input_booleans also triggers
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "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: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the input_boolean state trigger fires when the first input_boolean changes to a specific state."""
|
||||
other_entity_ids = set(target_input_booleans) - {entity_id}
|
||||
|
||||
# Set all input_booleans, including the tested one, to the initial state
|
||||
for eid in target_input_booleans:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Triggering other input_booleans should not cause the trigger to fire again
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "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: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the input_boolean state trigger fires when the last input_boolean changes to a specific state."""
|
||||
other_entity_ids = set(target_input_booleans) - {entity_id}
|
||||
|
||||
# Set all input_booleans, including the tested one, to the initial state
|
||||
for eid in target_input_booleans:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
26
tests/components/knx/fixtures/config_store_sensor.json
Normal file
26
tests/components/knx/fixtures/config_store_sensor.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": 2,
|
||||
"minor_version": 2,
|
||||
"key": "knx/config_store.json",
|
||||
"data": {
|
||||
"entities": {
|
||||
"sensor": {
|
||||
"knx_es_01KC2F5CP5S4QCE3FZ49EF7CSJ": {
|
||||
"entity": {
|
||||
"name": "Test",
|
||||
"entity_category": null,
|
||||
"device_info": null
|
||||
},
|
||||
"knx": {
|
||||
"ga_sensor": {
|
||||
"state": "1/1/1",
|
||||
"dpt": "7.600",
|
||||
"passive": []
|
||||
},
|
||||
"sync_state": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1576,6 +1576,338 @@
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_knx_get_schema[sensor]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': list([
|
||||
dict({
|
||||
'name': 'ga_sensor',
|
||||
'options': dict({
|
||||
'dptClasses': list([
|
||||
'numeric',
|
||||
'string',
|
||||
]),
|
||||
'passive': True,
|
||||
'state': dict({
|
||||
'required': True,
|
||||
}),
|
||||
'write': False,
|
||||
}),
|
||||
'required': True,
|
||||
'type': 'knx_group_address',
|
||||
}),
|
||||
dict({
|
||||
'collapsible': True,
|
||||
'name': 'section_advanced_options',
|
||||
'required': False,
|
||||
'type': 'knx_section_flat',
|
||||
}),
|
||||
dict({
|
||||
'name': 'unit_of_measurement',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'selector': dict({
|
||||
'select': dict({
|
||||
'custom_value': True,
|
||||
'mode': 'dropdown',
|
||||
'multiple': False,
|
||||
'options': list([
|
||||
'%',
|
||||
'A',
|
||||
'B',
|
||||
'B/s',
|
||||
'BTU/(h⋅ft²)',
|
||||
'Beaufort',
|
||||
'CCF',
|
||||
'EB',
|
||||
'EiB',
|
||||
'GB',
|
||||
'GB/s',
|
||||
'GHz',
|
||||
'GJ',
|
||||
'GW',
|
||||
'GWh',
|
||||
'Gbit',
|
||||
'Gbit/s',
|
||||
'Gcal',
|
||||
'GiB',
|
||||
'GiB/s',
|
||||
'Hz',
|
||||
'J',
|
||||
'K',
|
||||
'KiB',
|
||||
'KiB/s',
|
||||
'L',
|
||||
'L/h',
|
||||
'L/min',
|
||||
'L/s',
|
||||
'MB',
|
||||
'MB/s',
|
||||
'MCF',
|
||||
'MHz',
|
||||
'MJ',
|
||||
'MV',
|
||||
'MW',
|
||||
'MWh',
|
||||
'Mbit',
|
||||
'Mbit/s',
|
||||
'Mcal',
|
||||
'MiB',
|
||||
'MiB/s',
|
||||
'PB',
|
||||
'Pa',
|
||||
'PiB',
|
||||
'S/cm',
|
||||
'TB',
|
||||
'TW',
|
||||
'TWh',
|
||||
'TiB',
|
||||
'V',
|
||||
'VA',
|
||||
'W',
|
||||
'W/m²',
|
||||
'Wh',
|
||||
'Wh/km',
|
||||
'YB',
|
||||
'YiB',
|
||||
'ZB',
|
||||
'ZiB',
|
||||
'ac',
|
||||
'bar',
|
||||
'bit',
|
||||
'bit/s',
|
||||
'cal',
|
||||
'cbar',
|
||||
'cm',
|
||||
'cm²',
|
||||
'd',
|
||||
'dB',
|
||||
'dBA',
|
||||
'dBm',
|
||||
'fl. oz.',
|
||||
'ft',
|
||||
'ft/s',
|
||||
'ft²',
|
||||
'ft³',
|
||||
'ft³/min',
|
||||
'g',
|
||||
'g/m³',
|
||||
'gal',
|
||||
'gal/d',
|
||||
'gal/h',
|
||||
'gal/min',
|
||||
'h',
|
||||
'hPa',
|
||||
'ha',
|
||||
'in',
|
||||
'in/d',
|
||||
'in/h',
|
||||
'in/s',
|
||||
'inHg',
|
||||
'inH₂O',
|
||||
'in²',
|
||||
'kB',
|
||||
'kB/s',
|
||||
'kHz',
|
||||
'kJ',
|
||||
'kPa',
|
||||
'kV',
|
||||
'kVA',
|
||||
'kW',
|
||||
'kWh',
|
||||
'kWh/100km',
|
||||
'kbit',
|
||||
'kbit/s',
|
||||
'kcal',
|
||||
'kg',
|
||||
'km',
|
||||
'km/h',
|
||||
'km/kWh',
|
||||
'km²',
|
||||
'kn',
|
||||
'kvar',
|
||||
'kvarh',
|
||||
'lb',
|
||||
'lx',
|
||||
'm',
|
||||
'm/min',
|
||||
'm/s',
|
||||
'mA',
|
||||
'mL',
|
||||
'mL/s',
|
||||
'mPa',
|
||||
'mS/cm',
|
||||
'mV',
|
||||
'mVA',
|
||||
'mW',
|
||||
'mWh',
|
||||
'mbar',
|
||||
'mg',
|
||||
'mg/dL',
|
||||
'mg/m³',
|
||||
'mi',
|
||||
'mi/kWh',
|
||||
'min',
|
||||
'mi²',
|
||||
'mm',
|
||||
'mm/d',
|
||||
'mm/h',
|
||||
'mm/s',
|
||||
'mmHg',
|
||||
'mmol/L',
|
||||
'mm²',
|
||||
'mph',
|
||||
'ms',
|
||||
'mvar',
|
||||
'm²',
|
||||
'm³',
|
||||
'm³/h',
|
||||
'm³/min',
|
||||
'm³/s',
|
||||
'nmi',
|
||||
'oz',
|
||||
'ppb',
|
||||
'ppm',
|
||||
'psi',
|
||||
's',
|
||||
'st',
|
||||
'var',
|
||||
'varh',
|
||||
'yd',
|
||||
'yd²',
|
||||
'°',
|
||||
'°C',
|
||||
'°F',
|
||||
'μS/cm',
|
||||
'μV',
|
||||
'μg',
|
||||
'μg/m³',
|
||||
'μs',
|
||||
]),
|
||||
'sort': False,
|
||||
'translation_key': 'component.knx.selector.sensor_unit_of_measurement',
|
||||
}),
|
||||
}),
|
||||
'type': 'ha_selector',
|
||||
}),
|
||||
dict({
|
||||
'name': 'device_class',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'selector': dict({
|
||||
'select': dict({
|
||||
'custom_value': False,
|
||||
'multiple': False,
|
||||
'options': list([
|
||||
'date',
|
||||
'timestamp',
|
||||
'absolute_humidity',
|
||||
'apparent_power',
|
||||
'aqi',
|
||||
'area',
|
||||
'atmospheric_pressure',
|
||||
'battery',
|
||||
'blood_glucose_concentration',
|
||||
'carbon_monoxide',
|
||||
'carbon_dioxide',
|
||||
'conductivity',
|
||||
'current',
|
||||
'data_rate',
|
||||
'data_size',
|
||||
'distance',
|
||||
'duration',
|
||||
'energy',
|
||||
'energy_distance',
|
||||
'energy_storage',
|
||||
'frequency',
|
||||
'gas',
|
||||
'humidity',
|
||||
'illuminance',
|
||||
'irradiance',
|
||||
'moisture',
|
||||
'monetary',
|
||||
'nitrogen_dioxide',
|
||||
'nitrogen_monoxide',
|
||||
'nitrous_oxide',
|
||||
'ozone',
|
||||
'ph',
|
||||
'pm1',
|
||||
'pm10',
|
||||
'pm25',
|
||||
'pm4',
|
||||
'power_factor',
|
||||
'power',
|
||||
'precipitation',
|
||||
'precipitation_intensity',
|
||||
'pressure',
|
||||
'reactive_energy',
|
||||
'reactive_power',
|
||||
'signal_strength',
|
||||
'sound_pressure',
|
||||
'speed',
|
||||
'sulphur_dioxide',
|
||||
'temperature',
|
||||
'temperature_delta',
|
||||
'volatile_organic_compounds',
|
||||
'volatile_organic_compounds_parts',
|
||||
'voltage',
|
||||
'volume',
|
||||
'volume_storage',
|
||||
'volume_flow_rate',
|
||||
'water',
|
||||
'weight',
|
||||
'wind_direction',
|
||||
'wind_speed',
|
||||
]),
|
||||
'sort': True,
|
||||
'translation_key': 'component.knx.selector.sensor_device_class',
|
||||
}),
|
||||
}),
|
||||
'type': 'ha_selector',
|
||||
}),
|
||||
dict({
|
||||
'name': 'state_class',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'selector': dict({
|
||||
'select': dict({
|
||||
'custom_value': False,
|
||||
'mode': 'dropdown',
|
||||
'multiple': False,
|
||||
'options': list([
|
||||
'measurement',
|
||||
'measurement_angle',
|
||||
'total',
|
||||
'total_increasing',
|
||||
]),
|
||||
'sort': False,
|
||||
'translation_key': 'component.knx.selector.sensor_state_class',
|
||||
}),
|
||||
}),
|
||||
'type': 'ha_selector',
|
||||
}),
|
||||
dict({
|
||||
'name': 'always_callback',
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'selector': dict({
|
||||
'boolean': dict({
|
||||
}),
|
||||
}),
|
||||
'type': 'ha_selector',
|
||||
}),
|
||||
dict({
|
||||
'allow_false': True,
|
||||
'default': True,
|
||||
'name': 'sync_state',
|
||||
'required': True,
|
||||
'type': 'knx_sync_state',
|
||||
}),
|
||||
]),
|
||||
'success': True,
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_knx_get_schema[switch]
|
||||
dict({
|
||||
'id': 1,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Test KNX sensor."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.knx.const import (
|
||||
ATTR_SOURCE,
|
||||
@@ -8,9 +11,10 @@ from homeassistant.components.knx.const import (
|
||||
CONF_SYNC_STATE,
|
||||
)
|
||||
from homeassistant.components.knx.schema import SensorSchema
|
||||
from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN
|
||||
from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_UNKNOWN, Platform
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
|
||||
from . import KnxEntityGenerator
|
||||
from .conftest import KNXTestKit
|
||||
|
||||
from tests.common import (
|
||||
@@ -166,3 +170,135 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
await knx.receive_write("1/1/1", (0xFA,))
|
||||
await knx.receive_write("2/2/2", (0xFA,))
|
||||
assert len(events) == 6
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knx_config", "response_payload", "expected_state"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"ga_sensor": {
|
||||
"state": "1/1/1",
|
||||
"passive": [],
|
||||
"dpt": "9.001", # temperature 2 byte float
|
||||
},
|
||||
},
|
||||
(0, 0),
|
||||
{
|
||||
"state": "0.0",
|
||||
"device_class": "temperature",
|
||||
"state_class": "measurement",
|
||||
"unit_of_measurement": "°C",
|
||||
},
|
||||
),
|
||||
(
|
||||
{
|
||||
"ga_sensor": {
|
||||
"state": "1/1/1",
|
||||
"passive": [],
|
||||
"dpt": "12", # generic 4byte uint
|
||||
},
|
||||
"state_class": "total_increasing",
|
||||
"device_class": "energy",
|
||||
"unit_of_measurement": "Mcal",
|
||||
"sync_state": True,
|
||||
},
|
||||
(1, 2, 3, 4),
|
||||
{
|
||||
"state": "16909060",
|
||||
"device_class": "energy",
|
||||
"state_class": "total_increasing",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_sensor_ui_create(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
create_ui_entity: KnxEntityGenerator,
|
||||
knx_config: dict[str, Any],
|
||||
response_payload: tuple[int, ...],
|
||||
expected_state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a sensor."""
|
||||
await knx.setup_integration()
|
||||
await create_ui_entity(
|
||||
platform=Platform.SENSOR,
|
||||
entity_data={"name": "test"},
|
||||
knx_data=knx_config,
|
||||
)
|
||||
# created entity sends read-request to KNX bus
|
||||
await knx.assert_read("1/1/1")
|
||||
await knx.receive_response("1/1/1", response_payload)
|
||||
knx.assert_state("sensor.test", **expected_state)
|
||||
|
||||
|
||||
async def test_sensor_ui_load(knx: KNXTestKit) -> None:
|
||||
"""Test loading a sensor from storage."""
|
||||
await knx.setup_integration(config_store_fixture="config_store_sensor.json")
|
||||
|
||||
await knx.assert_read("1/1/1", response=(0, 0), ignore_order=True)
|
||||
knx.assert_state(
|
||||
"sensor.test",
|
||||
"0",
|
||||
device_class=None, # 7.600 color temperature has no sensor device class
|
||||
state_class="measurement",
|
||||
unit_of_measurement="K",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"knx_config",
|
||||
[
|
||||
(
|
||||
{
|
||||
"ga_sensor": {
|
||||
"state": "1/1/1",
|
||||
"passive": [],
|
||||
"dpt": "9.001", # temperature 2 byte float
|
||||
},
|
||||
"state_class": "totoal_increasing", # invalid for temperature
|
||||
}
|
||||
),
|
||||
(
|
||||
{
|
||||
"ga_sensor": {
|
||||
"state": "1/1/1",
|
||||
"passive": [],
|
||||
"dpt": "12", # generic 4byte uint
|
||||
},
|
||||
"state_class": "total_increasing",
|
||||
"device_class": "energy", # requires unit_of_measurement
|
||||
"sync_state": True,
|
||||
}
|
||||
),
|
||||
(
|
||||
{
|
||||
"ga_sensor": {
|
||||
"state": "1/1/1",
|
||||
"passive": [],
|
||||
"dpt": "9.001", # temperature 2 byte float
|
||||
},
|
||||
"state_class": "measurement_angle", # requires degree unit
|
||||
"sync_state": True,
|
||||
}
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_sensor_ui_create_attribute_validation(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
create_ui_entity: KnxEntityGenerator,
|
||||
knx_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test creating a sensor with invalid unit, state_class or device_class."""
|
||||
await knx.setup_integration()
|
||||
with pytest.raises(AssertionError) as err:
|
||||
await create_ui_entity(
|
||||
platform=Platform.SENSOR,
|
||||
entity_data={"name": "test"},
|
||||
knx_data=knx_config,
|
||||
)
|
||||
assert "success" in err.value.args[0]
|
||||
assert "error_base" in err.value.args[0]
|
||||
assert "path" in err.value.args[0]
|
||||
|
||||
261
tests/components/lock/test_trigger.py
Normal file
261
tests/components/lock/test_trigger.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""Test lock triggers."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.lock import DOMAIN, LockState
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
other_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture(name="enable_experimental_triggers_conditions")
|
||||
def enable_experimental_triggers_conditions() -> Generator[None]:
|
||||
"""Enable experimental triggers and conditions."""
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_is_preview_feature_enabled",
|
||||
return_value=True,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_locks(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple lock entities associated with different targets."""
|
||||
return (await target_entities(hass, DOMAIN))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"lock.jammed",
|
||||
"lock.locked",
|
||||
"lock.opened",
|
||||
"lock.unlocked",
|
||||
],
|
||||
)
|
||||
async def test_lock_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the lock triggers are gated by the labs flag."""
|
||||
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
|
||||
assert (
|
||||
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
|
||||
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
|
||||
"feature to be enabled in Home Assistant Labs settings (feature flag: "
|
||||
"'new_triggers_conditions')"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.jammed",
|
||||
target_states=[LockState.JAMMED],
|
||||
other_states=other_states(LockState.JAMMED),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.locked",
|
||||
target_states=[LockState.LOCKED],
|
||||
other_states=other_states(LockState.LOCKED),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.opened",
|
||||
target_states=[LockState.OPEN],
|
||||
other_states=other_states(LockState.OPEN),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.unlocked",
|
||||
target_states=[LockState.UNLOCKED],
|
||||
other_states=other_states(LockState.UNLOCKED),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_lock_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_locks: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the lock state trigger fires when any lock state changes to a specific state."""
|
||||
other_entity_ids = set(target_locks) - {entity_id}
|
||||
|
||||
# Set all locks, including the tested one, to the initial state
|
||||
for eid in target_locks:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Check if changing other locks also triggers
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.jammed",
|
||||
target_states=[LockState.JAMMED],
|
||||
other_states=other_states(LockState.JAMMED),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.locked",
|
||||
target_states=[LockState.LOCKED],
|
||||
other_states=other_states(LockState.LOCKED),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.opened",
|
||||
target_states=[LockState.OPEN],
|
||||
other_states=other_states(LockState.OPEN),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.unlocked",
|
||||
target_states=[LockState.UNLOCKED],
|
||||
other_states=other_states(LockState.UNLOCKED),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_lock_state_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_locks: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the lock state trigger fires when the first lock changes to a specific state."""
|
||||
other_entity_ids = set(target_locks) - {entity_id}
|
||||
|
||||
# Set all locks, including the tested one, to the initial state
|
||||
for eid in target_locks:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Triggering other locks should not cause the trigger to fire again
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.jammed",
|
||||
target_states=[LockState.JAMMED],
|
||||
other_states=other_states(LockState.JAMMED),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.locked",
|
||||
target_states=[LockState.LOCKED],
|
||||
other_states=other_states(LockState.LOCKED),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.opened",
|
||||
target_states=[LockState.OPEN],
|
||||
other_states=other_states(LockState.OPEN),
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="lock.unlocked",
|
||||
target_states=[LockState.UNLOCKED],
|
||||
other_states=other_states(LockState.UNLOCKED),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_lock_state_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_locks: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the lock state trigger fires when the last lock changes to a specific state."""
|
||||
other_entity_ids = set(target_locks) - {entity_id}
|
||||
|
||||
# Set all locks, including the tested one, to the initial state
|
||||
for eid in target_locks:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
228
tests/components/siren/test_trigger.py
Normal file
228
tests/components/siren/test_trigger.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Test siren triggers."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.siren import DOMAIN
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
StateDescription,
|
||||
arm_trigger,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture(name="enable_experimental_triggers_conditions")
|
||||
def enable_experimental_triggers_conditions() -> Generator[None]:
|
||||
"""Enable experimental triggers and conditions."""
|
||||
with patch(
|
||||
"homeassistant.components.labs.async_is_preview_feature_enabled",
|
||||
return_value=True,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_sirens(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple siren entities associated with different targets."""
|
||||
return (await target_entities(hass, DOMAIN))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"siren.turned_off",
|
||||
"siren.turned_on",
|
||||
],
|
||||
)
|
||||
async def test_siren_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the siren triggers are gated by the labs flag."""
|
||||
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
|
||||
assert (
|
||||
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
|
||||
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
|
||||
"feature to be enabled in Home Assistant Labs settings (feature flag: "
|
||||
"'new_triggers_conditions')"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="siren.turned_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="siren.turned_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_siren_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_sirens: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the siren state trigger fires when any siren state changes to a specific state."""
|
||||
other_entity_ids = set(target_sirens) - {entity_id}
|
||||
|
||||
# Set all sirens, including the tested one, to the initial state
|
||||
for eid in target_sirens:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Check if changing other sirens also triggers
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="siren.turned_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="siren.turned_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_siren_state_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_sirens: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the siren state trigger fires when the first siren changes to a specific state."""
|
||||
other_entity_ids = set(target_sirens) - {entity_id}
|
||||
|
||||
# Set all sirens, including the tested one, to the initial state
|
||||
for eid in target_sirens:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Triggering other sirens should not cause the trigger to fire again
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="siren.turned_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="siren.turned_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_siren_state_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_sirens: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the siren state trigger fires when the last siren changes to a specific state."""
|
||||
other_entity_ids = set(target_sirens) - {entity_id}
|
||||
|
||||
# Set all sirens, including the tested one, to the initial state
|
||||
for eid in target_sirens:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
@@ -53,6 +53,29 @@
|
||||
'last_wind_speed': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup_config_entry
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'assumed_state': True,
|
||||
'attribution': 'Powered by Home Assistant',
|
||||
'friendly_name': 'My template',
|
||||
'humidity': 50,
|
||||
'precipitation_unit': <UnitOfPrecipitationDepth.MILLIMETERS: 'mm'>,
|
||||
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
|
||||
'supported_features': 0,
|
||||
'temperature': 20.0,
|
||||
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
|
||||
'wind_speed_unit': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'weather.my_template',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_trigger_weather_services[config0-1-template-get_forecasts]
|
||||
dict({
|
||||
'weather.test': dict({
|
||||
|
||||
@@ -270,6 +270,16 @@ BINARY_SENSOR_OPTIONS = {
|
||||
{"start": []},
|
||||
{},
|
||||
),
|
||||
(
|
||||
"weather",
|
||||
{"condition": "{{ states('weather.one') }}"},
|
||||
"sunny",
|
||||
{"one": "sunny", "two": "cloudy"},
|
||||
{},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
{},
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00")
|
||||
@@ -463,6 +473,12 @@ async def test_config_flow(
|
||||
{"start": []},
|
||||
{"start": []},
|
||||
),
|
||||
(
|
||||
"weather",
|
||||
{"condition": "{{ states('weather.one') }}"},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_config_flow_device(
|
||||
@@ -752,6 +768,16 @@ async def test_config_flow_device(
|
||||
{"start": []},
|
||||
"state",
|
||||
),
|
||||
(
|
||||
"weather",
|
||||
{"condition": "{{ states('weather.one') }}"},
|
||||
{"condition": "{{ states('weather.two') }}"},
|
||||
["sunny", "cloudy"],
|
||||
{"one": "sunny", "two": "cloudy"},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
"condition",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00")
|
||||
@@ -1601,6 +1627,12 @@ async def test_option_flow_sensor_preview_config_entry_removed(
|
||||
{"start": []},
|
||||
{"start": []},
|
||||
),
|
||||
(
|
||||
"weather",
|
||||
{"condition": "{{ states('weather.one') }}"},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_options_flow_change_device(
|
||||
|
||||
@@ -37,13 +37,15 @@ from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import ConfigurationStyle
|
||||
from .conftest import ConfigurationStyle, async_get_flow_preview_state
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
assert_setup_component,
|
||||
async_mock_restore_state_shutdown_restart,
|
||||
mock_restore_cache_with_extra_data,
|
||||
)
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
ATTR_FORECAST = "forecast"
|
||||
|
||||
@@ -122,6 +124,27 @@ async def setup_weather(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_weather_single_attribute(
|
||||
hass: HomeAssistant,
|
||||
count: int,
|
||||
style: ConfigurationStyle,
|
||||
attribute: str,
|
||||
attribute_template: str,
|
||||
weather_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Do setup of weather integration."""
|
||||
extra = {attribute: attribute_template}
|
||||
if style == ConfigurationStyle.MODERN:
|
||||
await async_setup_modern_format(
|
||||
hass, count, {"name": TEST_OBJECT_ID, **weather_config, **extra}
|
||||
)
|
||||
if style == ConfigurationStyle.TRIGGER:
|
||||
await async_setup_trigger_format(
|
||||
hass, count, {"name": TEST_OBJECT_ID, **weather_config, **extra}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)])
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
@@ -1129,3 +1152,58 @@ async def test_templated_optional_config(
|
||||
state = hass.states.get(TEST_WEATHER)
|
||||
|
||||
assert state.attributes[attribute] == expected
|
||||
|
||||
|
||||
async def test_setup_config_entry(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Tests creating a weather from a config entry."""
|
||||
|
||||
hass.states.async_set(
|
||||
"weather.test_state",
|
||||
"sunny",
|
||||
{},
|
||||
)
|
||||
|
||||
template_config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=template.DOMAIN,
|
||||
options={
|
||||
"name": "My template",
|
||||
"condition": "{{ states('sensor.test_sensor') }}",
|
||||
"humidity": "{{ 50 }}",
|
||||
"temperature": "{{ 20 }}",
|
||||
"template_type": WEATHER_DOMAIN,
|
||||
},
|
||||
title="My template",
|
||||
)
|
||||
template_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(template_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("weather.my_template")
|
||||
assert state is not None
|
||||
assert state == snapshot
|
||||
|
||||
|
||||
async def test_flow_preview(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test the config flow preview."""
|
||||
|
||||
state = await async_get_flow_preview_state(
|
||||
hass,
|
||||
hass_ws_client,
|
||||
WEATHER_DOMAIN,
|
||||
{
|
||||
"name": "My template",
|
||||
"condition": "{{ 'sunny' }}",
|
||||
"humidity": "{{ 50 }}",
|
||||
"temperature": "{{ 20 }}",
|
||||
},
|
||||
)
|
||||
|
||||
assert state["state"] == "sunny"
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from uiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode
|
||||
from uiprotect.exceptions import ClientError, NotAuthorized
|
||||
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
|
||||
from homeassistant.components.unifiprotect.switch import (
|
||||
@@ -18,6 +19,7 @@ from homeassistant.components.unifiprotect.switch import (
|
||||
)
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .utils import (
|
||||
@@ -461,3 +463,47 @@ async def test_switch_camera_privacy_already_on(
|
||||
)
|
||||
|
||||
doorbell.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS)
|
||||
|
||||
|
||||
async def test_switch_turn_on_client_error(
|
||||
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
|
||||
) -> None:
|
||||
"""Test switch turn on with ClientError raises HomeAssistantError."""
|
||||
|
||||
await init_entry(hass, ufp, [light])
|
||||
|
||||
description = LIGHT_SWITCHES[1]
|
||||
|
||||
light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False)
|
||||
light.set_status_light = AsyncMock(side_effect=ClientError("Test error"))
|
||||
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, light, description
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
|
||||
|
||||
async def test_switch_turn_on_not_authorized(
|
||||
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
|
||||
) -> None:
|
||||
"""Test switch turn on with NotAuthorized raises HomeAssistantError."""
|
||||
|
||||
await init_entry(hass, ufp, [light])
|
||||
|
||||
description = LIGHT_SWITCHES[1]
|
||||
|
||||
light.__pydantic_fields__["set_status_light"] = Mock(final=False, frozen=False)
|
||||
light.set_status_light = AsyncMock(side_effect=NotAuthorized("Not authorized"))
|
||||
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, light, description
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
"""The tests for the trigger helper."""
|
||||
|
||||
from contextlib import AbstractContextManager, nullcontext as does_not_raise
|
||||
import io
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
|
||||
|
||||
import pytest
|
||||
from pytest_unordered import unordered
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.sun import DOMAIN as DOMAIN_SUN
|
||||
from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH
|
||||
from homeassistant.components.tag import DOMAIN as DOMAIN_TAG
|
||||
from homeassistant.components.text import DOMAIN as DOMAIN_TEXT
|
||||
from homeassistant.const import (
|
||||
CONF_ABOVE,
|
||||
CONF_BELOW,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TARGET,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
@@ -22,13 +35,19 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, trigger
|
||||
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
|
||||
from homeassistant.helpers.trigger import (
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_THRESHOLD_TYPE,
|
||||
CONF_UPPER_LIMIT,
|
||||
DATA_PLUGGABLE_ACTIONS,
|
||||
PluggableAction,
|
||||
ThresholdType,
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
_async_get_trigger_platform,
|
||||
async_initialize_triggers,
|
||||
async_validate_trigger_config,
|
||||
make_entity_numerical_state_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import Integration, async_get_integration
|
||||
@@ -1131,3 +1150,418 @@ async def test_subscribe_triggers_no_triggers(
|
||||
assert await async_setup_component(hass, "light", {})
|
||||
await hass.async_block_till_done()
|
||||
assert trigger_events == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_options", "expected_result"),
|
||||
[
|
||||
# Test validating climate.target_temperature_changed
|
||||
# Valid configurations
|
||||
(
|
||||
{},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: 10},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: "sensor.test"},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_BELOW: 90},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_BELOW: "sensor.test"},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: 10, CONF_BELOW: 90},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: "sensor.test", CONF_BELOW: 90},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: 10, CONF_BELOW: "sensor.test"},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: "sensor.test", CONF_BELOW: "sensor.test"},
|
||||
does_not_raise(),
|
||||
),
|
||||
# Test verbose choose selector options
|
||||
(
|
||||
{CONF_ABOVE: {"chosen_selector": "entity", "entity": "sensor.test"}},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_ABOVE: {"chosen_selector": "number", "number": 10}},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_BELOW: {"chosen_selector": "entity", "entity": "sensor.test"}},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_BELOW: {"chosen_selector": "number", "number": 90}},
|
||||
does_not_raise(),
|
||||
),
|
||||
# Test invalid configurations
|
||||
(
|
||||
# Must be valid entity id
|
||||
{CONF_ABOVE: "cat", CONF_BELOW: "dog"},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Above must be smaller than below
|
||||
{CONF_ABOVE: 90, CONF_BELOW: 10},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Invalid choose selector option
|
||||
{CONF_BELOW: {"chosen_selector": "cat", "cat": 90}},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_numerical_state_attribute_changed_trigger_config_validation(
|
||||
hass: HomeAssistant,
|
||||
trigger_options: dict[str, Any],
|
||||
expected_result: AbstractContextManager,
|
||||
) -> None:
|
||||
"""Test numerical state attribute change trigger config validation."""
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
return {
|
||||
"test_trigger": make_entity_numerical_state_attribute_changed_trigger(
|
||||
"test", "test_attribute"
|
||||
),
|
||||
}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
|
||||
with expected_result:
|
||||
await async_validate_trigger_config(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
"platform": "test.test_trigger",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
|
||||
CONF_OPTIONS: trigger_options,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_numerical_state_attribute_changed_error_handling(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> None:
|
||||
"""Test numerical state attribute change error handling."""
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
return {
|
||||
"attribute_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
"test", "test_attribute"
|
||||
),
|
||||
}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 20})
|
||||
|
||||
options = {
|
||||
CONF_OPTIONS: {CONF_ABOVE: "sensor.above", CONF_BELOW: "sensor.below"},
|
||||
}
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "test.attribute_changed",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
|
||||
}
|
||||
| options,
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Test the trigger works
|
||||
hass.states.async_set("sensor.above", "10")
|
||||
hass.states.async_set("sensor.below", "90")
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
|
||||
# Test the trigger fires again when still within limits
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 51})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
|
||||
# Test the trigger does not fire when the from-state is unknown or unavailable
|
||||
for from_state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||||
hass.states.async_set("test.test_entity", from_state)
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Test the trigger does not fire when the attribute value is outside the limits
|
||||
for value in (5, 95):
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Test the trigger does not fire when the attribute value is missing
|
||||
hass.states.async_set("test.test_entity", "on", {})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Test the trigger does not fire when the attribute value is invalid
|
||||
for value in ("cat", None):
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Test the trigger does not fire when the above sensor does not exist
|
||||
hass.states.async_remove("sensor.above")
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Test the trigger does not fire when the above sensor state is not numeric
|
||||
for invalid_value in ("cat", None):
|
||||
hass.states.async_set("sensor.above", invalid_value)
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Reset the above sensor state to a valid numeric value
|
||||
hass.states.async_set("sensor.above", "10")
|
||||
|
||||
# Test the trigger does not fire when the below sensor does not exist
|
||||
hass.states.async_remove("sensor.below")
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Test the trigger does not fire when the below sensor state is not numeric
|
||||
for invalid_value in ("cat", None):
|
||||
hass.states.async_set("sensor.below", invalid_value)
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": None})
|
||||
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_options", "expected_result"),
|
||||
[
|
||||
# Valid configurations
|
||||
(
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: 10},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_LOWER_LIMIT: "sensor.test"},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: 90},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_UPPER_LIMIT: "sensor.test"},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: "sensor.test",
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: "sensor.test",
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_LOWER_LIMIT: "sensor.test",
|
||||
CONF_UPPER_LIMIT: "sensor.test",
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: 10,
|
||||
CONF_UPPER_LIMIT: "sensor.test",
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: "sensor.test",
|
||||
CONF_UPPER_LIMIT: 90,
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
(
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE,
|
||||
CONF_LOWER_LIMIT: "sensor.test",
|
||||
CONF_UPPER_LIMIT: "sensor.test",
|
||||
},
|
||||
does_not_raise(),
|
||||
),
|
||||
# Test verbose choose selector options
|
||||
# Test invalid configurations
|
||||
(
|
||||
# Missing threshold type
|
||||
{},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Invalid threshold type
|
||||
{CONF_THRESHOLD_TYPE: "cat"},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide lower limit for ABOVE
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide lower limit for ABOVE
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.ABOVE, CONF_UPPER_LIMIT: 90},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper limit for BELOW
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper limit for BELOW
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.BELOW, CONF_LOWER_LIMIT: 10},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper and lower limits for BETWEEN
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper and lower limits for BETWEEN
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_LOWER_LIMIT: 10},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper and lower limits for BETWEEN
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN, CONF_UPPER_LIMIT: 90},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper and lower limits for OUTSIDE
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper and lower limits for OUTSIDE
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_LOWER_LIMIT: 10},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must provide upper and lower limits for OUTSIDE
|
||||
{CONF_THRESHOLD_TYPE: ThresholdType.OUTSIDE, CONF_UPPER_LIMIT: 90},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Must be valid entity id
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_ABOVE: "cat",
|
||||
CONF_BELOW: "dog",
|
||||
},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
(
|
||||
# Above must be smaller than below
|
||||
{
|
||||
CONF_THRESHOLD_TYPE: ThresholdType.BETWEEN,
|
||||
CONF_ABOVE: 90,
|
||||
CONF_BELOW: 10,
|
||||
},
|
||||
pytest.raises(vol.Invalid),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_numerical_state_attribute_crossed_threshold_trigger_config_validation(
|
||||
hass: HomeAssistant,
|
||||
trigger_options: dict[str, Any],
|
||||
expected_result: AbstractContextManager,
|
||||
) -> None:
|
||||
"""Test numerical state attribute change trigger config validation."""
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
return {
|
||||
"test_trigger": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
"test", "test_attribute"
|
||||
),
|
||||
}
|
||||
|
||||
mock_integration(hass, MockModule("test"))
|
||||
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
|
||||
|
||||
with expected_result:
|
||||
await async_validate_trigger_config(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
"platform": "test.test_trigger",
|
||||
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
|
||||
CONF_OPTIONS: trigger_options,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user