mirror of
https://github.com/home-assistant/core.git
synced 2025-12-21 07:18:03 +00:00
Compare commits
33 Commits
epenet-202
...
input_bool
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ec003124 | ||
|
|
0db9dcfd1c | ||
|
|
5b5850224a | ||
|
|
065b0eb5b2 | ||
|
|
6a1d86d5db | ||
|
|
f99a73ef28 | ||
|
|
0436d30062 | ||
|
|
24b6b5452b | ||
|
|
8b91ebfe30 | ||
|
|
37d3b73c1b | ||
|
|
c881d9809e | ||
|
|
85dfe3a107 | ||
|
|
d8a468833e | ||
|
|
5bbd56b8e6 | ||
|
|
d0411b6613 | ||
|
|
293fbebef2 | ||
|
|
cfe542acb9 | ||
|
|
8da323d4b7 | ||
|
|
b2edf637cc | ||
|
|
de61a45de1 | ||
|
|
d9324cb0e4 | ||
|
|
4a464f601c | ||
|
|
43e9c24c18 | ||
|
|
1c3492b4c2 | ||
|
|
e0cb56a38c | ||
|
|
6e05cc4898 | ||
|
|
6f9dc2e5a2 | ||
|
|
ddb1ae371d | ||
|
|
6553337b79 | ||
|
|
aedc729d57 | ||
|
|
ee0230f3b1 | ||
|
|
851fd467fe | ||
|
|
d10148a175 |
@@ -402,6 +402,8 @@ class AuthManager:
|
||||
if user.is_owner:
|
||||
raise ValueError("Unable to deactivate the owner")
|
||||
await self._store.async_deactivate_user(user)
|
||||
for refresh_token in list(user.refresh_tokens.values()):
|
||||
self.async_remove_refresh_token(refresh_token)
|
||||
|
||||
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
|
||||
"""Remove credentials."""
|
||||
|
||||
@@ -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,
|
||||
@@ -130,9 +131,13 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"fan",
|
||||
"humidifier",
|
||||
"input_boolean",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"siren",
|
||||
"switch",
|
||||
"text",
|
||||
"update",
|
||||
@@ -1214,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
|
||||
@@ -30,7 +60,21 @@ hvac_mode_changed:
|
||||
required: true
|
||||
selector:
|
||||
state:
|
||||
# Note: This should allow selecting multiple modes, but state selector does not support that yet.
|
||||
hide_states:
|
||||
- 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"]
|
||||
}
|
||||
|
||||
@@ -101,6 +101,15 @@ def _is_location_already_configured(
|
||||
return False
|
||||
|
||||
|
||||
def _is_location_name_already_configured(hass: HomeAssistant, new_data: str) -> bool:
|
||||
"""Check if the location name is already configured."""
|
||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.title.lower() == new_data.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google AirQuality."""
|
||||
|
||||
@@ -178,8 +187,19 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
description_placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
||||
return self.async_abort(reason="already_configured")
|
||||
errors["base"] = "location_already_configured"
|
||||
if _is_location_name_already_configured(self.hass, user_input[CONF_NAME]):
|
||||
errors["base"] = "location_name_already_configured"
|
||||
api: GoogleAirQualityApi = self._get_entry().runtime_data.api
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="location",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
_get_location_schema(self.hass), user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
if await _validate_input(user_input, api, errors, description_placeholders):
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME],
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
"config_subentries": {
|
||||
"location": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"unable_to_fetch": "[%key:component::google_air_quality::common::unable_to_fetch%]"
|
||||
},
|
||||
"entry_type": "Air quality location",
|
||||
"error": {
|
||||
"no_data_for_location": "Information is unavailable for this location. Please try a different location.",
|
||||
"location_already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"location_name_already_configured": "Location name already configured.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
|
||||
from .const import (
|
||||
ABORT_NO_PLANTS,
|
||||
@@ -23,12 +24,13 @@ from .const import (
|
||||
AUTH_PASSWORD,
|
||||
CONF_AUTH_TYPE,
|
||||
CONF_PLANT_ID,
|
||||
CONF_REGION,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ERROR_CANNOT_CONNECT,
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
SERVER_URLS,
|
||||
SERVER_URLS_NAMES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -67,10 +69,13 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.auth_type = AUTH_PASSWORD
|
||||
|
||||
# Traditional username/password authentication
|
||||
# Convert region name to URL - guaranteed to exist since vol.In validates it
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
|
||||
self.api = growattServer.GrowattApi(
|
||||
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
|
||||
)
|
||||
self.api.server_url = user_input[CONF_URL]
|
||||
self.api.server_url = server_url
|
||||
|
||||
try:
|
||||
login_response = await self.hass.async_add_executor_job(
|
||||
@@ -91,6 +96,8 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self.user_id = login_response["user"]["id"]
|
||||
self.data = user_input
|
||||
# Store the actual URL, not the region name
|
||||
self.data[CONF_URL] = server_url
|
||||
self.data[CONF_AUTH_TYPE] = self.auth_type
|
||||
return await self.async_step_plant()
|
||||
|
||||
@@ -104,8 +111,11 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.auth_type = AUTH_API_TOKEN
|
||||
|
||||
# Using token authentication
|
||||
token = user_input[CONF_TOKEN]
|
||||
self.api = growattServer.OpenApiV1(token=token)
|
||||
# Convert region name to URL - guaranteed to exist since vol.In validates it
|
||||
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
|
||||
|
||||
self.api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
|
||||
self.api.server_url = server_url
|
||||
|
||||
# Verify token by fetching plant list
|
||||
try:
|
||||
@@ -127,6 +137,8 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
|
||||
self.data = user_input
|
||||
# Store the actual URL, not the region name
|
||||
self.data[CONF_URL] = server_url
|
||||
self.data[CONF_AUTH_TYPE] = self.auth_type
|
||||
return await self.async_step_plant()
|
||||
|
||||
@@ -139,7 +151,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS),
|
||||
vol.Required(CONF_REGION, default=DEFAULT_URL): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -155,6 +172,12 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOKEN): str,
|
||||
vol.Required(CONF_REGION, default=DEFAULT_URL): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(SERVER_URLS_NAMES.keys()),
|
||||
translation_key="region",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from homeassistant.const import Platform
|
||||
|
||||
CONF_PLANT_ID = "plant_id"
|
||||
CONF_REGION = "region"
|
||||
|
||||
|
||||
# API key support
|
||||
@@ -18,13 +19,14 @@ DEFAULT_PLANT_ID = "0"
|
||||
|
||||
DEFAULT_NAME = "Growatt"
|
||||
|
||||
SERVER_URLS = [
|
||||
"https://openapi.growatt.com/", # Other regional server
|
||||
"https://openapi-cn.growatt.com/", # Chinese server
|
||||
"https://openapi-us.growatt.com/", # North American server
|
||||
"https://openapi-au.growatt.com/", # Australia Server
|
||||
"http://server.smten.com/", # smten server
|
||||
]
|
||||
SERVER_URLS_NAMES = {
|
||||
"north_america": "https://openapi-us.growatt.com/",
|
||||
"australia_new_zealand": "https://openapi-au.growatt.com/",
|
||||
"china": "https://openapi-cn.growatt.com/",
|
||||
"other_regions": "https://openapi.growatt.com/",
|
||||
"smten_server": "http://server.smten.com/",
|
||||
"era_server": "http://ess-server.atesspower.com/",
|
||||
}
|
||||
|
||||
DEPRECATED_URLS = [
|
||||
"https://server.growatt.com/",
|
||||
@@ -32,7 +34,7 @@ DEPRECATED_URLS = [
|
||||
"https://server-us.growatt.com/",
|
||||
]
|
||||
|
||||
DEFAULT_URL = SERVER_URLS[0]
|
||||
DEFAULT_URL = "other_regions"
|
||||
|
||||
DOMAIN = "growatt_server"
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ rules:
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: todo
|
||||
comment: Update server URL dropdown to show regional descriptions (e.g., 'China', 'United States') instead of raw URLs.
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"password_auth": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"url": "Server region",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"title": "Enter your Growatt login credentials"
|
||||
@@ -26,7 +26,8 @@
|
||||
},
|
||||
"token_auth": {
|
||||
"data": {
|
||||
"token": "API Token"
|
||||
"token": "API Token",
|
||||
"url": "Server region"
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
@@ -530,6 +531,16 @@
|
||||
"grid_first": "Grid first",
|
||||
"load_first": "Load first"
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"options": {
|
||||
"australia_new_zealand": "Australia and New Zealand",
|
||||
"china": "China",
|
||||
"era_server": "Era server (Atess Power)",
|
||||
"north_america": "North America",
|
||||
"other_regions": "Other regions",
|
||||
"smten_server": "SMTEN server"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -65,6 +65,11 @@ BINARY_SENSORS = (
|
||||
},
|
||||
translation_key="charging_connection",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=StatusKey.BSH_COMMON_INTERIOR_ILLUMINATION_ACTIVE,
|
||||
translation_key="interior_illumination_active",
|
||||
device_class=BinarySensorDeviceClass.LIGHT,
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED,
|
||||
translation_key="dust_box_inserted",
|
||||
|
||||
@@ -270,6 +270,10 @@ WARMING_LEVEL_OPTIONS = {
|
||||
)
|
||||
}
|
||||
|
||||
RINSE_PLUS_OPTIONS = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in ("LaundryCare.Washer.EnumType.RinsePlus.Off",)
|
||||
}
|
||||
TEMPERATURE_OPTIONS = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
@@ -309,6 +313,12 @@ SPIN_SPEED_OPTIONS = {
|
||||
)
|
||||
}
|
||||
|
||||
STAINS_OPTIONS = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in ("LaundryCare.Washer.EnumType.Stains.Off",)
|
||||
}
|
||||
|
||||
|
||||
VARIO_PERFECT_OPTIONS = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
@@ -363,8 +373,10 @@ PROGRAM_ENUM_OPTIONS = {
|
||||
(OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS),
|
||||
(OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS),
|
||||
(OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS),
|
||||
(OptionKey.LAUNDRY_CARE_WASHER_RINSE_PLUS, RINSE_PLUS_OPTIONS),
|
||||
(OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS),
|
||||
(OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS),
|
||||
(OptionKey.LAUNDRY_CARE_WASHER_STAINS, STAINS_OPTIONS),
|
||||
(OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"dust_box_inserted": {
|
||||
"default": "mdi:download"
|
||||
},
|
||||
"interior_illumination_active": {
|
||||
"default": "mdi:lightbulb-on",
|
||||
"state": {
|
||||
"off": "mdi:lightbulb-off"
|
||||
}
|
||||
},
|
||||
"lifted": {
|
||||
"default": "mdi:arrow-up-right-bold"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,9 @@ from .const import (
|
||||
HOT_WATER_TEMPERATURE_OPTIONS,
|
||||
INTENSIVE_LEVEL_OPTIONS,
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP,
|
||||
RINSE_PLUS_OPTIONS,
|
||||
SPIN_SPEED_OPTIONS,
|
||||
STAINS_OPTIONS,
|
||||
SUCTION_POWER_OPTIONS,
|
||||
TEMPERATURE_OPTIONS,
|
||||
TRANSLATION_KEYS_PROGRAMS_MAP,
|
||||
@@ -279,6 +281,16 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
|
||||
for translation_key, value in WARMING_LEVEL_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_RINSE_PLUS,
|
||||
translation_key="rinse_plus",
|
||||
options=list(RINSE_PLUS_OPTIONS),
|
||||
translation_key_values=RINSE_PLUS_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in RINSE_PLUS_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
|
||||
translation_key="washer_temperature",
|
||||
@@ -299,6 +311,15 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
|
||||
for translation_key, value in SPIN_SPEED_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_STAINS,
|
||||
translation_key="auto_stain",
|
||||
options=list(STAINS_OPTIONS),
|
||||
translation_key_values=STAINS_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key for translation_key, value in STAINS_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT,
|
||||
translation_key="vario_perfect",
|
||||
|
||||
@@ -524,6 +524,15 @@ set_program_and_options:
|
||||
washer_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
laundry_care_washer_option_rinse_plus:
|
||||
example: laundry_care_washer_enum_type_rinse_plus_off
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
mode: dropdown
|
||||
translation_key: rinse_plus
|
||||
options:
|
||||
- laundry_care_washer_enum_type_rinse_plus_off
|
||||
laundry_care_washer_option_temperature:
|
||||
example: laundry_care_washer_enum_type_temperature_g_c_40
|
||||
required: false
|
||||
@@ -567,6 +576,15 @@ set_program_and_options:
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_low
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_medium
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_high
|
||||
laundry_care_washer_option_stains:
|
||||
example: laundry_care_washer_enum_type_stains_off
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
mode: dropdown
|
||||
translation_key: stains
|
||||
options:
|
||||
- laundry_care_washer_enum_type_stains_off
|
||||
b_s_h_common_option_finish_in_relative:
|
||||
example: 3600
|
||||
required: false
|
||||
@@ -576,6 +594,11 @@ set_program_and_options:
|
||||
step: 1
|
||||
mode: box
|
||||
unit_of_measurement: s
|
||||
laundry_care_common_option_silent_mode:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_i_dos1_active:
|
||||
example: false
|
||||
required: false
|
||||
@@ -586,6 +609,41 @@ set_program_and_options:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_intensive_plus:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_less_ironing:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_mini_load:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_prewash:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_rinse_hold:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_soak:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_water_plus:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_vario_perfect:
|
||||
example: laundry_care_common_enum_type_vario_perfect_eco_perfect
|
||||
required: false
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
"freezer_door": {
|
||||
"name": "Freezer door"
|
||||
},
|
||||
"interior_illumination_active": {
|
||||
"name": "Interior illumination active"
|
||||
},
|
||||
"left_chiller_door": {
|
||||
"name": "Left chiller door"
|
||||
},
|
||||
@@ -359,6 +362,12 @@
|
||||
"b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom"
|
||||
}
|
||||
},
|
||||
"auto_stain": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_stains::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_stains_off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"bean_amount": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]",
|
||||
"state": {
|
||||
@@ -523,6 +532,12 @@
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
|
||||
}
|
||||
},
|
||||
"rinse_plus": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_rinse_plus::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_rinse_plus_off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"selected_program": {
|
||||
"name": "Selected program",
|
||||
"state": {
|
||||
@@ -1212,27 +1227,51 @@
|
||||
"intensiv_zone": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
|
||||
},
|
||||
"intensive_plus": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_intensive_plus::name%]"
|
||||
},
|
||||
"less_ironing": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_less_ironing::name%]"
|
||||
},
|
||||
"mini_load": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_mini_load::name%]"
|
||||
},
|
||||
"multiple_beverages": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]"
|
||||
},
|
||||
"power": {
|
||||
"name": "Power"
|
||||
},
|
||||
"prewash": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_prewash::name%]"
|
||||
},
|
||||
"refrigerator_super_mode": {
|
||||
"name": "Refrigerator super mode"
|
||||
},
|
||||
"rinse_hold": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_rinse_hold::name%]"
|
||||
},
|
||||
"sabbath_mode": {
|
||||
"name": "Sabbath mode"
|
||||
},
|
||||
"silence_on_demand": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]"
|
||||
},
|
||||
"silent_mode": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_silent_mode::name%]"
|
||||
},
|
||||
"soaking": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_soak::name%]"
|
||||
},
|
||||
"vacation_mode": {
|
||||
"name": "Vacation mode"
|
||||
},
|
||||
"vario_speed_plus": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]"
|
||||
},
|
||||
"water_plus": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_water_plus::name%]"
|
||||
},
|
||||
"zeolite_dry": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]"
|
||||
}
|
||||
@@ -1654,6 +1693,11 @@
|
||||
"laundry_care_washer_program_wool": "Wool"
|
||||
}
|
||||
},
|
||||
"rinse_plus": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_rinse_plus_off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
|
||||
@@ -1672,6 +1716,11 @@
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"stains": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_stains_off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"suction_power": {
|
||||
"options": {
|
||||
"consumer_products_cleaning_robot_enum_type_suction_power_max": "Max",
|
||||
@@ -1865,6 +1914,10 @@
|
||||
"description": "Setting to adjust the venting level of the air conditioner as a percentage.",
|
||||
"name": "Fan speed percentage"
|
||||
},
|
||||
"laundry_care_common_option_silent_mode": {
|
||||
"description": "Defines if the silent mode is activated.",
|
||||
"name": "Silent mode"
|
||||
},
|
||||
"laundry_care_dryer_option_drying_target": {
|
||||
"description": "Describes the drying target for a dryer program.",
|
||||
"name": "Drying target"
|
||||
@@ -1877,10 +1930,42 @@
|
||||
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)",
|
||||
"name": "i-Dos 2 Active"
|
||||
},
|
||||
"laundry_care_washer_option_intensive_plus": {
|
||||
"description": "Defines if the intensive washing is enabled for heavily soiled laundry.",
|
||||
"name": "Intensive +"
|
||||
},
|
||||
"laundry_care_washer_option_less_ironing": {
|
||||
"description": "Defines if the laundry is treated gently to reduce creasing and make ironing easier.",
|
||||
"name": "Less ironing"
|
||||
},
|
||||
"laundry_care_washer_option_mini_load": {
|
||||
"description": "Defines if the mini load option is activated.",
|
||||
"name": "Mini load"
|
||||
},
|
||||
"laundry_care_washer_option_prewash": {
|
||||
"description": "Defines if an additional prewash cycle is added to the program.",
|
||||
"name": "Prewash"
|
||||
},
|
||||
"laundry_care_washer_option_rinse_hold": {
|
||||
"description": "Defines if the rinse hold option is activated.",
|
||||
"name": "Rinse hold"
|
||||
},
|
||||
"laundry_care_washer_option_rinse_plus": {
|
||||
"description": "Defines if an additional rinse cycle is added to the program.",
|
||||
"name": "Extra rinse"
|
||||
},
|
||||
"laundry_care_washer_option_soak": {
|
||||
"description": "Defines if the soaking is activated.",
|
||||
"name": "Soaking"
|
||||
},
|
||||
"laundry_care_washer_option_spin_speed": {
|
||||
"description": "Defines the spin speed of a washer program.",
|
||||
"name": "Spin speed"
|
||||
},
|
||||
"laundry_care_washer_option_stains": {
|
||||
"description": "Defines the type of stains to be treated.",
|
||||
"name": "Auto stain"
|
||||
},
|
||||
"laundry_care_washer_option_temperature": {
|
||||
"description": "Defines the temperature of the washing program.",
|
||||
"name": "Temperature"
|
||||
@@ -1889,6 +1974,10 @@
|
||||
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
|
||||
"name": "Vario perfect"
|
||||
},
|
||||
"laundry_care_washer_option_water_plus": {
|
||||
"description": "Defines if the water plus option is activated.",
|
||||
"name": "Water +"
|
||||
},
|
||||
"program": {
|
||||
"description": "Program to select",
|
||||
"name": "Program"
|
||||
|
||||
@@ -124,6 +124,10 @@ SWITCH_OPTIONS = (
|
||||
key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT,
|
||||
translation_key="fast_pre_heat",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE,
|
||||
translation_key="silent_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE,
|
||||
translation_key="i_dos1_active",
|
||||
@@ -132,6 +136,34 @@ SWITCH_OPTIONS = (
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE,
|
||||
translation_key="i_dos2_active",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS,
|
||||
translation_key="intensive_plus",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING,
|
||||
translation_key="less_ironing",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD,
|
||||
translation_key="mini_load",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_PREWASH,
|
||||
translation_key="prewash",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD,
|
||||
translation_key="rinse_hold",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_SOAK,
|
||||
translation_key="soaking",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS,
|
||||
translation_key="water_plus",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -48,5 +48,19 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:air-humidifier"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"started_drying": {
|
||||
"trigger": "mdi:arrow-down-bold"
|
||||
},
|
||||
"started_humidifying": {
|
||||
"trigger": "mdi:arrow-up-bold"
|
||||
},
|
||||
"turned_off": {
|
||||
"trigger": "mdi:air-humidifier-off"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:air-humidifier-on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"set_humidity": "Set humidity for {entity_name}",
|
||||
@@ -86,6 +90,15 @@
|
||||
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_humidity": {
|
||||
"description": "Sets the target humidity.",
|
||||
@@ -120,5 +133,47 @@
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
"title": "Humidifier"
|
||||
"title": "Humidifier",
|
||||
"triggers": {
|
||||
"started_drying": {
|
||||
"description": "Triggers after one or more humidifiers start drying.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier started drying"
|
||||
},
|
||||
"started_humidifying": {
|
||||
"description": "Triggers after one or more humidifiers start humidifying.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier started humidifying"
|
||||
},
|
||||
"turned_off": {
|
||||
"description": "Triggers after one or more humidifiers turn off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier turned off"
|
||||
},
|
||||
"turned_on": {
|
||||
"description": "Triggers after one or more humidifiers turn on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier turned on"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
homeassistant/components/humidifier/trigger.py
Normal file
27
homeassistant/components/humidifier/trigger.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Provides triggers for humidifiers."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
|
||||
),
|
||||
"started_humidifying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for humidifiers."""
|
||||
return TRIGGERS
|
||||
20
homeassistant/components/humidifier/triggers.yaml
Normal file
20
homeassistant/components/humidifier/triggers.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: humidifier
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
started_drying: *trigger_common
|
||||
started_humidifying: *trigger_common
|
||||
turned_on: *trigger_common
|
||||
turned_off: *trigger_common
|
||||
@@ -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,
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from propcache.api import cached_property
|
||||
from xknx.devices import Fan as XknxFan
|
||||
@@ -32,12 +32,11 @@ from .storage.const import (
|
||||
CONF_GA_OSCILLATION,
|
||||
CONF_GA_SPEED,
|
||||
CONF_GA_STEP,
|
||||
CONF_GA_SWITCH,
|
||||
CONF_SPEED,
|
||||
)
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
DEFAULT_PERCENTAGE: Final = 50
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -77,26 +76,24 @@ class _KnxFan(FanEntity):
|
||||
_device: XknxFan
|
||||
_step_range: tuple[int, int] | None
|
||||
|
||||
def _get_knx_speed(self, percentage: int) -> int:
|
||||
"""Convert percentage to KNX speed value."""
|
||||
if self._step_range is not None:
|
||||
return math.ceil(percentage_to_ranged_value(self._step_range, percentage))
|
||||
return percentage
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
if self._step_range:
|
||||
step = math.ceil(percentage_to_ranged_value(self._step_range, percentage))
|
||||
await self._device.set_speed(step)
|
||||
else:
|
||||
await self._device.set_speed(percentage)
|
||||
await self._device.set_speed(self._get_knx_speed(percentage))
|
||||
|
||||
@cached_property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Flag supported features."""
|
||||
flags = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
flags = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
if self._device.speed.initialized:
|
||||
flags |= FanEntityFeature.SET_SPEED
|
||||
if self._device.supports_oscillation:
|
||||
flags |= FanEntityFeature.OSCILLATE
|
||||
|
||||
return flags
|
||||
|
||||
@property
|
||||
@@ -118,6 +115,11 @@ class _KnxFan(FanEntity):
|
||||
return super().speed_count
|
||||
return int_states_in_range(self._step_range)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the current fan state of the device."""
|
||||
return self._device.is_on
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
@@ -125,14 +127,12 @@ class _KnxFan(FanEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
if percentage is None:
|
||||
await self.async_set_percentage(DEFAULT_PERCENTAGE)
|
||||
else:
|
||||
await self.async_set_percentage(percentage)
|
||||
speed = self._get_knx_speed(percentage) if percentage is not None else None
|
||||
await self._device.turn_on(speed)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
await self.async_set_percentage(0)
|
||||
await self._device.turn_off()
|
||||
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Oscillate the fan."""
|
||||
@@ -165,7 +165,12 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
|
||||
group_address_oscillation_state=config.get(
|
||||
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
|
||||
),
|
||||
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
|
||||
group_address_switch_state=config.get(
|
||||
FanSchema.CONF_SWITCH_STATE_ADDRESS
|
||||
),
|
||||
max_step=max_step,
|
||||
sync_state=config.get(CONF_SYNC_STATE, True),
|
||||
),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
@@ -210,6 +215,8 @@ class KnxUiFan(_KnxFan, KnxUiEntity):
|
||||
group_address_oscillation_state=knx_conf.get_state_and_passive(
|
||||
CONF_GA_OSCILLATION
|
||||
),
|
||||
group_address_switch=knx_conf.get_write(CONF_GA_SWITCH),
|
||||
group_address_switch_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH),
|
||||
max_step=max_step,
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -576,19 +576,40 @@ class FanSchema(KNXPlatformSchema):
|
||||
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
|
||||
CONF_OSCILLATION_ADDRESS = "oscillation_address"
|
||||
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
|
||||
CONF_SWITCH_ADDRESS = "switch_address"
|
||||
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
|
||||
|
||||
DEFAULT_NAME = "KNX Fan"
|
||||
|
||||
ENTITY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Required(KNX_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(FanConf.MAX_STEP): cv.byte,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
}
|
||||
ENTITY_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(KNX_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_SWITCH_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(FanConf.MAX_STEP): cv.byte,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
|
||||
}
|
||||
),
|
||||
vol.Any(
|
||||
vol.Schema(
|
||||
{vol.Required(KNX_ADDRESS): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
vol.Schema(
|
||||
{vol.Required(CONF_SWITCH_ADDRESS): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
msg=(
|
||||
f"At least one of '{KNX_ADDRESS}' or"
|
||||
f" '{CONF_SWITCH_ADDRESS}' is required."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -224,40 +237,58 @@ DATETIME_KNX_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
FAN_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SPEED): GroupSelect(
|
||||
GroupSelectOption(
|
||||
translation_key="percentage_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_SPEED): GASelector(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
},
|
||||
FAN_KNX_SCHEMA = AllSerializeFirst(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_GA_SWITCH): GASelector(
|
||||
write_required=True, valid_dpt="1"
|
||||
),
|
||||
GroupSelectOption(
|
||||
translation_key="step_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_STEP): GASelector(
|
||||
write_required=True, valid_dpt="5.010"
|
||||
),
|
||||
vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=100,
|
||||
step=1,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
},
|
||||
vol.Optional(CONF_SPEED): GroupSelect(
|
||||
GroupSelectOption(
|
||||
translation_key="percentage_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_SPEED): GASelector(
|
||||
write_required=True, valid_dpt="5.001"
|
||||
),
|
||||
},
|
||||
),
|
||||
GroupSelectOption(
|
||||
translation_key="step_mode",
|
||||
schema={
|
||||
vol.Required(CONF_GA_STEP): GASelector(
|
||||
write_required=True, valid_dpt="5.010"
|
||||
),
|
||||
vol.Required(
|
||||
FanConf.MAX_STEP, default=3
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=100,
|
||||
step=1,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
},
|
||||
),
|
||||
collapsible=False,
|
||||
),
|
||||
collapsible=False,
|
||||
vol.Optional(CONF_GA_OSCILLATION): GASelector(
|
||||
write_required=True, valid_dpt="1"
|
||||
),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
),
|
||||
vol.Any(
|
||||
vol.Schema(
|
||||
{vol.Required(CONF_GA_SWITCH): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
vol.Optional(CONF_GA_OSCILLATION): GASelector(
|
||||
write_required=True, valid_dpt="1"
|
||||
vol.Schema(
|
||||
{vol.Required(CONF_SPEED): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
msg=("At least one of 'Switch' or 'Fan speed' is required."),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -547,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,
|
||||
@@ -555,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": {
|
||||
"_": {
|
||||
@@ -467,6 +644,10 @@
|
||||
"description": "Toggle oscillation of the fan.",
|
||||
"label": "Oscillation"
|
||||
},
|
||||
"ga_switch": {
|
||||
"description": "Group address to turn the fan on/off.",
|
||||
"label": "Switch"
|
||||
},
|
||||
"speed": {
|
||||
"description": "Control the speed of the fan.",
|
||||
"ga_speed": {
|
||||
@@ -593,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": {
|
||||
@@ -751,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.",
|
||||
@@ -778,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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Final
|
||||
|
||||
from lunatone_rest_api_client import Auth, Devices, Info
|
||||
from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info
|
||||
|
||||
from homeassistant.const import CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -42,19 +42,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
|
||||
name=info_api.name,
|
||||
manufacturer="Lunatone",
|
||||
sw_version=info_api.version,
|
||||
hw_version=info_api.data.device.pcb,
|
||||
hw_version=coordinator_info.data.device.pcb,
|
||||
configuration_url=entry.data[CONF_URL],
|
||||
serial_number=str(info_api.serial_number),
|
||||
model=info_api.product_name,
|
||||
model_id=(
|
||||
f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
|
||||
f"{coordinator_info.data.device.article_number}{coordinator_info.data.device.article_info}"
|
||||
),
|
||||
)
|
||||
|
||||
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
|
||||
await coordinator_devices.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
|
||||
dali_line_broadcasts = [
|
||||
DALIBroadcast(auth_api, int(line)) for line in coordinator_info.data.lines
|
||||
]
|
||||
|
||||
entry.runtime_data = LunatoneData(
|
||||
coordinator_info,
|
||||
coordinator_devices,
|
||||
dali_line_broadcasts,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from lunatone_rest_api_client import Device, Devices, Info
|
||||
from lunatone_rest_api_client import DALIBroadcast, Device, Devices, Info
|
||||
from lunatone_rest_api_client.models import InfoData
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -18,6 +18,7 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_INFO_SCAN_INTERVAL = timedelta(seconds=60)
|
||||
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
|
||||
@@ -27,6 +28,7 @@ class LunatoneData:
|
||||
|
||||
coordinator_info: LunatoneInfoDataUpdateCoordinator
|
||||
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
|
||||
dali_line_broadcasts: list[DALIBroadcast]
|
||||
|
||||
|
||||
type LunatoneConfigEntry = ConfigEntry[LunatoneData]
|
||||
@@ -47,6 +49,7 @@ class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}-info",
|
||||
always_update=False,
|
||||
update_interval=DEFAULT_INFO_SCAN_INTERVAL,
|
||||
)
|
||||
self.info_api = info_api
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from lunatone_rest_api_client import DALIBroadcast
|
||||
from lunatone_rest_api_client.models import LineStatus
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ColorMode,
|
||||
@@ -18,7 +21,11 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.color import brightness_to_value, value_to_brightness
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
|
||||
from .coordinator import (
|
||||
LunatoneConfigEntry,
|
||||
LunatoneDevicesDataUpdateCoordinator,
|
||||
LunatoneInfoDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
STATUS_UPDATE_DELAY = 0.04
|
||||
@@ -32,8 +39,15 @@ async def async_setup_entry(
|
||||
"""Set up the Lunatone Light platform."""
|
||||
coordinator_info = config_entry.runtime_data.coordinator_info
|
||||
coordinator_devices = config_entry.runtime_data.coordinator_devices
|
||||
dali_line_broadcasts = config_entry.runtime_data.dali_line_broadcasts
|
||||
|
||||
async_add_entities(
|
||||
entities: list[LightEntity] = [
|
||||
LunatoneLineBroadcastLight(
|
||||
coordinator_info, coordinator_devices, dali_line_broadcast
|
||||
)
|
||||
for dali_line_broadcast in dali_line_broadcasts
|
||||
]
|
||||
entities.extend(
|
||||
[
|
||||
LunatoneLight(
|
||||
coordinator_devices, device_id, coordinator_info.data.device.serial
|
||||
@@ -42,6 +56,8 @@ async def async_setup_entry(
|
||||
]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LunatoneLight(
|
||||
CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
|
||||
@@ -62,22 +78,24 @@ class LunatoneLight(
|
||||
device_id: int,
|
||||
interface_serial_number: int,
|
||||
) -> None:
|
||||
"""Initialize a LunatoneLight."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
"""Initialize a Lunatone light."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._interface_serial_number = interface_serial_number
|
||||
self._device = self.coordinator.data.get(self._device_id)
|
||||
self._device = self.coordinator.data[self._device_id]
|
||||
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device info."""
|
||||
assert self.unique_id
|
||||
name = self._device.name if self._device is not None else None
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
name=name,
|
||||
via_device=(DOMAIN, str(self._interface_serial_number)),
|
||||
name=self._device.name,
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
f"{self._interface_serial_number}-line{self._device.data.line}",
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -93,8 +111,6 @@ class LunatoneLight(
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if self._device is None:
|
||||
return 0
|
||||
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
|
||||
@property
|
||||
@@ -112,17 +128,17 @@ class LunatoneLight(
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._device = self.coordinator.data.get(self._device_id)
|
||||
self._device = self.coordinator.data[self._device_id]
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
assert self._device
|
||||
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness)
|
||||
await self._device.fade_to_brightness(
|
||||
brightness_to_value(self.BRIGHTNESS_SCALE, brightness)
|
||||
brightness_to_value(
|
||||
self.BRIGHTNESS_SCALE,
|
||||
kwargs.get(ATTR_BRIGHTNESS, self._last_brightness),
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._device.switch_on()
|
||||
@@ -132,8 +148,6 @@ class LunatoneLight(
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
assert self._device
|
||||
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
self._last_brightness = self.brightness
|
||||
await self._device.fade_to_brightness(0)
|
||||
@@ -142,3 +156,69 @@ class LunatoneLight(
|
||||
|
||||
await asyncio.sleep(STATUS_UPDATE_DELAY)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
|
||||
class LunatoneLineBroadcastLight(
|
||||
CoordinatorEntity[LunatoneInfoDataUpdateCoordinator], LightEntity
|
||||
):
|
||||
"""Representation of a Lunatone line broadcast light."""
|
||||
|
||||
BRIGHTNESS_SCALE = (1, 100)
|
||||
|
||||
_attr_assumed_state = True
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator_info: LunatoneInfoDataUpdateCoordinator,
|
||||
coordinator_devices: LunatoneDevicesDataUpdateCoordinator,
|
||||
broadcast: DALIBroadcast,
|
||||
) -> None:
|
||||
"""Initialize a Lunatone line broadcast light."""
|
||||
super().__init__(coordinator_info)
|
||||
self._coordinator_devices = coordinator_devices
|
||||
self._broadcast = broadcast
|
||||
|
||||
line = broadcast.line
|
||||
|
||||
self._attr_unique_id = f"{coordinator_info.data.device.serial}-line{line}"
|
||||
|
||||
line_device = self.coordinator.data.lines[str(line)].device
|
||||
extra_info: dict = {}
|
||||
if line_device.serial != coordinator_info.data.device.serial:
|
||||
extra_info.update(
|
||||
serial_number=str(line_device.serial),
|
||||
hw_version=line_device.pcb,
|
||||
model_id=f"{line_device.article_number}{line_device.article_info}",
|
||||
)
|
||||
|
||||
assert self.unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
name=f"DALI Line {line}",
|
||||
via_device=(DOMAIN, str(coordinator_info.data.device.serial)),
|
||||
**extra_info,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
line_status = self.coordinator.data.lines[str(self._broadcast.line)].line_status
|
||||
return super().available and line_status == LineStatus.OK
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the line to turn on."""
|
||||
await self._broadcast.fade_to_brightness(
|
||||
brightness_to_value(self.BRIGHTNESS_SCALE, kwargs.get(ATTR_BRIGHTNESS, 255))
|
||||
)
|
||||
|
||||
await asyncio.sleep(STATUS_UPDATE_DELAY)
|
||||
await self._coordinator_devices.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the line to turn off."""
|
||||
await self._broadcast.fade_to_brightness(0)
|
||||
|
||||
await asyncio.sleep(STATUS_UPDATE_DELAY)
|
||||
await self._coordinator_devices.async_refresh()
|
||||
|
||||
@@ -20,9 +20,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
ATTR_CAMERA_LIGHT_MODE,
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_PERSON,
|
||||
ATTR_PERSONS,
|
||||
CAMERA_LIGHT_MODES,
|
||||
CAMERA_TRIGGERS,
|
||||
CONF_URL_SECURITY,
|
||||
DATA_CAMERAS,
|
||||
DATA_EVENTS,
|
||||
@@ -37,8 +39,6 @@ from .const import (
|
||||
SERVICE_SET_CAMERA_LIGHT,
|
||||
SERVICE_SET_PERSON_AWAY,
|
||||
SERVICE_SET_PERSONS_HOME,
|
||||
WEBHOOK_LIGHT_MODE,
|
||||
WEBHOOK_NACAMERA_CONNECTION,
|
||||
WEBHOOK_PUSH_TYPE,
|
||||
)
|
||||
from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice
|
||||
@@ -125,13 +125,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
|
||||
"""Entity created."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
for event_type in (
|
||||
EVENT_TYPE_LIGHT_MODE,
|
||||
EVENT_TYPE_OFF,
|
||||
EVENT_TYPE_ON,
|
||||
EVENT_TYPE_CONNECTION,
|
||||
EVENT_TYPE_DISCONNECTION,
|
||||
):
|
||||
for event_type in CAMERA_TRIGGERS:
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
@@ -146,34 +140,63 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
|
||||
def handle_event(self, event: dict) -> None:
|
||||
"""Handle webhook events."""
|
||||
data = event["data"]
|
||||
event_type = data.get(ATTR_EVENT_TYPE)
|
||||
push_type = data.get(WEBHOOK_PUSH_TYPE)
|
||||
|
||||
if not push_type:
|
||||
_LOGGER.debug("Event has no push_type, returning")
|
||||
return
|
||||
|
||||
if not data.get("camera_id"):
|
||||
_LOGGER.debug("Event %s has no camera ID, returning", event_type)
|
||||
return
|
||||
|
||||
if (
|
||||
data["home_id"] == self.home.entity_id
|
||||
and data["camera_id"] == self.device.entity_id
|
||||
):
|
||||
if data[WEBHOOK_PUSH_TYPE] in (
|
||||
"NACamera-off",
|
||||
"NOCamera-off",
|
||||
"NACamera-disconnection",
|
||||
"NOCamera-disconnection",
|
||||
):
|
||||
# device_type to be stripped "DeviceType."
|
||||
device_push_type = f"{self.device_type.name}-{event_type}"
|
||||
if push_type != device_push_type:
|
||||
_LOGGER.debug(
|
||||
"Event push_type %s does not match device push_type %s, returning",
|
||||
push_type,
|
||||
device_push_type,
|
||||
)
|
||||
return
|
||||
|
||||
if event_type in [EVENT_TYPE_DISCONNECTION, EVENT_TYPE_OFF]:
|
||||
_LOGGER.debug(
|
||||
"Camera %s has received %s event, turning off and idleing streaming",
|
||||
data["camera_id"],
|
||||
event_type,
|
||||
)
|
||||
self._attr_is_streaming = False
|
||||
self._monitoring = False
|
||||
elif data[WEBHOOK_PUSH_TYPE] in (
|
||||
"NACamera-on",
|
||||
"NOCamera-on",
|
||||
WEBHOOK_NACAMERA_CONNECTION,
|
||||
"NOCamera-connection",
|
||||
):
|
||||
elif event_type in [EVENT_TYPE_CONNECTION, EVENT_TYPE_ON]:
|
||||
_LOGGER.debug(
|
||||
"Camera %s has received %s event, turning on and enabling streaming",
|
||||
data["camera_id"],
|
||||
event_type,
|
||||
)
|
||||
self._attr_is_streaming = True
|
||||
self._monitoring = True
|
||||
elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE:
|
||||
self._light_state = data["sub_type"]
|
||||
self._attr_extra_state_attributes.update(
|
||||
{"light_state": self._light_state}
|
||||
elif event_type == EVENT_TYPE_LIGHT_MODE:
|
||||
if data.get("sub_type"):
|
||||
self._light_state = data["sub_type"]
|
||||
self._attr_extra_state_attributes.update(
|
||||
{"light_state": self._light_state}
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Camera %s has received light mode event without sub_type",
|
||||
data["camera_id"],
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Camera %s has received unexpected event as type %s",
|
||||
data["camera_id"],
|
||||
event_type,
|
||||
)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -114,41 +114,51 @@ EVENT_TYPE_SCHEDULE = "schedule"
|
||||
EVENT_TYPE_SET_POINT = "set_point"
|
||||
EVENT_TYPE_THERM_MODE = "therm_mode"
|
||||
# Camera events
|
||||
EVENT_TYPE_CAMERA_ANIMAL = "animal"
|
||||
EVENT_TYPE_CAMERA_HUMAN = "human"
|
||||
EVENT_TYPE_CAMERA_MOVEMENT = "movement"
|
||||
EVENT_TYPE_CAMERA_OUTDOOR = "outdoor"
|
||||
EVENT_TYPE_CAMERA_PERSON = "person"
|
||||
EVENT_TYPE_CAMERA_PERSON_AWAY = "person_away"
|
||||
EVENT_TYPE_CAMERA_VEHICLE = "vehicle"
|
||||
EVENT_TYPE_ANIMAL = "animal"
|
||||
EVENT_TYPE_HUMAN = "human"
|
||||
EVENT_TYPE_MOVEMENT = "movement"
|
||||
EVENT_TYPE_OUTDOOR = "outdoor"
|
||||
EVENT_TYPE_PERSON = "person"
|
||||
EVENT_TYPE_PERSON_AWAY = "person_away"
|
||||
EVENT_TYPE_VEHICLE = "vehicle"
|
||||
EVENT_TYPE_LIGHT_MODE = "light_mode"
|
||||
# Door tags
|
||||
EVENT_TYPE_ALARM_STARTED = "alarm_started"
|
||||
EVENT_TYPE_DOOR_TAG_BIG_MOVE = "tag_big_move"
|
||||
EVENT_TYPE_DOOR_TAG_OPEN = "tag_open"
|
||||
EVENT_TYPE_DOOR_TAG_SMALL_MOVE = "tag_small_move"
|
||||
EVENT_TYPE_TAG_BIG_MOVE = "tag_big_move"
|
||||
EVENT_TYPE_TAG_OPEN = "tag_open"
|
||||
EVENT_TYPE_TAG_SMALL_MOVE = "tag_small_move"
|
||||
# Generic events
|
||||
EVENT_TYPE_CONNECTION = "connection"
|
||||
EVENT_TYPE_DISCONNECTION = "disconnection"
|
||||
EVENT_TYPE_MODULE_CONNECT = "module_connect"
|
||||
EVENT_TYPE_MODULE_DISCONNECT = "module_disconnect"
|
||||
EVENT_TYPE_OFF = "off"
|
||||
EVENT_TYPE_ON = "on"
|
||||
|
||||
CAMERA_TRIGGERS = [
|
||||
EVENT_TYPE_CONNECTION,
|
||||
EVENT_TYPE_DISCONNECTION,
|
||||
EVENT_TYPE_LIGHT_MODE,
|
||||
EVENT_TYPE_OFF,
|
||||
EVENT_TYPE_ON,
|
||||
]
|
||||
|
||||
OUTDOOR_CAMERA_TRIGGERS = [
|
||||
EVENT_TYPE_CAMERA_ANIMAL,
|
||||
EVENT_TYPE_CAMERA_HUMAN,
|
||||
EVENT_TYPE_CAMERA_OUTDOOR,
|
||||
EVENT_TYPE_CAMERA_VEHICLE,
|
||||
EVENT_TYPE_ANIMAL,
|
||||
EVENT_TYPE_HUMAN,
|
||||
EVENT_TYPE_OUTDOOR,
|
||||
EVENT_TYPE_VEHICLE,
|
||||
]
|
||||
INDOOR_CAMERA_TRIGGERS = [
|
||||
EVENT_TYPE_ALARM_STARTED,
|
||||
EVENT_TYPE_CAMERA_MOVEMENT,
|
||||
EVENT_TYPE_CAMERA_PERSON_AWAY,
|
||||
EVENT_TYPE_CAMERA_PERSON,
|
||||
EVENT_TYPE_MOVEMENT,
|
||||
EVENT_TYPE_PERSON_AWAY,
|
||||
EVENT_TYPE_PERSON,
|
||||
]
|
||||
DOOR_TAG_TRIGGERS = [
|
||||
EVENT_TYPE_DOOR_TAG_BIG_MOVE,
|
||||
EVENT_TYPE_DOOR_TAG_OPEN,
|
||||
EVENT_TYPE_DOOR_TAG_SMALL_MOVE,
|
||||
EVENT_TYPE_TAG_BIG_MOVE,
|
||||
EVENT_TYPE_TAG_OPEN,
|
||||
EVENT_TYPE_TAG_SMALL_MOVE,
|
||||
]
|
||||
CLIMATE_TRIGGERS = [
|
||||
EVENT_TYPE_CANCEL_SET_POINT,
|
||||
@@ -157,18 +167,20 @@ CLIMATE_TRIGGERS = [
|
||||
]
|
||||
EVENT_ID_MAP = {
|
||||
EVENT_TYPE_ALARM_STARTED: "device_id",
|
||||
EVENT_TYPE_CAMERA_ANIMAL: "device_id",
|
||||
EVENT_TYPE_CAMERA_HUMAN: "device_id",
|
||||
EVENT_TYPE_CAMERA_MOVEMENT: "device_id",
|
||||
EVENT_TYPE_CAMERA_OUTDOOR: "device_id",
|
||||
EVENT_TYPE_CAMERA_PERSON_AWAY: "device_id",
|
||||
EVENT_TYPE_CAMERA_PERSON: "device_id",
|
||||
EVENT_TYPE_CAMERA_VEHICLE: "device_id",
|
||||
EVENT_TYPE_ANIMAL: "device_id",
|
||||
EVENT_TYPE_HUMAN: "device_id",
|
||||
EVENT_TYPE_MOVEMENT: "device_id",
|
||||
EVENT_TYPE_OUTDOOR: "device_id",
|
||||
EVENT_TYPE_PERSON_AWAY: "device_id",
|
||||
EVENT_TYPE_PERSON: "device_id",
|
||||
EVENT_TYPE_VEHICLE: "device_id",
|
||||
EVENT_TYPE_CANCEL_SET_POINT: "room_id",
|
||||
EVENT_TYPE_DOOR_TAG_BIG_MOVE: "device_id",
|
||||
EVENT_TYPE_DOOR_TAG_OPEN: "device_id",
|
||||
EVENT_TYPE_DOOR_TAG_SMALL_MOVE: "device_id",
|
||||
EVENT_TYPE_TAG_BIG_MOVE: "device_id",
|
||||
EVENT_TYPE_TAG_OPEN: "device_id",
|
||||
EVENT_TYPE_TAG_SMALL_MOVE: "device_id",
|
||||
EVENT_TYPE_LIGHT_MODE: "device_id",
|
||||
EVENT_TYPE_MODULE_CONNECT: "module_id",
|
||||
EVENT_TYPE_MODULE_DISCONNECT: "module_id",
|
||||
EVENT_TYPE_SET_POINT: "room_id",
|
||||
EVENT_TYPE_THERM_MODE: "home_id",
|
||||
}
|
||||
@@ -178,8 +190,12 @@ MODE_LIGHT_OFF = "off"
|
||||
MODE_LIGHT_ON = "on"
|
||||
CAMERA_LIGHT_MODES = [MODE_LIGHT_ON, MODE_LIGHT_OFF, MODE_LIGHT_AUTO]
|
||||
|
||||
# Webhook push_types MUST follow exactly Netatmo's naming on products!
|
||||
# See https://dev.netatmo.com/apidocumentation
|
||||
# e.g. cameras: NACamera, NOC, etc.
|
||||
WEBHOOK_ACTIVATION = "webhook_activation"
|
||||
WEBHOOK_DEACTIVATION = "webhook_deactivation"
|
||||
WEBHOOK_LIGHT_MODE = "NOC-light_mode"
|
||||
WEBHOOK_NACAMERA_CONNECTION = "NACamera-connection"
|
||||
WEBHOOK_NOCAMERA_CONNECTION = "NOC-connection"
|
||||
WEBHOOK_PUSH_TYPE = "push_type"
|
||||
CAMERA_CONNECTION_WEBHOOKS = [WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NOCAMERA_CONNECTION]
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import (
|
||||
AUTH,
|
||||
CAMERA_CONNECTION_WEBHOOKS,
|
||||
DATA_PERSONS,
|
||||
DATA_SCHEDULES,
|
||||
DOMAIN,
|
||||
@@ -48,7 +49,6 @@ from .const import (
|
||||
PLATFORMS,
|
||||
WEBHOOK_ACTIVATION,
|
||||
WEBHOOK_DEACTIVATION,
|
||||
WEBHOOK_NACAMERA_CONNECTION,
|
||||
WEBHOOK_PUSH_TYPE,
|
||||
)
|
||||
|
||||
@@ -223,7 +223,7 @@ class NetatmoDataHandler:
|
||||
_LOGGER.debug("%s webhook unregistered", MANUFACTURER)
|
||||
self._webhook = False
|
||||
|
||||
elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION:
|
||||
elif event["data"][WEBHOOK_PUSH_TYPE] in CAMERA_CONNECTION_WEBHOOKS:
|
||||
_LOGGER.debug("%s camera reconnected", MANUFACTURER)
|
||||
self.async_force_update(ACCOUNT)
|
||||
|
||||
|
||||
@@ -14,14 +14,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
ATTR_EVENT_TYPE,
|
||||
CONF_URL_CONTROL,
|
||||
CONF_URL_SECURITY,
|
||||
DOMAIN,
|
||||
EVENT_TYPE_LIGHT_MODE,
|
||||
NETATMO_CREATE_CAMERA_LIGHT,
|
||||
NETATMO_CREATE_LIGHT,
|
||||
WEBHOOK_LIGHT_MODE,
|
||||
WEBHOOK_PUSH_TYPE,
|
||||
)
|
||||
from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice
|
||||
from .entity import NetatmoModuleEntity
|
||||
@@ -114,7 +113,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity):
|
||||
if (
|
||||
data["home_id"] == self.home.entity_id
|
||||
and data["camera_id"] == self.device.entity_id
|
||||
and data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE
|
||||
and data[ATTR_EVENT_TYPE] == EVENT_TYPE_LIGHT_MODE
|
||||
):
|
||||
self._attr_is_on = bool(data["sub_type"] == "on")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator, Sequence
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -29,8 +28,7 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import config_validation as cv, template
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
@@ -39,6 +37,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.script import Script
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import validators as tcv
|
||||
from .const import DOMAIN
|
||||
from .coordinator import TriggerUpdateCoordinator
|
||||
from .entity import AbstractTemplateEntity
|
||||
@@ -56,19 +55,6 @@ from .template_entity import TemplateEntity
|
||||
from .trigger_entity import TriggerEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_VALID_STATES = [
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelState.ARMING,
|
||||
AlarmControlPanelState.DISARMED,
|
||||
AlarmControlPanelState.DISARMING,
|
||||
AlarmControlPanelState.PENDING,
|
||||
AlarmControlPanelState.TRIGGERED,
|
||||
STATE_UNAVAILABLE,
|
||||
]
|
||||
|
||||
CONF_ALARM_CONTROL_PANELS = "panels"
|
||||
CONF_ARM_AWAY_ACTION = "arm_away"
|
||||
@@ -212,22 +198,22 @@ class AbstractTemplateAlarmControlPanel(
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_optimistic_entity = True
|
||||
|
||||
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
|
||||
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
|
||||
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
|
||||
"""Initialize the features."""
|
||||
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
|
||||
self._attr_code_format = config[CONF_CODE_FORMAT].value
|
||||
# The super init is not called because TemplateEntity calls AbstractTemplateEntity.__init__.
|
||||
def __init__(self, name: str) -> None: # pylint: disable=super-init-not-called
|
||||
"""Setup the templates and scripts."""
|
||||
|
||||
self._attr_code_arm_required: bool = self._config[CONF_CODE_ARM_REQUIRED]
|
||||
self._attr_code_format = self._config[CONF_CODE_FORMAT].value
|
||||
|
||||
self.setup_state_template(
|
||||
CONF_STATE,
|
||||
"_attr_alarm_state",
|
||||
validator=tcv.strenum(self, CONF_STATE, AlarmControlPanelState),
|
||||
)
|
||||
|
||||
self._attr_supported_features: AlarmControlPanelEntityFeature = (
|
||||
AlarmControlPanelEntityFeature(0)
|
||||
)
|
||||
|
||||
def _iterate_scripts(
|
||||
self, config: dict[str, Any]
|
||||
) -> Generator[
|
||||
tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int]
|
||||
]:
|
||||
for action_id, supported_feature in (
|
||||
(CONF_DISARM_ACTION, 0),
|
||||
(CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY),
|
||||
@@ -240,35 +226,21 @@ class AbstractTemplateAlarmControlPanel(
|
||||
),
|
||||
(CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER),
|
||||
):
|
||||
if (action_config := config.get(action_id)) is not None:
|
||||
yield (action_id, action_config, supported_feature)
|
||||
if (action_config := self._config.get(action_id)) is not None:
|
||||
self.add_script(action_id, action_config, name, DOMAIN)
|
||||
self._attr_supported_features |= supported_feature
|
||||
|
||||
async def _async_handle_restored_state(self) -> None:
|
||||
if (
|
||||
(last_state := await self.async_get_last_state()) is not None
|
||||
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
and last_state.state in _VALID_STATES
|
||||
and last_state.state in AlarmControlPanelState
|
||||
# The trigger might have fired already while we waited for stored data,
|
||||
# then we should not restore state
|
||||
and self._attr_alarm_state is None
|
||||
):
|
||||
self._attr_alarm_state = AlarmControlPanelState(last_state.state)
|
||||
|
||||
def _handle_state(self, result: Any) -> None:
|
||||
# Validate state
|
||||
if result in _VALID_STATES:
|
||||
self._attr_alarm_state = result
|
||||
_LOGGER.debug("Valid state - %s", result)
|
||||
return
|
||||
|
||||
_LOGGER.error(
|
||||
"Received invalid alarm panel state: %s for entity %s. Expected: %s",
|
||||
result,
|
||||
self.entity_id,
|
||||
", ".join(_VALID_STATES),
|
||||
)
|
||||
self._attr_alarm_state = None
|
||||
|
||||
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
|
||||
"""Arm the panel to specified state with supplied script."""
|
||||
|
||||
@@ -351,39 +323,17 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
|
||||
) -> None:
|
||||
"""Initialize the panel."""
|
||||
TemplateEntity.__init__(self, hass, config, unique_id)
|
||||
AbstractTemplateAlarmControlPanel.__init__(self, config)
|
||||
name = self._attr_name
|
||||
if TYPE_CHECKING:
|
||||
assert name is not None
|
||||
|
||||
for action_id, action_config, supported_feature in self._iterate_scripts(
|
||||
config
|
||||
):
|
||||
self.add_script(action_id, action_config, name, DOMAIN)
|
||||
self._attr_supported_features |= supported_feature
|
||||
AbstractTemplateAlarmControlPanel.__init__(self, name)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
await self._async_handle_restored_state()
|
||||
|
||||
@callback
|
||||
def _update_state(self, result):
|
||||
if isinstance(result, TemplateError):
|
||||
self._attr_alarm_state = None
|
||||
return
|
||||
|
||||
self._handle_state(result)
|
||||
|
||||
@callback
|
||||
def _async_setup_templates(self) -> None:
|
||||
"""Set up templates."""
|
||||
if self._template:
|
||||
self.add_template_attribute(
|
||||
"_attr_alarm_state", self._template, None, self._update_state
|
||||
)
|
||||
super()._async_setup_templates()
|
||||
|
||||
|
||||
class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel):
|
||||
"""Alarm Control Panel entity based on trigger data."""
|
||||
@@ -398,19 +348,8 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
TriggerEntity.__init__(self, hass, coordinator, config)
|
||||
AbstractTemplateAlarmControlPanel.__init__(self, config)
|
||||
|
||||
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
|
||||
|
||||
if isinstance(config.get(CONF_STATE), template.Template):
|
||||
self._to_render_simple.append(CONF_STATE)
|
||||
self._parse_result.add(CONF_STATE)
|
||||
|
||||
for action_id, action_config, supported_feature in self._iterate_scripts(
|
||||
config
|
||||
):
|
||||
self.add_script(action_id, action_config, name, DOMAIN)
|
||||
self._attr_supported_features |= supported_feature
|
||||
AbstractTemplateAlarmControlPanel.__init__(self, name)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
@@ -426,7 +365,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if (rendered := self._rendered.get(CONF_STATE)) is not None:
|
||||
self._handle_state(rendered)
|
||||
if self.handle_rendered_result(CONF_STATE):
|
||||
self.async_set_context(self.coordinator.data["context"])
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Template entity base class."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -18,6 +19,17 @@ from .const import CONF_DEFAULT_ENTITY_ID
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityTemplate:
|
||||
"""Information class for properly handling template results."""
|
||||
|
||||
attribute: str
|
||||
template: Template
|
||||
validator: Callable[[Any], Any] | None
|
||||
on_update: Callable[[Any], None] | None
|
||||
none_on_template_error: bool
|
||||
|
||||
|
||||
class AbstractTemplateEntity(Entity):
|
||||
"""Actions linked to a template entity."""
|
||||
|
||||
@@ -34,6 +46,8 @@ class AbstractTemplateEntity(Entity):
|
||||
"""Initialize the entity."""
|
||||
|
||||
self.hass = hass
|
||||
self._config = config
|
||||
self._templates: dict[str, EntityTemplate] = {}
|
||||
self._action_scripts: dict[str, Script] = {}
|
||||
|
||||
if self._optimistic_entity:
|
||||
@@ -72,6 +86,35 @@ class AbstractTemplateEntity(Entity):
|
||||
def _render_script_variables(self) -> dict:
|
||||
"""Render configured variables."""
|
||||
|
||||
@abstractmethod
|
||||
def setup_state_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages the main state of the entity."""
|
||||
|
||||
def add_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
none_on_template_error: bool = False,
|
||||
add_if_static: bool = True,
|
||||
) -> Template | None:
|
||||
"""Add a template."""
|
||||
if (template := self._config.get(option)) and isinstance(template, Template):
|
||||
if add_if_static or (not template.is_static):
|
||||
self._templates[option] = EntityTemplate(
|
||||
attribute, template, validator, on_update, none_on_template_error
|
||||
)
|
||||
return template
|
||||
|
||||
return None
|
||||
|
||||
def add_script(
|
||||
self,
|
||||
script_id: str,
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -181,9 +181,6 @@ class TemplateEntity(AbstractTemplateEntity):
|
||||
self._run_variables: ScriptVariables | dict
|
||||
self._attribute_templates = config.get(CONF_ATTRIBUTES)
|
||||
self._availability_template = config.get(CONF_AVAILABILITY)
|
||||
self._icon_template = config.get(CONF_ICON)
|
||||
self._entity_picture_template = config.get(CONF_PICTURE)
|
||||
self._friendly_name_template = config.get(CONF_NAME)
|
||||
self._run_variables = config.get(CONF_VARIABLES, {})
|
||||
self._blueprint_inputs = config.get("raw_blueprint_inputs")
|
||||
|
||||
@@ -208,27 +205,28 @@ class TemplateEntity(AbstractTemplateEntity):
|
||||
)
|
||||
variables = {"this": DummyState(), **variables}
|
||||
|
||||
# Try to render the name as it can influence the entity ID
|
||||
self.add_template(
|
||||
CONF_AVAILABILITY, "_attr_available", on_update=self._update_available
|
||||
)
|
||||
|
||||
# Render name, icon, and picture early. name is rendered early because it influences
|
||||
# the entity_id. icon and picture are rendered early to ensure they are populated even
|
||||
# if the entity renders unavailable.
|
||||
self._attr_name = None
|
||||
if self._friendly_name_template:
|
||||
with contextlib.suppress(TemplateError):
|
||||
self._attr_name = self._friendly_name_template.async_render(
|
||||
variables=variables, parse_result=False
|
||||
)
|
||||
|
||||
# Templates will not render while the entity is unavailable, try to render the
|
||||
# icon and picture templates.
|
||||
if self._entity_picture_template:
|
||||
with contextlib.suppress(TemplateError):
|
||||
self._attr_entity_picture = self._entity_picture_template.async_render(
|
||||
variables=variables, parse_result=False
|
||||
)
|
||||
|
||||
if self._icon_template:
|
||||
with contextlib.suppress(TemplateError):
|
||||
self._attr_icon = self._icon_template.async_render(
|
||||
variables=variables, parse_result=False
|
||||
)
|
||||
for option, attribute, validator in (
|
||||
(CONF_ICON, "_attr_icon", vol.Or(cv.whitespace, cv.icon)),
|
||||
(CONF_PICTURE, "_attr_entity_picture", cv.string),
|
||||
(CONF_NAME, "_attr_name", cv.string),
|
||||
):
|
||||
if template := self.add_template(
|
||||
option, attribute, validator, add_if_static=option != CONF_NAME
|
||||
):
|
||||
with contextlib.suppress(TemplateError):
|
||||
setattr(
|
||||
self,
|
||||
attribute,
|
||||
template.async_render(variables=variables, parse_result=False),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_available(self, result: str | TemplateError) -> None:
|
||||
@@ -278,6 +276,33 @@ class TemplateEntity(AbstractTemplateEntity):
|
||||
},
|
||||
)
|
||||
|
||||
def setup_state_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages the main state of the entity."""
|
||||
|
||||
@callback
|
||||
def _update_state(result: Any) -> None:
|
||||
if isinstance(result, TemplateError):
|
||||
setattr(self, attribute, None)
|
||||
if self._availability_template:
|
||||
return
|
||||
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
state = validator(result) if validator else result
|
||||
if on_update:
|
||||
on_update(state)
|
||||
else:
|
||||
setattr(self, attribute, state)
|
||||
|
||||
self.add_template(option, attribute, on_update=_update_state)
|
||||
|
||||
def add_template_attribute(
|
||||
self,
|
||||
attribute: str,
|
||||
@@ -417,30 +442,20 @@ class TemplateEntity(AbstractTemplateEntity):
|
||||
@callback
|
||||
def _async_setup_templates(self) -> None:
|
||||
"""Set up templates."""
|
||||
if self._availability_template is not None:
|
||||
self.add_template_attribute(
|
||||
"_attr_available",
|
||||
self._availability_template,
|
||||
None,
|
||||
self._update_available,
|
||||
)
|
||||
|
||||
# Handle attributes as a dictionary.
|
||||
if self._attribute_templates is not None:
|
||||
for key, value in self._attribute_templates.items():
|
||||
self._add_attribute_template(key, value)
|
||||
if self._icon_template is not None:
|
||||
|
||||
# Iterate all dynamic templates and add listeners.
|
||||
for entity_template in self._templates.values():
|
||||
self.add_template_attribute(
|
||||
"_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon)
|
||||
)
|
||||
if self._entity_picture_template is not None:
|
||||
self.add_template_attribute(
|
||||
"_attr_entity_picture", self._entity_picture_template, cv.string
|
||||
)
|
||||
if (
|
||||
self._friendly_name_template is not None
|
||||
and not self._friendly_name_template.is_static
|
||||
):
|
||||
self.add_template_attribute(
|
||||
"_attr_name", self._friendly_name_template, cv.string
|
||||
entity_template.attribute,
|
||||
entity_template.template,
|
||||
entity_template.validator,
|
||||
entity_template.on_update,
|
||||
entity_template.none_on_template_error,
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_STATE, CONF_VARIABLES
|
||||
@@ -50,6 +51,19 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
else:
|
||||
self._unique_id = unique_id
|
||||
|
||||
def setup_state_template(
|
||||
self,
|
||||
option: str,
|
||||
attribute: str,
|
||||
validator: Callable[[Any], Any] | None = None,
|
||||
on_update: Callable[[Any], None] | None = None,
|
||||
) -> None:
|
||||
"""Set up a template that manages the main state of the entity."""
|
||||
if self._config.get(option):
|
||||
self._to_render_simple.append(CONF_STATE)
|
||||
self._parse_result.add(CONF_STATE)
|
||||
self.add_template(option, attribute, validator, on_update)
|
||||
|
||||
@property
|
||||
def referenced_blueprint(self) -> str | None:
|
||||
"""Return referenced blueprint or None."""
|
||||
@@ -89,6 +103,23 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
|
||||
self._render_attributes(rendered, variables)
|
||||
self._rendered = rendered
|
||||
|
||||
def handle_rendered_result(self, key: str) -> bool:
|
||||
"""Get a rendered result and return the value."""
|
||||
if (rendered := self._rendered.get(key)) is not None:
|
||||
if (entity_template := self._templates.get(key)) is not None:
|
||||
value = rendered
|
||||
if entity_template.validator:
|
||||
value = entity_template.validator(rendered)
|
||||
|
||||
if entity_template.on_update:
|
||||
entity_template.on_update(value)
|
||||
else:
|
||||
setattr(self, entity_template.attribute, value)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@callback
|
||||
def _process_data(self) -> None:
|
||||
"""Process new data."""
|
||||
|
||||
305
homeassistant/components/template/validators.py
Normal file
305
homeassistant/components/template/validators.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Template config validation methods."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Valid on/off values for booleans. These tuples are pulled
|
||||
# from cv.boolean and are used to produce logger errors for the user.
|
||||
RESULT_ON = ("1", "true", "yes", "on", "enable")
|
||||
RESULT_OFF = ("0", "false", "no", "off", "disable")
|
||||
|
||||
|
||||
def _log_validation_result_error(
|
||||
entity: Entity,
|
||||
attribute: str,
|
||||
value: Any,
|
||||
expected: tuple[str, ...] | str,
|
||||
) -> None:
|
||||
"""Log a template result error."""
|
||||
|
||||
# in some cases, like `preview` entities, the entity_id does not exist.
|
||||
if entity.entity_id is None:
|
||||
message = f"Received invalid {attribute}: {value} for entity {entity.name}, %s"
|
||||
else:
|
||||
message = (
|
||||
f"Received invalid {entity.entity_id.split('.')[0]} {attribute}"
|
||||
f": {value} for entity {entity.entity_id}, %s"
|
||||
)
|
||||
|
||||
_LOGGER.error(
|
||||
message,
|
||||
expected
|
||||
if isinstance(expected, str)
|
||||
else "expected one of " + ", ".join(expected),
|
||||
)
|
||||
|
||||
|
||||
def _check_result_for_none(result: Any, **kwargs: Any) -> bool:
|
||||
"""Checks the result for none, unknown, unavailable."""
|
||||
if result is None:
|
||||
return True
|
||||
|
||||
if kwargs.get("none_on_unknown_unavailable") and isinstance(result, str):
|
||||
return result.lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def strenum[T: StrEnum](
|
||||
entity: Entity,
|
||||
attribute: str,
|
||||
state_enum: type[T],
|
||||
state_on: T | None = None,
|
||||
state_off: T | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Callable[[Any], T | None]:
|
||||
"""Converts the template result to an StrEnum.
|
||||
|
||||
All strings will attempt to convert to the StrEnum
|
||||
If state_on or state_off are provided, boolean values will return the
|
||||
enum that represents each boolean value.
|
||||
Anything that cannot convert will result in None.
|
||||
|
||||
none_on_unknown_unavailable
|
||||
"""
|
||||
|
||||
def convert(result: Any) -> T | None:
|
||||
if _check_result_for_none(result, **kwargs):
|
||||
return None
|
||||
|
||||
if isinstance(result, str):
|
||||
value = result.lower().strip()
|
||||
try:
|
||||
return state_enum(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if state_on or state_off:
|
||||
try:
|
||||
bool_value = cv.boolean(result)
|
||||
if state_on and bool_value:
|
||||
return state_on
|
||||
|
||||
if state_off and not bool_value:
|
||||
return state_off
|
||||
|
||||
except vol.Invalid:
|
||||
pass
|
||||
|
||||
expected = tuple(s.value for s in state_enum)
|
||||
if state_on:
|
||||
expected += RESULT_ON
|
||||
if state_off:
|
||||
expected += RESULT_OFF
|
||||
|
||||
_log_validation_result_error(
|
||||
entity,
|
||||
attribute,
|
||||
result,
|
||||
expected,
|
||||
)
|
||||
return None
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
def boolean(
|
||||
entity: Entity,
|
||||
attribute: str,
|
||||
as_true: tuple[str, ...] | None = None,
|
||||
as_false: tuple[str, ...] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Callable[[Any], bool | None]:
|
||||
"""Convert the result to a boolean.
|
||||
|
||||
True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy
|
||||
False/0/'0'/'false'/'no'/'off'/'disable' are considered falsy
|
||||
Additional values provided by as_true are considered truthy
|
||||
Additional values provided by as_false are considered truthy
|
||||
All other values are None
|
||||
"""
|
||||
|
||||
def convert(result: Any) -> bool | None:
|
||||
if _check_result_for_none(result, **kwargs):
|
||||
return None
|
||||
|
||||
if isinstance(result, bool):
|
||||
return result
|
||||
|
||||
if isinstance(result, str) and (as_true or as_false):
|
||||
value = result.lower().strip()
|
||||
if as_true and value in as_true:
|
||||
return True
|
||||
if as_false and value in as_false:
|
||||
return False
|
||||
|
||||
try:
|
||||
return cv.boolean(result)
|
||||
except vol.Invalid:
|
||||
pass
|
||||
|
||||
items: tuple[str, ...] = RESULT_ON + RESULT_OFF
|
||||
if as_true:
|
||||
items += as_true
|
||||
if as_false:
|
||||
items += as_false
|
||||
|
||||
_log_validation_result_error(entity, attribute, result, items)
|
||||
return None
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
def number(
|
||||
entity: Entity,
|
||||
attribute: str,
|
||||
minimum: float | None = None,
|
||||
maximum: float | None = None,
|
||||
return_type: type[float] | type[int] = float,
|
||||
**kwargs: Any,
|
||||
) -> Callable[[Any], float | int | None]:
|
||||
"""Convert the result to a number (float or int).
|
||||
|
||||
Any value in the range is converted to a float or int
|
||||
All other values are None
|
||||
"""
|
||||
message = "expected a number"
|
||||
if minimum is not None and maximum is not None:
|
||||
message = f"{message} between {minimum:0.1f} and {maximum:0.1f}"
|
||||
elif minimum is not None and maximum is None:
|
||||
message = f"{message} greater than or equal to {minimum:0.1f}"
|
||||
elif minimum is None and maximum is not None:
|
||||
message = f"{message} less than or equal to {maximum:0.1f}"
|
||||
|
||||
def convert(result: Any) -> float | int | None:
|
||||
if _check_result_for_none(result, **kwargs):
|
||||
return None
|
||||
|
||||
if (result_type := type(result)) is bool:
|
||||
_log_validation_result_error(entity, attribute, result, message)
|
||||
return None
|
||||
|
||||
if isinstance(result, (float, int)):
|
||||
value = result
|
||||
if return_type is int and result_type is float:
|
||||
value = int(value)
|
||||
elif return_type is float and result_type is int:
|
||||
value = float(value)
|
||||
else:
|
||||
try:
|
||||
value = vol.Coerce(float)(result)
|
||||
if return_type is int:
|
||||
value = int(value)
|
||||
except vol.Invalid:
|
||||
_log_validation_result_error(entity, attribute, result, message)
|
||||
return None
|
||||
|
||||
if minimum is None and maximum is None:
|
||||
return value
|
||||
|
||||
if (
|
||||
(
|
||||
minimum is not None
|
||||
and maximum is not None
|
||||
and minimum <= value <= maximum
|
||||
)
|
||||
or (minimum is not None and maximum is None and value >= minimum)
|
||||
or (minimum is None and maximum is not None and value <= maximum)
|
||||
):
|
||||
return value
|
||||
|
||||
_log_validation_result_error(entity, attribute, result, message)
|
||||
return None
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
def list_of_strings(
|
||||
entity: Entity,
|
||||
attribute: str,
|
||||
none_on_empty: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Callable[[Any], list[str] | None]:
|
||||
"""Convert the result to a list of strings.
|
||||
|
||||
This ensures the result is a list of strings.
|
||||
All other values that are not lists will result in None.
|
||||
|
||||
none_on_empty will cause the converter to return None when the list is empty.
|
||||
"""
|
||||
|
||||
def convert(result: Any) -> list[str] | None:
|
||||
if _check_result_for_none(result, **kwargs):
|
||||
return None
|
||||
|
||||
if not isinstance(result, list):
|
||||
_log_validation_result_error(
|
||||
entity,
|
||||
attribute,
|
||||
result,
|
||||
"expected a list of strings",
|
||||
)
|
||||
return None
|
||||
|
||||
if none_on_empty and len(result) == 0:
|
||||
return None
|
||||
|
||||
# Ensure the result are strings.
|
||||
return [str(v) for v in result]
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
def item_in_list[T](
|
||||
entity: Entity,
|
||||
attribute: str,
|
||||
items: list[Any] | None,
|
||||
items_attribute: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Callable[[Any], Any | None]:
|
||||
"""Assert the result of the template is an item inside a list.
|
||||
|
||||
Returns the result if the result is inside the list.
|
||||
All results that are not inside the list will return None.
|
||||
"""
|
||||
|
||||
def convert(result: Any) -> Any | None:
|
||||
if _check_result_for_none(result, **kwargs):
|
||||
return None
|
||||
|
||||
# items may be mutable based on another template field. Always
|
||||
# perform this check when the items come from an configured
|
||||
# attribute.
|
||||
if items is None or (len(items) == 0):
|
||||
if items_attribute:
|
||||
_log_validation_result_error(
|
||||
entity,
|
||||
attribute,
|
||||
result,
|
||||
f"{items_attribute} is empty",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
if result not in items:
|
||||
_log_validation_result_error(
|
||||
entity,
|
||||
attribute,
|
||||
result,
|
||||
tuple(str(v) for v in items),
|
||||
)
|
||||
return None
|
||||
|
||||
return result
|
||||
|
||||
return convert
|
||||
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
|
||||
|
||||
@@ -27,7 +27,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DATA_BATTERY_CAPACITY, DOMAIN
|
||||
|
||||
VERY_SLOW_INTERVAL = 60
|
||||
VERY_SLOW_INTERVAL = 30
|
||||
SLOW_INTERVAL = 15
|
||||
MEDIUM_INTERVAL = 2
|
||||
FAST_INTERVAL = 1
|
||||
@@ -214,11 +214,8 @@ class VolvoVerySlowIntervalCoordinator(VolvoBaseCoordinator):
|
||||
api = self.context.api
|
||||
|
||||
return [
|
||||
api.async_get_brakes_status,
|
||||
api.async_get_command_accessibility,
|
||||
api.async_get_diagnostics,
|
||||
api.async_get_engine_warnings,
|
||||
api.async_get_odometer,
|
||||
api.async_get_statistics,
|
||||
api.async_get_tyre_states,
|
||||
api.async_get_warnings,
|
||||
]
|
||||
@@ -260,16 +257,17 @@ class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator):
|
||||
self,
|
||||
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
|
||||
api = self.context.api
|
||||
api_calls: list[Any] = [api.async_get_command_accessibility]
|
||||
api_calls: list[Any] = [
|
||||
api.async_get_brakes_status,
|
||||
api.async_get_engine_warnings,
|
||||
api.async_get_odometer,
|
||||
]
|
||||
|
||||
location = await api.async_get_location()
|
||||
|
||||
if location.get("location") is not None:
|
||||
api_calls.append(api.async_get_location)
|
||||
|
||||
if self.context.vehicle.has_combustion_engine():
|
||||
api_calls.append(api.async_get_fuel_status)
|
||||
|
||||
return api_calls
|
||||
|
||||
|
||||
@@ -299,7 +297,10 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
|
||||
) -> list[Callable[[], Coroutine[Any, Any, Any]]]:
|
||||
api = self.context.api
|
||||
vehicle = self.context.vehicle
|
||||
api_calls: list[Any] = [api.async_get_engine_status]
|
||||
api_calls: list[Any] = [
|
||||
api.async_get_engine_status,
|
||||
api.async_get_statistics,
|
||||
]
|
||||
|
||||
if vehicle.has_battery_engine():
|
||||
capabilities = await api.async_get_energy_capabilities()
|
||||
@@ -317,6 +318,9 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
|
||||
|
||||
api_calls.append(self._async_get_energy_state)
|
||||
|
||||
if self.context.vehicle.has_combustion_engine():
|
||||
api_calls.append(api.async_get_fuel_status)
|
||||
|
||||
return api_calls
|
||||
|
||||
async def _async_get_energy_state(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -418,6 +418,88 @@ class BooleanSelector(Selector[BooleanSelectorConfig]):
|
||||
return value
|
||||
|
||||
|
||||
def reject_nested_choose_selector(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Reject nested choose selectors."""
|
||||
for choice in config.get("choices", {}).values():
|
||||
if isinstance(choice["selector"], dict):
|
||||
selector_type, _ = _get_selector_type_and_class(choice["selector"])
|
||||
if selector_type == "choose":
|
||||
raise vol.Invalid("Nested choose selectors are not allowed")
|
||||
return config
|
||||
|
||||
|
||||
class ChooseSelectorChoiceConfig(TypedDict, total=False):
|
||||
"""Class to represent a choose selector choice config."""
|
||||
|
||||
selector: Required[Selector | dict[str, Any]]
|
||||
|
||||
|
||||
class ChooseSelectorConfig(BaseSelectorConfig):
|
||||
"""Class to represent a choose selector config."""
|
||||
|
||||
choices: Required[dict[str, ChooseSelectorChoiceConfig]]
|
||||
translation_key: str
|
||||
|
||||
|
||||
@SELECTORS.register("choose")
|
||||
class ChooseSelector(Selector[ChooseSelectorConfig]):
|
||||
"""Selector allowing to choose one of several selectors."""
|
||||
|
||||
selector_type = "choose"
|
||||
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
make_selector_config_schema(
|
||||
{
|
||||
vol.Required("choices"): {
|
||||
str: {
|
||||
vol.Required("selector"): vol.Any(Selector, validate_selector),
|
||||
}
|
||||
},
|
||||
vol.Optional("translation_key"): cv.string,
|
||||
},
|
||||
),
|
||||
reject_nested_choose_selector,
|
||||
)
|
||||
|
||||
def __init__(self, config: ChooseSelectorConfig | None = None) -> None:
|
||||
"""Instantiate a selector."""
|
||||
super().__init__(config)
|
||||
|
||||
def serialize(self) -> dict[str, dict[str, ChooseSelectorConfig]]:
|
||||
"""Serialize ChooseSelectorConfig for voluptuous_serialize."""
|
||||
_config = deepcopy(self.config)
|
||||
if "choices" in _config:
|
||||
for choice in _config["choices"].values():
|
||||
if isinstance(choice["selector"], Selector):
|
||||
choice["selector"] = choice["selector"].serialize()["selector"]
|
||||
return {"selector": {self.selector_type: _config}}
|
||||
|
||||
def __call__(self, data: Any) -> Any:
|
||||
"""Validate the passed selection."""
|
||||
if not isinstance(data, dict):
|
||||
for choice in self.config["choices"].values():
|
||||
try:
|
||||
validated = selector(choice["selector"])(data) # type: ignore[operator]
|
||||
except (vol.Invalid, vol.MultipleInvalid):
|
||||
continue
|
||||
else:
|
||||
return validated
|
||||
|
||||
raise vol.Invalid("Value does not match any choice selector")
|
||||
|
||||
if "active_choice" not in data:
|
||||
raise vol.Invalid("Missing active_choice key")
|
||||
if data["active_choice"] not in data:
|
||||
raise vol.Invalid("Missing value for active choice")
|
||||
|
||||
choices = self.config.get("choices", {})
|
||||
if data["active_choice"] not in choices:
|
||||
raise vol.Invalid("Invalid active_choice key")
|
||||
return selector(choices[data["active_choice"]]["selector"])( # type: ignore[operator]
|
||||
data[data["active_choice"]]
|
||||
)
|
||||
|
||||
|
||||
class ColorRGBSelectorConfig(BaseSelectorConfig):
|
||||
"""Class to represent a color RGB selector config."""
|
||||
|
||||
@@ -1223,14 +1305,10 @@ class ObjectSelector(Selector[ObjectSelectorConfig]):
|
||||
_config = deepcopy(self.config)
|
||||
if "fields" in _config:
|
||||
for field_items in _config["fields"].values():
|
||||
if isinstance(field_items["selector"], ObjectSelector):
|
||||
field_items["selector"] = field_items["selector"].serialize()
|
||||
elif isinstance(field_items["selector"], Selector):
|
||||
field_items["selector"] = {
|
||||
field_items["selector"].selector_type: field_items[
|
||||
"selector"
|
||||
].config
|
||||
}
|
||||
if isinstance(field_items["selector"], Selector):
|
||||
field_items["selector"] = field_items["selector"].serialize()[
|
||||
"selector"
|
||||
]
|
||||
return {"selector": {self.selector_type: _config}}
|
||||
|
||||
def __call__(self, data: Any) -> Any:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,12 +14,7 @@ from packaging.requirements import Requirement
|
||||
from .core import HomeAssistant, callback
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import singleton
|
||||
from .loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
async_get_integration,
|
||||
async_suggest_report_issue,
|
||||
)
|
||||
from .loader import Integration, IntegrationNotFound, async_get_integration
|
||||
from .util import package as pkg_util
|
||||
|
||||
# The default is too low when the internet connection is satellite or high latency
|
||||
@@ -33,10 +28,6 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = {
|
||||
"ssdp": ("ssdp",),
|
||||
"zeroconf": ("zeroconf", "homekit"),
|
||||
}
|
||||
DEPRECATED_PACKAGES: dict[str, tuple[str, str]] = {
|
||||
# old_package_name: (reason, breaks_in_ha_version)
|
||||
"pyserial-asyncio": ("should be replaced by pyserial-asyncio-fast", "2026.7"),
|
||||
}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -64,16 +55,14 @@ async def async_get_integration_with_requirements(
|
||||
|
||||
|
||||
async def async_process_requirements(
|
||||
hass: HomeAssistant, name: str, requirements: list[str], is_built_in: bool = True
|
||||
hass: HomeAssistant, name: str, requirements: list[str]
|
||||
) -> None:
|
||||
"""Install the requirements for a component or platform.
|
||||
|
||||
This method is a coroutine. It will raise RequirementsNotFound
|
||||
if an requirement can't be satisfied.
|
||||
"""
|
||||
await _async_get_manager(hass).async_process_requirements(
|
||||
name, requirements, is_built_in
|
||||
)
|
||||
await _async_get_manager(hass).async_process_requirements(name, requirements)
|
||||
|
||||
|
||||
async def async_load_installed_versions(
|
||||
@@ -191,7 +180,7 @@ class RequirementsManager:
|
||||
"""Process an integration and requirements."""
|
||||
if integration.requirements:
|
||||
await self.async_process_requirements(
|
||||
integration.domain, integration.requirements, integration.is_built_in
|
||||
integration.domain, integration.requirements
|
||||
)
|
||||
|
||||
cache = self.integrations_with_reqs
|
||||
@@ -251,46 +240,24 @@ class RequirementsManager:
|
||||
raise exceptions[0]
|
||||
|
||||
async def async_process_requirements(
|
||||
self, name: str, requirements: list[str], is_built_in: bool
|
||||
self, name: str, requirements: list[str]
|
||||
) -> None:
|
||||
"""Install the requirements for a component or platform.
|
||||
|
||||
This method is a coroutine. It will raise RequirementsNotFound
|
||||
if an requirement can't be satisfied.
|
||||
"""
|
||||
if DEPRECATED_PACKAGES or self.hass.config.skip_pip_packages:
|
||||
all_requirements = {
|
||||
requirement_string: Requirement(requirement_string)
|
||||
for requirement_string in requirements
|
||||
if self.hass.config.skip_pip_packages:
|
||||
skipped_requirements = {
|
||||
req
|
||||
for req in requirements
|
||||
if Requirement(req).name in self.hass.config.skip_pip_packages
|
||||
}
|
||||
if DEPRECATED_PACKAGES:
|
||||
for requirement_string, requirement_details in all_requirements.items():
|
||||
if deprecation := DEPRECATED_PACKAGES.get(requirement_details.name):
|
||||
reason, breaks_in_ha_version = deprecation
|
||||
_LOGGER.warning(
|
||||
"Detected that %sintegration '%s' %s. %s %s",
|
||||
"" if is_built_in else "custom ",
|
||||
name,
|
||||
f"has requirement '{requirement_string}' which {reason}",
|
||||
f"This will stop working in Home Assistant {breaks_in_ha_version}, please"
|
||||
if breaks_in_ha_version
|
||||
else "Please",
|
||||
async_suggest_report_issue(
|
||||
self.hass, integration_domain=name
|
||||
),
|
||||
)
|
||||
if skip_pip_packages := self.hass.config.skip_pip_packages:
|
||||
skipped_requirements: set[str] = set()
|
||||
for requirement_string, requirement_details in all_requirements.items():
|
||||
if requirement_details.name in skip_pip_packages:
|
||||
_LOGGER.warning(
|
||||
"Skipping requirement %s. This may cause issues",
|
||||
requirement_string,
|
||||
)
|
||||
skipped_requirements.add(requirement_string)
|
||||
requirements = [
|
||||
r for r in requirements if r not in skipped_requirements
|
||||
]
|
||||
|
||||
for req in skipped_requirements:
|
||||
_LOGGER.warning("Skipping requirement %s. This may cause issues", req)
|
||||
|
||||
requirements = [r for r in requirements if r not in skipped_requirements]
|
||||
|
||||
if not (missing := self._find_missing_requirements(requirements)):
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
@@ -340,6 +340,10 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
|
||||
),
|
||||
vol.Optional("selector"): cv.schema_with_slug_keys(
|
||||
{
|
||||
vol.Optional("choices"): cv.schema_with_slug_keys(
|
||||
translation_value_validator,
|
||||
slug_validator=translation_key_validator,
|
||||
),
|
||||
vol.Optional("options"): cv.schema_with_slug_keys(
|
||||
translation_value_validator,
|
||||
slug_validator=translation_key_validator,
|
||||
|
||||
@@ -577,6 +577,26 @@ async def test_cannot_deactive_owner(mock_hass) -> None:
|
||||
await manager.async_deactivate_user(owner)
|
||||
|
||||
|
||||
async def test_deactivate_user_removes_refresh_tokens(hass: HomeAssistant) -> None:
|
||||
"""Test that deactivating a user removes their refresh tokens."""
|
||||
manager = await auth.auth_manager_from_config(hass, [], [])
|
||||
user = MockUser().add_to_auth_manager(manager)
|
||||
|
||||
refresh_token1 = await manager.async_create_refresh_token(user, CLIENT_ID)
|
||||
refresh_token2 = await manager.async_create_refresh_token(user, "other-client")
|
||||
assert len(user.refresh_tokens) == 2
|
||||
assert manager.async_get_refresh_token(refresh_token1.id) == refresh_token1
|
||||
assert manager.async_get_refresh_token(refresh_token2.id) == refresh_token2
|
||||
|
||||
await manager.async_deactivate_user(user)
|
||||
|
||||
# Verify user is deactivated and all refresh tokens are removed
|
||||
assert user.is_active is False
|
||||
assert len(user.refresh_tokens) == 0
|
||||
assert manager.async_get_refresh_token(refresh_token1.id) is None
|
||||
assert manager.async_get_refresh_token(refresh_token2.id) is None
|
||||
|
||||
|
||||
async def test_remove_refresh_token(hass: HomeAssistant) -> None:
|
||||
"""Test that we can remove a refresh token."""
|
||||
manager = await auth.auth_manager_from_config(hass, [], [])
|
||||
|
||||
@@ -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,9 +538,10 @@ 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 any climate state changes to a specific state."""
|
||||
"""Test that the climate state trigger fires when the first climate state changes to a specific state."""
|
||||
other_entity_ids = set(target_climates) - {entity_id}
|
||||
|
||||
# Set all climates, including the tested climate, to the initial 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,9 +678,10 @@ 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 any climate state changes to a specific state."""
|
||||
"""Test that the climate state trigger fires when the last climate state changes to a specific state."""
|
||||
other_entity_ids = set(target_climates) - {entity_id}
|
||||
|
||||
# Set all climates, including the tested climate, to the initial 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"]
|
||||
|
||||
@@ -349,8 +349,43 @@ async def test_subentry_flow_location_already_configured(
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "location_already_configured"
|
||||
|
||||
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert len(entry.subentries) == 1
|
||||
|
||||
|
||||
async def test_subentry_flow_location_name_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api: AsyncMock,
|
||||
) -> None:
|
||||
"""Test user input for a location name that already exists."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "location"),
|
||||
context={"source": "user"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "location"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_NAME: "Home",
|
||||
CONF_LOCATION: {
|
||||
CONF_LATITUDE: 30.1,
|
||||
CONF_LONGITUDE: 40.1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "location_name_already_configured"
|
||||
|
||||
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert len(entry.subentries) == 1
|
||||
|
||||
@@ -13,19 +13,14 @@ from homeassistant.components.growatt_server.const import (
|
||||
AUTH_PASSWORD,
|
||||
CONF_AUTH_TYPE,
|
||||
CONF_PLANT_ID,
|
||||
CONF_REGION,
|
||||
DEFAULT_URL,
|
||||
DOMAIN,
|
||||
ERROR_CANNOT_CONNECT,
|
||||
ERROR_INVALID_AUTH,
|
||||
LOGIN_INVALID_AUTH_CODE,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -34,11 +29,12 @@ from tests.common import MockConfigEntry
|
||||
FIXTURE_USER_INPUT_PASSWORD = {
|
||||
CONF_USERNAME: "username",
|
||||
CONF_PASSWORD: "password",
|
||||
CONF_URL: DEFAULT_URL,
|
||||
CONF_REGION: DEFAULT_URL,
|
||||
}
|
||||
|
||||
FIXTURE_USER_INPUT_TOKEN = {
|
||||
CONF_TOKEN: "test_api_token_12345",
|
||||
CONF_REGION: DEFAULT_URL,
|
||||
}
|
||||
|
||||
GROWATT_PLANT_LIST_RESPONSE = {
|
||||
@@ -109,8 +105,8 @@ async def test_show_auth_menu(hass: HomeAssistant) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("auth_type", "expected_fields"),
|
||||
[
|
||||
("password_auth", [CONF_USERNAME, CONF_PASSWORD, CONF_URL]),
|
||||
("token_auth", [CONF_TOKEN]),
|
||||
("password_auth", [CONF_USERNAME, CONF_PASSWORD, CONF_REGION]),
|
||||
("token_auth", [CONF_TOKEN, CONF_REGION]),
|
||||
],
|
||||
)
|
||||
async def test_auth_form_display(
|
||||
|
||||
398
tests/components/humidifier/test_trigger.py
Normal file
398
tests/components/humidifier/test_trigger.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""Test humidifier trigger."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction
|
||||
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_humidifiers(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple humidifier entities associated with different targets."""
|
||||
return (await target_entities(hass, "humidifier"))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"humidifier.started_drying",
|
||||
"humidifier.started_humidifying",
|
||||
"humidifier.turned_off",
|
||||
"humidifier.turned_on",
|
||||
],
|
||||
)
|
||||
async def test_humidifier_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the humidifier 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("humidifier"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.turned_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.turned_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_humidifier_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_humidifiers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the humidifier state trigger fires when any humidifier state changes to a specific state."""
|
||||
other_entity_ids = set(target_humidifiers) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers:
|
||||
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 humidifiers 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("humidifier"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})],
|
||||
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_humidifying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
|
||||
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_humidifier_state_attribute_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_humidifiers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the humidifier state trigger fires when any humidifier state changes to a specific state."""
|
||||
other_entity_ids = set(target_humidifiers) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers:
|
||||
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 humidifiers 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("humidifier"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.turned_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.turned_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_humidifier_state_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_humidifiers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the humidifier state trigger fires when the first humidifier changes to a specific state."""
|
||||
other_entity_ids = set(target_humidifiers) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers:
|
||||
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 humidifiers 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("humidifier"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})],
|
||||
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_humidifying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
|
||||
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_humidifier_state_attribute_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_humidifiers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[tuple[tuple[str, dict], int]],
|
||||
) -> None:
|
||||
"""Test that the humidifier state trigger fires when the first humidifier state changes to a specific state."""
|
||||
other_entity_ids = set(target_humidifiers) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers:
|
||||
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 humidifiers 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("humidifier"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.turned_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.turned_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_humidifier_state_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_humidifiers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[StateDescription],
|
||||
) -> None:
|
||||
"""Test that the humidifier state trigger fires when the last humidifier changes to a specific state."""
|
||||
other_entity_ids = set(target_humidifiers) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers:
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("humidifier"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})],
|
||||
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_humidifying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.HUMIDIFYING})],
|
||||
other_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.IDLE})],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_humidifier_state_attribute_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_humidifiers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[tuple[tuple[str, dict], int]],
|
||||
) -> None:
|
||||
"""Test that the humidifier state trigger fires when the last humidifier state changes to a specific state."""
|
||||
other_entity_ids = set(target_humidifiers) - {entity_id}
|
||||
|
||||
# Set all humidifiers, including the tested humidifier, to the initial state
|
||||
for eid in target_humidifiers:
|
||||
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/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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1037,10 +1037,32 @@
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': list([
|
||||
dict({
|
||||
'name': 'ga_switch',
|
||||
'optional': True,
|
||||
'options': dict({
|
||||
'passive': True,
|
||||
'state': dict({
|
||||
'required': False,
|
||||
}),
|
||||
'validDPTs': list([
|
||||
dict({
|
||||
'main': 1,
|
||||
'sub': None,
|
||||
}),
|
||||
]),
|
||||
'write': dict({
|
||||
'required': True,
|
||||
}),
|
||||
}),
|
||||
'required': False,
|
||||
'type': 'knx_group_address',
|
||||
}),
|
||||
dict({
|
||||
'collapsible': False,
|
||||
'name': 'speed',
|
||||
'required': True,
|
||||
'optional': True,
|
||||
'required': False,
|
||||
'schema': list([
|
||||
dict({
|
||||
'schema': list([
|
||||
@@ -1554,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,
|
||||
|
||||
@@ -109,6 +109,72 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
await knx.assert_telegram_count(0)
|
||||
|
||||
|
||||
async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
"""Test KNX fan with switch only."""
|
||||
await knx.setup_integration(
|
||||
{
|
||||
FanSchema.PLATFORM: {
|
||||
CONF_NAME: "test",
|
||||
FanSchema.CONF_SWITCH_ADDRESS: "1/2/3",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# turn on fan
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_on", {"entity_id": "fan.test"}, blocking=True
|
||||
)
|
||||
await knx.assert_write("1/2/3", True)
|
||||
|
||||
# turn off fan
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_off", {"entity_id": "fan.test"}, blocking=True
|
||||
)
|
||||
await knx.assert_write("1/2/3", False)
|
||||
|
||||
|
||||
async def test_fan_switch_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
"""Test KNX fan with speed steps and switch address."""
|
||||
await knx.setup_integration(
|
||||
{
|
||||
FanSchema.PLATFORM: {
|
||||
CONF_NAME: "test",
|
||||
KNX_ADDRESS: "1/1/1",
|
||||
FanSchema.CONF_SWITCH_ADDRESS: "2/2/2",
|
||||
FanConf.MAX_STEP: 4,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# turn on fan without percentage - actuator sets default speed
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_on", {"entity_id": "fan.test"}, blocking=True
|
||||
)
|
||||
await knx.assert_write("2/2/2", True)
|
||||
|
||||
# turn on with speed 75% - step 3 - turn_on sends switch ON again
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_on", {"entity_id": "fan.test", "percentage": 75}, blocking=True
|
||||
)
|
||||
await knx.assert_write("2/2/2", True)
|
||||
await knx.assert_write("1/1/1", (3,))
|
||||
|
||||
# set speed to 25% - step 1 - set_percentage doesn't send switch ON
|
||||
await hass.services.async_call(
|
||||
"fan",
|
||||
"set_percentage",
|
||||
{"entity_id": "fan.test", "percentage": 25},
|
||||
blocking=True,
|
||||
)
|
||||
await knx.assert_write("1/1/1", (1,))
|
||||
|
||||
# turn off fan - no percentage change sent
|
||||
await hass.services.async_call(
|
||||
"fan", "turn_off", {"entity_id": "fan.test"}, blocking=True
|
||||
)
|
||||
await knx.assert_write("2/2/2", False)
|
||||
|
||||
|
||||
async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
"""Test KNX fan oscillation."""
|
||||
await knx.setup_integration(
|
||||
@@ -153,7 +219,7 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("knx_data", "expected_read_response", "expected_state"),
|
||||
[
|
||||
(
|
||||
( # percent mode fan with oscillation
|
||||
{
|
||||
"speed": {
|
||||
"ga_speed": {"write": "1/1/0", "state": "1/1/1"},
|
||||
@@ -164,7 +230,7 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
[("1/1/1", (0x55,)), ("2/2/2", True)],
|
||||
{"state": STATE_ON, "percentage": 33, "oscillating": True},
|
||||
),
|
||||
(
|
||||
( # step only fan
|
||||
{
|
||||
"speed": {
|
||||
"ga_step": {"write": "1/1/0", "state": "1/1/1"},
|
||||
@@ -175,6 +241,14 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
[("1/1/1", (2,))],
|
||||
{"state": STATE_ON, "percentage": 66},
|
||||
),
|
||||
( # switch only fan
|
||||
{
|
||||
"ga_switch": {"write": "1/1/0", "state": "1/1/1"},
|
||||
"sync_state": True,
|
||||
},
|
||||
[("1/1/1", True)],
|
||||
{"state": STATE_ON, "percentage": None},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_fan_ui_create(
|
||||
|
||||
@@ -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()
|
||||
@@ -3,11 +3,13 @@
|
||||
from typing import Final
|
||||
|
||||
from lunatone_rest_api_client.models import (
|
||||
DALIBusData,
|
||||
DeviceData,
|
||||
DeviceInfoData,
|
||||
DevicesData,
|
||||
FeaturesStatus,
|
||||
InfoData,
|
||||
LineStatus,
|
||||
)
|
||||
from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status
|
||||
from lunatone_rest_api_client.models.devices import DeviceStatus
|
||||
@@ -54,17 +56,43 @@ DEVICE_DATA_LIST: Final[list[DeviceData]] = [
|
||||
),
|
||||
]
|
||||
DEVICES_DATA: Final[DevicesData] = DevicesData(devices=DEVICE_DATA_LIST)
|
||||
DEVICE_INFO_DATA: Final[DeviceInfoData] = DeviceInfoData(
|
||||
serial=SERIAL_NUMBER,
|
||||
gtin=192837465,
|
||||
pcb="2a",
|
||||
articleNumber=87654321,
|
||||
productionYear=20,
|
||||
productionWeek=1,
|
||||
)
|
||||
INFO_DATA: Final[InfoData] = InfoData(
|
||||
name="Test",
|
||||
version=VERSION,
|
||||
device=DeviceInfoData(
|
||||
serial=SERIAL_NUMBER,
|
||||
gtin=192837465,
|
||||
pcb="2a",
|
||||
articleNumber=87654321,
|
||||
productionYear=20,
|
||||
productionWeek=1,
|
||||
),
|
||||
device=DEVICE_INFO_DATA,
|
||||
lines={
|
||||
"0": DALIBusData(
|
||||
sendBlockedInitialize=False,
|
||||
sendBlockedQuiescent=False,
|
||||
sendBlockedMacroRunning=False,
|
||||
sendBufferFull=False,
|
||||
lineStatus=LineStatus.OK,
|
||||
device=DEVICE_INFO_DATA,
|
||||
),
|
||||
"1": DALIBusData(
|
||||
sendBlockedInitialize=False,
|
||||
sendBlockedQuiescent=False,
|
||||
sendBlockedMacroRunning=False,
|
||||
sendBufferFull=False,
|
||||
lineStatus=LineStatus.OK,
|
||||
device=DeviceInfoData(
|
||||
serial=54321,
|
||||
gtin=101010101,
|
||||
pcb="1a",
|
||||
articleNumber=12345678,
|
||||
productionYear=22,
|
||||
productionWeek=10,
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ def mock_lunatone_devices() -> Generator[AsyncMock]:
|
||||
|
||||
def build_devices_mock(devices: Devices):
|
||||
device_list = []
|
||||
if devices.data is None:
|
||||
return device_list
|
||||
for device_data in devices.data.devices:
|
||||
device = AsyncMock(spec=Device)
|
||||
device.data = device_data
|
||||
@@ -78,6 +80,18 @@ def mock_lunatone_info() -> Generator[AsyncMock]:
|
||||
yield info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_lunatone_dali_broadcast() -> Generator[AsyncMock]:
|
||||
"""Mock a Lunatone DALI broadcast object."""
|
||||
with patch(
|
||||
"homeassistant.components.lunatone.DALIBroadcast",
|
||||
autospec=True,
|
||||
) as mock_dali_broadcast:
|
||||
dali_broadcast = mock_dali_broadcast.return_value
|
||||
dali_broadcast.line = 0
|
||||
yield dali_broadcast
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
|
||||
@@ -113,3 +113,119 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[light.lunatone_12345_line0-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.lunatone_12345_line0',
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'lunatone',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '12345-line0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[light.lunatone_12345_line0-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'assumed_state': True,
|
||||
'brightness': None,
|
||||
'color_mode': None,
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.lunatone_12345_line0',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[light.lunatone_12345_line1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': None,
|
||||
'entity_id': 'light.lunatone_12345_line1',
|
||||
'has_entity_name': False,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'lunatone',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '12345-line1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_setup[light.lunatone_12345_line1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'assumed_state': True,
|
||||
'brightness': None,
|
||||
'color_mode': None,
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.BRIGHTNESS: 'brightness'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.lunatone_12345_line1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user