Compare commits

..

33 Commits

Author SHA1 Message Date
Michael
f9ec003124 Merge branch 'dev' into input_boolean/add-domain-driven-triggers 2025-12-20 10:00:21 +01:00
Matthias Alphart
0db9dcfd1c Fix knx translation typos (#159486) 2025-12-20 09:53:45 +01:00
J. Nick Koston
5b5850224a Bump yalexs-ble to 3.2.4 (#159476) 2025-12-19 14:05:07 -10:00
Erik Montnemery
065b0eb5b2 Fix siren entity triggers (#159474) 2025-12-19 22:45:32 +01:00
Michael
6a1d86d5db Add domain driven triggers to lock platform (#159327)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:34:33 +01:00
Petro31
f99a73ef28 Modernize template weather platform and add config flow (#156399) 2025-12-19 22:28:26 +01:00
Michael
0436d30062 Add turned off and turned on triggers to siren platform (#158847)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:15:06 +01:00
Erik Montnemery
24b6b5452b Add trigger climate.target_temperature_crossed_threshold (#159461)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-19 21:57:10 +01:00
Erik Montnemery
8b91ebfe30 Add test of error handling in numerical_attribute_changed triggers (#159469) 2025-12-19 21:40:56 +01:00
Matthias Alphart
37d3b73c1b Support KNX sensor entity configuration from UI (#158498) 2025-12-19 19:20:14 +01:00
Matthias Alphart
c881d9809e Update knx-frontend to 2025.12.19.150946 (#159446) 2025-12-19 19:09:19 +01:00
Erik Montnemery
85dfe3a107 Add trigger climate.target_temperature_changed (#159434) 2025-12-19 18:39:53 +01:00
Pierre PÉRONNET
d8a468833e Bump renault-api to 0.5.2 (#159448) 2025-12-19 18:25:46 +01:00
Raphael Hehl
5bbd56b8e6 Add exception handling to UniFi Protect entity commands (#159292)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-19 07:10:32 -10:00
Bram Kragten
d0411b6613 Update frontend to 20251203.3 (#159451) 2025-12-19 17:57:27 +01:00
Abílio Costa
293fbebef2 Modernize calendar trigger (#159395)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-12-19 17:41:30 +01:00
Erik Montnemery
cfe542acb9 Fix hassfest support for choose selector translations (#159453) 2025-12-19 17:31:57 +01:00
Bram Kragten
8da323d4b7 Add support for choose selector (#159412) 2025-12-19 16:49:04 +01:00
Zoltán Farkasdi
b2edf637cc Netatmo camera webhook refactor (#159359) 2025-12-19 16:41:22 +01:00
Erik Montnemery
de61a45de1 Add humidifier triggers (#159163) 2025-12-19 16:38:30 +01:00
Erik Montnemery
d9324cb0e4 Improve docstrings in climate trigger tests (#159438) 2025-12-19 16:10:07 +01:00
Robert Resch
4a464f601c Remove users refresh tokens when the user get's deactivated (#159443) 2025-12-19 15:50:47 +01:00
Thomas D
43e9c24c18 Adjust volvo update interval (#159200) 2025-12-19 15:38:50 +01:00
Matthias Alphart
1c3492b4c2 KNX Fan: Add support for switch addresses (#159367) 2025-12-19 15:37:50 +01:00
johanzander
e0cb56a38c Improve Growatt Server config flow with region dropdown (#159329)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-19 15:33:35 +01:00
Erik Montnemery
6e05cc4898 Enable multiple states in trigger climate.hvac_mode_changed (#159435) 2025-12-19 15:14:55 +01:00
MoonDevLT
6f9dc2e5a2 Add a DALI line into the device hierarchy with a broadcast entity (#156570)
Co-authored-by: Tom <CoMPaTech@users.noreply.github.com>
2025-12-19 14:57:51 +01:00
Petro31
ddb1ae371d Add new template entity framework to template alarm control panel (#156614) 2025-12-19 14:41:45 +01:00
J. Diego Rodríguez Royo
6553337b79 Add entities related to the new data from aiohomeconnect 0.22.0 (#154717) 2025-12-19 14:33:28 +01:00
Thomas55555
aedc729d57 Only allow unique location names in google air quality (#159285) 2025-12-19 14:33:16 +01:00
mib1185
ee0230f3b1 use renamed helpers 2025-12-17 20:06:03 +00:00
mib1185
851fd467fe Merge branch 'dev' into input_boolean/add-domain-driven-triggers 2025-12-17 20:05:20 +00:00
mib1185
d10148a175 add turned_off and turned_on triggers 2025-12-12 20:53:03 +00:00
116 changed files with 7915 additions and 1168 deletions

View File

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

View File

@@ -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"]
}

View File

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

View File

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

View File

@@ -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"
},

View File

@@ -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": {

View File

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

View File

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

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251203.2"]
"requirements": ["home-assistant-frontend==20251203.3"]
}

View File

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

View File

@@ -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": {

View File

@@ -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",
)
),
}
)

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

@@ -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),
)
}

View File

@@ -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"
},

View File

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

View File

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

View File

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

View File

@@ -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",
),
)

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View 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

View 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

View File

@@ -168,6 +168,7 @@ SUPPORTED_PLATFORMS_UI: Final = {
Platform.FAN,
Platform.DATETIME,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
}

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

View File

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

View File

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

View File

@@ -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."
),
),
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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),
},

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]
}

View File

@@ -14,5 +14,13 @@
"turn_on": {
"service": "mdi:bullhorn"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:bullhorn-outline"
},
"turned_on": {
"trigger": "mdi:bullhorn"
}
}
}

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

@@ -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%]"
}
}
},

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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, [], [])

View File

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

View File

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

View File

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

View File

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

View 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()

View 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()

View 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
}
}
}
}
}
}

View File

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

View File

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

View File

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

View 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()

View File

@@ -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,
),
),
},
)

View File

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

View File

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