Compare commits

..

1 Commits

Author SHA1 Message Date
G Johansson 7411e54b2c Remove deprecated use of incorrect UoM with device class in mqtt sensor 2025-06-17 16:37:47 +00:00
34 changed files with 64 additions and 987 deletions
-2
View File
@@ -89,7 +89,6 @@ from .helpers import (
restore_state,
template,
translation,
trigger,
)
from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager
@@ -453,7 +452,6 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(trigger.async_setup(hass)),
)
@@ -105,6 +105,11 @@ DEFAULT_MAX_HUMIDITY = 99
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH]
# Can be removed in 2025.1 after deprecation period of the new feature flags
CHECK_TURN_ON_OFF_FEATURE_FLAG = (
ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
)
SET_TEMPERATURE_SCHEMA = vol.All(
cv.has_at_least_one_key(
ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW
@@ -87,7 +87,6 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
):
"""Representation of a devolo device tracker."""
_attr_has_entity_name = True
_attr_translation_key = "device_tracker"
def __init__(
@@ -100,7 +99,6 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
super().__init__(coordinator)
self._device = device
self._attr_mac_address = mac
self._attr_name = mac
@property
def extra_state_attributes(self) -> dict[str, str]:
@@ -21,7 +21,6 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.18.0"],
"requirements": ["aiohomeconnect==0.17.1"],
"zeroconf": ["_homeconnect._tcp.local."]
}
@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.0.9"]
"requirements": ["pylamarzocco==2.0.8"]
}
@@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
).target_temperature
),
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
),
LaMarzoccoNumberEntityDescription(
key="smart_standby_time",
@@ -225,7 +221,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
entity_description: LaMarzoccoNumberEntityDescription
@property
def native_value(self) -> float | int:
def native_value(self) -> float:
"""Return the current value."""
return self.entity_description.native_value_fn(self.coordinator.device)
@@ -57,10 +57,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
).ready_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
available_fn=(
lambda coordinator: WidgetType.CM_COFFEE_BOILER
in coordinator.device.dashboard.config
),
),
LaMarzoccoSensorEntityDescription(
key="steam_boiler_ready_time",
-5
View File
@@ -9,10 +9,5 @@
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"mqtt": {
"trigger": "mdi:swap-horizontal"
}
}
}
+3 -6
View File
@@ -138,12 +138,9 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT
device_class in DEVICE_CLASS_UNITS
and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class]
):
_LOGGER.warning(
"The unit of measurement `%s` is not valid "
"together with device class `%s`. "
"this will stop working in HA Core 2025.7.0",
unit_of_measurement,
device_class,
raise vol.Invalid(
f"The unit of measurement '{unit_of_measurement}' is not valid "
f"together with device class '{device_class}'"
)
return config
@@ -988,23 +988,6 @@
"description": "Reloads MQTT entities from the YAML-configuration."
}
},
"triggers": {
"mqtt": {
"name": "MQTT",
"description": "When a specific message is received on a given MQTT topic.",
"description_configured": "When an MQTT message has been received",
"fields": {
"payload": {
"name": "Payload",
"description": "The payload to trigger on."
},
"topic": {
"name": "Topic",
"description": "MQTT topic to listen to."
}
}
}
},
"exceptions": {
"addon_start_failed": {
"message": "Failed to correctly start {addon} add-on."
@@ -1,14 +0,0 @@
# Describes the format for MQTT triggers
mqtt:
fields:
payload:
example: "on"
required: false
selector:
text:
topic:
example: "living_room/switch/ac"
required: true
selector:
text:
@@ -66,7 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except OneDriveException as err:
_LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True)
_LOGGER.debug("Failed to fetch drive data: %s")
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_failed"
) from err
@@ -52,13 +52,7 @@ from homeassistant.helpers.json import (
json_bytes,
json_fragment,
)
from homeassistant.helpers.service import (
async_get_all_descriptions as async_get_all_service_descriptions,
)
from homeassistant.helpers.trigger import (
async_get_all_descriptions as async_get_all_trigger_descriptions,
async_subscribe_platform_events as async_subscribe_trigger_platform_events,
)
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.loader import (
IntegrationNotFound,
async_get_integration,
@@ -74,10 +68,9 @@ from homeassistant.util.json import format_unserializable_data
from . import const, decorators, messages
from .connection import ActiveConnection
from .messages import construct_event_message, construct_result_message
from .messages import construct_result_message
ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json"
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_descriptions_json"
_LOGGER = logging.getLogger(__name__)
@@ -95,7 +88,6 @@ def async_register_commands(
async_reg(hass, handle_get_config)
async_reg(hass, handle_get_services)
async_reg(hass, handle_get_states)
async_reg(hass, handle_subscribe_trigger_platforms)
async_reg(hass, handle_manifest_get)
async_reg(hass, handle_integration_setup_info)
async_reg(hass, handle_manifest_list)
@@ -501,9 +493,9 @@ def _send_handle_entities_init_response(
)
async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes:
async def _async_get_all_descriptions_json(hass: HomeAssistant) -> bytes:
"""Return JSON of descriptions (i.e. user documentation) for all service calls."""
descriptions = await async_get_all_service_descriptions(hass)
descriptions = await async_get_all_descriptions(hass)
if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data:
cached_descriptions, cached_json_payload = hass.data[
ALL_SERVICE_DESCRIPTIONS_JSON_CACHE
@@ -522,57 +514,10 @@ async def handle_get_services(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle get services command."""
payload = await _async_get_all_service_descriptions_json(hass)
payload = await _async_get_all_descriptions_json(hass)
connection.send_message(construct_result_message(msg["id"], payload))
async def _async_get_all_trigger_descriptions_json(hass: HomeAssistant) -> bytes:
"""Return JSON of descriptions (i.e. user documentation) for all triggers."""
descriptions = await async_get_all_trigger_descriptions(hass)
if ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE in hass.data:
cached_descriptions, cached_json_payload = hass.data[
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE
]
# If the descriptions are the same, return the cached JSON payload
if cached_descriptions is descriptions:
return cast(bytes, cached_json_payload)
json_payload = json_bytes(
{
trigger: description
for trigger, description in descriptions.items()
if description is not None
}
)
hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload)
return json_payload
@decorators.websocket_command({vol.Required("type"): "trigger_platforms/subscribe"})
@decorators.async_response
async def handle_subscribe_trigger_platforms(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle subscribe triggers command."""
async def on_new_triggers(new_triggers: set[str]) -> None:
"""Forward new triggers to websocket."""
descriptions = await async_get_all_trigger_descriptions(hass)
new_trigger_descriptions = {}
for trigger in new_triggers:
if (description := descriptions[trigger]) is not None:
new_trigger_descriptions[trigger] = description
if not new_trigger_descriptions:
return
connection.send_event(msg["id"], new_trigger_descriptions)
connection.subscriptions[msg["id"]] = async_subscribe_trigger_platform_events(
hass, on_new_triggers
)
connection.send_result(msg["id"])
triggers_json = await _async_get_all_trigger_descriptions_json(hass)
connection.send_message(construct_event_message(msg["id"], triggers_json))
@callback
@decorators.websocket_command({vol.Required("type"): "get_config"})
def handle_get_config(
@@ -109,19 +109,6 @@ def event_message(iden: int, event: Any) -> dict[str, Any]:
return {"id": iden, "type": "event", "event": event}
def construct_event_message(iden: int, event: bytes) -> bytes:
"""Construct an event message JSON."""
return b"".join(
(
b'{"id":',
str(iden).encode(),
b',"type":"event","event":',
event,
b"}",
)
)
def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes:
"""Return an event message.
+1 -1
View File
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
"requirements": ["zha==0.0.60"],
"requirements": ["zha==0.0.59"],
"usb": [
{
"vid": "10C4",
@@ -1188,7 +1188,6 @@ DISCOVERY_SCHEMAS = [
any_available_states={(0, "idle")},
),
allow_multi=True,
entity_registry_enabled_default=False,
),
# event
# stateful = False
+3 -211
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import abc
import asyncio
from collections import defaultdict
from collections.abc import Callable, Coroutine, Iterable
from collections.abc import Callable, Coroutine
from dataclasses import dataclass, field
import functools
import logging
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast
from typing import Any, Protocol, TypedDict, cast
import voluptuous as vol
@@ -29,24 +29,13 @@ from homeassistant.core import (
is_callback,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.loader import (
Integration,
IntegrationNotFound,
async_get_integration,
async_get_integrations,
)
from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.yaml import load_yaml_dict
from homeassistant.util.yaml.loader import JSON_TYPE
from . import config_validation as cv
from .integration_platform import async_process_integration_platforms
from .template import Template
from .typing import ConfigType, TemplateVarsType
_LOGGER = logging.getLogger(__name__)
_PLATFORM_ALIASES = {
"device": "device_automation",
"event": "homeassistant",
@@ -60,99 +49,6 @@ DATA_PLUGGABLE_ACTIONS: HassKey[defaultdict[tuple, PluggableActionsEntry]] = Has
"pluggable_actions"
)
TRIGGER_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey(
"trigger_description_cache"
)
TRIGGER_PLATFORM_SUBSCRIPTIONS: HassKey[
list[Callable[[set[str]], Coroutine[Any, Any, None]]]
] = HassKey("trigger_platform_subscriptions")
TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers")
# Basic schemas to sanity check the trigger descriptions,
# full validation is done by hassfest.services
_FIELD_SCHEMA = vol.Schema(
{},
extra=vol.ALLOW_EXTRA,
)
_TRIGGER_SCHEMA = vol.Schema(
{
vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}),
},
extra=vol.ALLOW_EXTRA,
)
def starts_with_dot(key: str) -> str:
"""Check if key starts with dot."""
if not key.startswith("."):
raise vol.Invalid("Key does not start with .")
return key
_TRIGGERS_SCHEMA = vol.Schema(
{
vol.Remove(vol.All(str, starts_with_dot)): object,
cv.slug: vol.Any(None, _TRIGGER_SCHEMA),
}
)
async def async_setup(hass: HomeAssistant) -> None:
"""Set up the trigger helper."""
hass.data[TRIGGER_DESCRIPTION_CACHE] = {}
hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] = []
hass.data[TRIGGERS] = {}
await async_process_integration_platforms(
hass, "trigger", _register_trigger_platform, wait_for_platforms=True
)
@callback
def async_subscribe_platform_events(
hass: HomeAssistant,
on_event: Callable[[set[str]], Coroutine[Any, Any, None]],
) -> Callable[[], None]:
"""Subscribe to trigger platform events."""
trigger_platform_event_subscriptions = hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]
def remove_subscription() -> None:
trigger_platform_event_subscriptions.remove(on_event)
trigger_platform_event_subscriptions.append(on_event)
return remove_subscription
async def _register_trigger_platform(
hass: HomeAssistant, integration_domain: str, platform: TriggerProtocol
) -> None:
"""Register a trigger platform."""
new_triggers: set[str] = set()
if hasattr(platform, "async_get_triggers"):
for trigger_key in await platform.async_get_triggers(hass):
hass.data[TRIGGERS][trigger_key] = integration_domain
new_triggers.add(trigger_key)
elif hasattr(platform, "async_validate_trigger_config") or hasattr(
platform, "TRIGGER_SCHEMA"
):
hass.data[TRIGGERS][integration_domain] = integration_domain
new_triggers.add(integration_domain)
else:
_LOGGER.debug(
"Integration %s does not provide trigger support, skipping",
integration_domain,
)
return
tasks: list[asyncio.Task[None]] = [
create_eager_task(listener(new_triggers))
for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]
]
await asyncio.gather(*tasks)
class Trigger(abc.ABC):
"""Trigger class."""
@@ -513,107 +409,3 @@ async def async_initialize_triggers(
remove()
return remove_triggers
def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
"""Load triggers file for an integration."""
try:
return cast(
JSON_TYPE,
_TRIGGERS_SCHEMA(
load_yaml_dict(str(integration.file_path / "triggers.yaml"))
),
)
except FileNotFoundError:
_LOGGER.warning(
"Unable to find triggers.yaml for the %s integration", integration.domain
)
return {}
except (HomeAssistantError, vol.Invalid) as ex:
_LOGGER.warning(
"Unable to parse triggers.yaml for the %s integration: %s",
integration.domain,
ex,
)
return {}
def _load_triggers_files(
hass: HomeAssistant, integrations: Iterable[Integration]
) -> dict[str, JSON_TYPE]:
"""Load trigger files for multiple integrations."""
return {
integration.domain: _load_triggers_file(hass, integration)
for integration in integrations
}
async def async_get_all_descriptions(
hass: HomeAssistant,
) -> dict[str, dict[str, Any] | None]:
"""Return descriptions (i.e. user documentation) for all triggers."""
descriptions_cache = hass.data[TRIGGER_DESCRIPTION_CACHE]
triggers = hass.data[TRIGGERS]
# See if there are new triggers not seen before.
# Any trigger that we saw before already has an entry in description_cache.
all_triggers = set(triggers)
previous_all_triggers = set(descriptions_cache)
# If the triggers are the same, we can return the cache
if previous_all_triggers == all_triggers:
return descriptions_cache
# Files we loaded for missing descriptions
new_triggers_descriptions: dict[str, JSON_TYPE] = {}
# We try to avoid making a copy in the event the cache is good,
# but now we must make a copy in case new triggers get added
# while we are loading the missing ones so we do not
# add the new ones to the cache without their descriptions
triggers = triggers.copy()
if missing_triggers := all_triggers.difference(descriptions_cache):
domains_with_missing_triggers = {
triggers[missing_trigger] for missing_trigger in missing_triggers
}
ints_or_excs = await async_get_integrations(hass, domains_with_missing_triggers)
integrations: list[Integration] = []
for domain, int_or_exc in ints_or_excs.items():
if type(int_or_exc) is Integration and int_or_exc.has_triggers:
integrations.append(int_or_exc)
continue
if TYPE_CHECKING:
assert isinstance(int_or_exc, Exception)
_LOGGER.debug(
"Failed to load triggers.yaml for integration: %s",
domain,
exc_info=int_or_exc,
)
if integrations:
new_triggers_descriptions = await hass.async_add_executor_job(
_load_triggers_files, hass, integrations
)
# Make a copy of the old cache and add missing descriptions to it
new_descriptions_cache = descriptions_cache.copy()
for missing_trigger in missing_triggers:
domain = triggers[missing_trigger]
if (
yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr]
missing_trigger
)
) is None:
_LOGGER.debug(
"No trigger descriptions found for trigger %s, skipping",
missing_trigger,
)
new_descriptions_cache[missing_trigger] = None
continue
description = {"fields": yaml_description.get("fields", {})}
new_descriptions_cache[missing_trigger] = description
hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache
return new_descriptions_cache
+3 -8
View File
@@ -857,20 +857,15 @@ class Integration:
# True.
return self.manifest.get("import_executor", True)
@cached_property
def has_services(self) -> bool:
"""Return if the integration has services."""
return "services.yaml" in self._top_level_files
@cached_property
def has_translations(self) -> bool:
"""Return if the integration has translations."""
return "translations" in self._top_level_files
@cached_property
def has_triggers(self) -> bool:
"""Return if the integration has triggers."""
return "triggers.yaml" in self._top_level_files
def has_services(self) -> bool:
"""Return if the integration has services."""
return "services.yaml" in self._top_level_files
@property
def mqtt(self) -> list[str] | None:
+3 -3
View File
@@ -265,7 +265,7 @@ aioharmony==0.5.2
aiohasupervisor==0.3.1
# homeassistant.components.home_connect
aiohomeconnect==0.18.0
aiohomeconnect==0.17.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.15
@@ -2096,7 +2096,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
pylamarzocco==2.0.9
pylamarzocco==2.0.8
# homeassistant.components.lastfm
pylast==5.1.0
@@ -3180,7 +3180,7 @@ zeroconf==0.147.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.60
zha==0.0.59
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
+3 -3
View File
@@ -250,7 +250,7 @@ aioharmony==0.5.2
aiohasupervisor==0.3.1
# homeassistant.components.home_connect
aiohomeconnect==0.18.0
aiohomeconnect==0.17.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.15
@@ -1738,7 +1738,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
pylamarzocco==2.0.9
pylamarzocco==2.0.8
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2621,7 +2621,7 @@ zeroconf==0.147.0
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.60
zha==0.0.59
# homeassistant.components.zwave_js
zwave-js-server-python==0.63.0
-2
View File
@@ -28,7 +28,6 @@ from . import (
services,
ssdp,
translations,
triggers,
usb,
zeroconf,
)
@@ -50,7 +49,6 @@ INTEGRATION_PLUGINS = [
services,
ssdp,
translations,
triggers,
usb,
zeroconf,
config_flow, # This needs to run last, after translations are processed
-11
View File
@@ -120,16 +120,6 @@ CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA = cv.schema_with_slug_keys(
)
TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys(
vol.Schema(
{
vol.Optional("trigger"): icon_value_validator,
}
),
slug_validator=translation_key_validator,
)
def icon_schema(
core_integration: bool, integration_type: str, no_entity_platform: bool
) -> vol.Schema:
@@ -174,7 +164,6 @@ def icon_schema(
vol.Optional("services"): CORE_SERVICE_ICONS_SCHEMA
if core_integration
else CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA,
vol.Optional("triggers"): TRIGGER_ICONS_SCHEMA,
}
)
+1
View File
@@ -1527,6 +1527,7 @@ INTEGRATIONS_WITHOUT_SCALE = [
"hko",
"hlk_sw16",
"holiday",
"home_connect",
"homekit",
"homekit_controller",
"homematic",
-16
View File
@@ -415,22 +415,6 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
},
slug_validator=translation_key_validator,
),
vol.Optional("triggers"): cv.schema_with_slug_keys(
{
vol.Required("name"): translation_value_validator,
vol.Required("description"): translation_value_validator,
vol.Required("description_configured"): translation_value_validator,
vol.Optional("fields"): cv.schema_with_slug_keys(
{
vol.Required("name"): str,
vol.Required("description"): translation_value_validator,
vol.Optional("example"): translation_value_validator,
},
slug_validator=translation_key_validator,
),
},
slug_validator=translation_key_validator,
),
vol.Optional("conversation"): {
vol.Required("agent"): {
vol.Required("done"): translation_value_validator,
-238
View File
@@ -1,238 +0,0 @@
"""Validate triggers."""
from __future__ import annotations
import contextlib
import json
import pathlib
import re
from typing import Any
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.const import CONF_SELECTOR
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, selector, trigger
from homeassistant.util.yaml import load_yaml_dict
from .model import Config, Integration
def exists(value: Any) -> Any:
"""Check if value exists."""
if value is None:
raise vol.Invalid("Value cannot be None")
return value
FIELD_SCHEMA = vol.Schema(
{
vol.Optional("example"): exists,
vol.Optional("default"): exists,
vol.Optional("required"): bool,
vol.Optional(CONF_SELECTOR): selector.validate_selector,
}
)
TRIGGER_SCHEMA = vol.Any(
vol.Schema(
{
vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}),
}
),
None,
)
TRIGGERS_SCHEMA = vol.Schema(
{
vol.Remove(vol.All(str, trigger.starts_with_dot)): object,
cv.slug: TRIGGER_SCHEMA,
}
)
NON_MIGRATED_INTEGRATIONS = {
"calendar",
"conversation",
"device_automation",
"geo_location",
"homeassistant",
"knx",
"lg_netcast",
"litejet",
"persistent_notification",
"samsungtv",
"sun",
"tag",
"template",
"webhook",
"webostv",
"zone",
"zwave_js",
}
def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool:
"""Recursively go through a dir and it's children and find the regex."""
pattern = re.compile(search_pattern)
for fil in path.glob(glob_pattern):
if not fil.is_file():
continue
if pattern.search(fil.read_text()):
return True
return False
def validate_triggers(config: Config, integration: Integration) -> None: # noqa: C901
"""Validate triggers."""
try:
data = load_yaml_dict(str(integration.path / "triggers.yaml"))
except FileNotFoundError:
# Find if integration uses triggers
has_triggers = grep_dir(
integration.path,
"**/trigger.py",
r"async_attach_trigger|async_get_triggers",
)
if has_triggers and integration.domain not in NON_MIGRATED_INTEGRATIONS:
integration.add_error(
"triggers", "Registers triggers but has no triggers.yaml"
)
return
except HomeAssistantError:
integration.add_error("triggers", "Invalid triggers.yaml")
return
try:
triggers = TRIGGERS_SCHEMA(data)
except vol.Invalid as err:
integration.add_error(
"triggers", f"Invalid triggers.yaml: {humanize_error(data, err)}"
)
return
icons_file = integration.path / "icons.json"
icons = {}
if icons_file.is_file():
with contextlib.suppress(ValueError):
icons = json.loads(icons_file.read_text())
trigger_icons = icons.get("triggers", {})
# Try loading translation strings
if integration.core:
strings_file = integration.path / "strings.json"
else:
# For custom integrations, use the en.json file
strings_file = integration.path / "translations/en.json"
strings = {}
if strings_file.is_file():
with contextlib.suppress(ValueError):
strings = json.loads(strings_file.read_text())
error_msg_suffix = "in the translations file"
if not integration.core:
error_msg_suffix = f"and is not {error_msg_suffix}"
# For each trigger in the integration:
# 1. Check if the trigger description is set, if not,
# check if it's in the strings file else add an error.
# 2. Check if the trigger has an icon set in icons.json.
# raise an error if not.,
for trigger_name, trigger_schema in triggers.items():
if integration.core and trigger_name not in trigger_icons:
# This is enforced for Core integrations only
integration.add_error(
"triggers",
f"Trigger {trigger_name} has no icon in icons.json.",
)
if trigger_schema is None:
continue
if "name" not in trigger_schema and integration.core:
try:
strings["triggers"][trigger_name]["name"]
except KeyError:
integration.add_error(
"triggers",
f"Trigger {trigger_name} has no name {error_msg_suffix}",
)
if "description" not in trigger_schema and integration.core:
try:
strings["triggers"][trigger_name]["description"]
except KeyError:
integration.add_error(
"triggers",
f"Trigger {trigger_name} has no description {error_msg_suffix}",
)
# The same check is done for the description in each of the fields of the
# trigger schema.
for field_name, field_schema in trigger_schema.get("fields", {}).items():
if "fields" in field_schema:
# This is a section
continue
if "name" not in field_schema and integration.core:
try:
strings["triggers"][trigger_name]["fields"][field_name]["name"]
except KeyError:
integration.add_error(
"triggers",
(
f"Trigger {trigger_name} has a field {field_name} with no "
f"name {error_msg_suffix}"
),
)
if "description" not in field_schema and integration.core:
try:
strings["triggers"][trigger_name]["fields"][field_name][
"description"
]
except KeyError:
integration.add_error(
"triggers",
(
f"Trigger {trigger_name} has a field {field_name} with no "
f"description {error_msg_suffix}"
),
)
if "selector" in field_schema:
with contextlib.suppress(KeyError):
translation_key = field_schema["selector"]["select"][
"translation_key"
]
try:
strings["selector"][translation_key]
except KeyError:
integration.add_error(
"triggers",
f"Trigger {trigger_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file",
)
# The same check is done for the description in each of the sections of the
# trigger schema.
for section_name, section_schema in trigger_schema.get("fields", {}).items():
if "fields" not in section_schema:
# This is not a section
continue
if "name" not in section_schema and integration.core:
try:
strings["triggers"][trigger_name]["sections"][section_name]["name"]
except KeyError:
integration.add_error(
"triggers",
f"Trigger {trigger_name} has a section {section_name} with no name {error_msg_suffix}",
)
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Handle dependencies for integrations."""
# check triggers.yaml is valid
for integration in integrations.values():
validate_triggers(config, integration)
-2
View File
@@ -87,7 +87,6 @@ from homeassistant.helpers import (
restore_state as rs,
storage,
translation,
trigger,
)
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -296,7 +295,6 @@ async def async_test_home_assistant(
# Load the registries
entity.async_setup(hass)
loader.async_setup(hass)
await trigger.async_setup(hass)
# setup translation cache instead of calling translation.async_setup(hass)
hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache(
@@ -3,13 +3,12 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'band': '5 GHz',
'friendly_name': 'AA:BB:CC:DD:EE:FF',
'mac': 'AA:BB:CC:DD:EE:FF',
'source_type': <SourceType.ROUTER: 'router'>,
'wifi': 'Main',
}),
'context': <ANY>,
'entity_id': 'device_tracker.aa_bb_cc_dd_ee_ff',
'entity_id': 'device_tracker.devolo_home_network_1234567890_aa_bb_cc_dd_ee_ff',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -17,12 +17,13 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import configure_integration
from .const import CONNECTED_STATIONS, NO_CONNECTED_STATIONS
from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS
from .mock import MockDevice
from tests.common import async_fire_time_changed
STATION = CONNECTED_STATIONS[0]
SERIAL = DISCOVERY_INFO.properties["SN"]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@@ -34,7 +35,9 @@ async def test_device_tracker(
snapshot: SnapshotAssertion,
) -> None:
"""Test device tracker states."""
state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}"
state_key = (
f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}"
)
entry = configure_integration(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -74,12 +77,14 @@ async def test_restoring_clients(
entity_registry: er.EntityRegistry,
) -> None:
"""Test restoring existing device_tracker entities."""
state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}"
state_key = (
f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}"
)
entry = configure_integration(hass)
entity_registry.async_get_or_create(
PLATFORM,
DOMAIN,
f"{STATION.mac_address}",
f"{SERIAL}_{STATION.mac_address}",
config_entry=entry,
)
+2 -40
View File
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
@@ -71,7 +71,6 @@ from .common import (
from tests.common import (
MockConfigEntry,
async_capture_events,
async_fire_mqtt_message,
async_fire_time_changed,
mock_restore_cache_with_extra_data,
@@ -892,48 +891,11 @@ async def test_invalid_unit_of_measurement(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test device_class with invalid unit of measurement."""
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
assert await mqtt_mock_entry()
assert (
"The unit of measurement `ppm` is not valid together with device class `energy`"
"The unit of measurement 'ppm' is not valid together with device class 'energy'"
in caplog.text
)
# A repair issue was logged
assert len(events) == 1
assert events[0].data["issue_id"] == "sensor.test"
# Assert the sensor works
async_fire_mqtt_message(hass, "test-topic", "100")
await hass.async_block_till_done()
state = hass.states.get("sensor.test")
assert state is not None
assert state.state == "100"
caplog.clear()
discovery_payload = {
"name": "bla",
"state_topic": "test-topic2",
"device_class": "temperature",
"unit_of_measurement": "C",
}
# Now discover an other invalid sensor
async_fire_mqtt_message(
hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload)
)
await hass.async_block_till_done()
assert (
"The unit of measurement `C` is not valid together with device class `temperature`"
in caplog.text
)
# Assert the sensor works
async_fire_mqtt_message(hass, "test-topic2", "21")
await hass.async_block_till_done()
state = hass.states.get("sensor.bla")
assert state is not None
assert state.state == "21"
# No new issue was registered for the discovered entity
assert len(events) == 1
@pytest.mark.parametrize(
@@ -2,7 +2,6 @@
import asyncio
from copy import deepcopy
import io
import logging
from typing import Any
from unittest.mock import ANY, AsyncMock, Mock, patch
@@ -18,9 +17,6 @@ from homeassistant.components.websocket_api.auth import (
TYPE_AUTH_OK,
TYPE_AUTH_REQUIRED,
)
from homeassistant.components.websocket_api.commands import (
ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE,
)
from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS
@@ -29,10 +25,9 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.loader import Integration, async_get_integration
from homeassistant.loader import async_get_integration
from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component
from homeassistant.util.json import json_loads
from homeassistant.util.yaml.loader import parse_yaml
from tests.common import (
MockConfigEntry,
@@ -682,91 +677,6 @@ async def test_get_services(
assert msg["result"].keys() == hass.services.async_services().keys()
@patch("annotatedyaml.loader.load_yaml")
@patch.object(Integration, "has_triggers", return_value=True)
async def test_subscribe_triggers(
mock_has_triggers: Mock,
mock_load_yaml: Mock,
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
) -> None:
"""Test get_triggers command."""
sun_service_descriptions = """
sun: {}
"""
tag_service_descriptions = """
tag: {}
"""
def _load_yaml(fname, secrets=None):
if fname.endswith("sun/triggers.yaml"):
service_descriptions = sun_service_descriptions
elif fname.endswith("tag/triggers.yaml"):
service_descriptions = tag_service_descriptions
else:
raise FileNotFoundError
with io.StringIO(service_descriptions) as file:
return parse_yaml(file)
mock_load_yaml.side_effect = _load_yaml
assert await async_setup_component(hass, "sun", {})
assert await async_setup_component(hass, "system_health", {})
await hass.async_block_till_done()
assert ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE not in hass.data
await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"})
# Test start subscription with initial event
msg = await websocket_client.receive_json()
assert msg == {"id": 1, "result": None, "success": True, "type": "result"}
msg = await websocket_client.receive_json()
assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"}
old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE]
# Test we receive an event when a new platform is loaded, if it has descriptions
assert await async_setup_component(hass, "calendar", {})
assert await async_setup_component(hass, "tag", {})
await hass.async_block_till_done()
msg = await websocket_client.receive_json()
assert msg == {
"event": {"tag": {"fields": {}}},
"id": 1,
"type": "event",
}
# Initiate a second subscription to check the cache is updated because of the new
# trigger
await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"})
msg = await websocket_client.receive_json()
assert msg == {"id": 2, "result": None, "success": True, "type": "result"}
msg = await websocket_client.receive_json()
assert msg == {
"event": {"sun": {"fields": {}}, "tag": {"fields": {}}},
"id": 2,
"type": "event",
}
assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is not old_cache
# Initiate a third subscription to check the cache is not updated because no new
# trigger was added
old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE]
await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"})
msg = await websocket_client.receive_json()
assert msg == {"id": 3, "result": None, "success": True, "type": "result"}
msg = await websocket_client.receive_json()
assert msg == {
"event": {"sun": {"fields": {}}, "tag": {"fields": {}}},
"id": 3,
"type": "event",
}
assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is old_cache
async def test_get_config(
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
) -> None:
@@ -97,8 +97,8 @@
'value_id': '52-113-0-Home Security-Cover status',
}),
dict({
'disabled': True,
'disabled_by': 'integration',
'disabled': False,
'disabled_by': None,
'domain': 'button',
'entity_category': 'config',
'entity_id': 'button.multisensor_6_idle_home_security_cover_status',
@@ -120,8 +120,8 @@
'value_id': '52-113-0-Home Security-Cover status',
}),
dict({
'disabled': True,
'disabled_by': 'integration',
'disabled': False,
'disabled_by': None,
'domain': 'button',
'entity_category': 'config',
'entity_id': 'button.multisensor_6_idle_home_security_motion_sensor_status',
+6 -35
View File
@@ -1,21 +1,13 @@
"""Test the Z-Wave JS button entities."""
from datetime import timedelta
from unittest.mock import MagicMock
import pytest
from zwave_js_server.model.node import Node
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE
from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import ATTR_ENTITY_ID, EntityCategory, Platform
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
@@ -79,32 +71,11 @@ async def test_ping_entity(
async def test_notification_idle_button(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
client: MagicMock,
multisensor_6: Node,
integration: MockConfigEntry,
hass: HomeAssistant, client, multisensor_6, integration
) -> None:
"""Test Notification idle button."""
node = multisensor_6
entity_id = "button.multisensor_6_idle_home_security_cover_status"
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category is EntityCategory.CONFIG
assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
assert hass.states.get(entity_id) is None # disabled by default
entity_registry.async_update_entity(
entity_id,
disabled_by=None,
)
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
state = hass.states.get("button.multisensor_6_idle_home_security_cover_status")
assert state
assert state.state == "unknown"
assert (
@@ -117,13 +88,13 @@ async def test_notification_idle_button(
BUTTON_DOMAIN,
SERVICE_PRESS,
{
ATTR_ENTITY_ID: entity_id,
ATTR_ENTITY_ID: "button.multisensor_6_idle_home_security_cover_status",
},
blocking=True,
)
assert client.async_send_command_no_wait.call_count == 1
args = client.async_send_command_no_wait.call_args[0][0]
assert len(client.async_send_command_no_wait.call_args_list) == 1
args = client.async_send_command_no_wait.call_args_list[0][0][0]
assert args["command"] == "node.manually_idle_notification_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
+10 -2
View File
@@ -1812,8 +1812,7 @@ async def test_disabled_node_status_entity_on_node_replaced(
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_remove_entity_on_value_removed(
async def test_disabled_entity_on_value_removed(
hass: HomeAssistant,
zp3111: Node,
client: MagicMock,
@@ -1824,6 +1823,15 @@ async def test_remove_entity_on_value_removed(
"button.4_in_1_sensor_idle_home_security_cover_status"
)
# must reload the integration when enabling an entity
await hass.config_entries.async_unload(integration.entry_id)
await hass.async_block_till_done()
assert integration.state is ConfigEntryState.NOT_LOADED
integration.add_to_hass(hass)
await hass.config_entries.async_setup(integration.entry_id)
await hass.async_block_till_done()
assert integration.state is ConfigEntryState.LOADED
state = hass.states.get(idle_cover_status_button_entity)
assert state
assert state.state != STATE_UNAVAILABLE
-181
View File
@@ -1,17 +1,11 @@
"""The tests for the trigger helper."""
import io
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
import pytest
from pytest_unordered import unordered
import voluptuous as vol
from homeassistant.components.sun import DOMAIN as DOMAIN_SUN
from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH
from homeassistant.core import Context, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import trigger
from homeassistant.helpers.trigger import (
DATA_PLUGGABLE_ACTIONS,
PluggableAction,
@@ -19,11 +13,7 @@ from homeassistant.helpers.trigger import (
async_initialize_triggers,
async_validate_trigger_config,
)
from homeassistant.loader import Integration, async_get_integration
from homeassistant.setup import async_setup_component
from homeassistant.util.yaml.loader import parse_yaml
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
async def test_bad_trigger_platform(hass: HomeAssistant) -> None:
@@ -438,174 +428,3 @@ async def test_pluggable_action(
remove_attach_2()
assert not hass.data[DATA_PLUGGABLE_ACTIONS]
assert not plug_2
@pytest.mark.parametrize(
"sun_service_descriptions",
[
"""
sun:
fields:
event:
example: sunrise
selector:
select:
options:
- sunrise
- sunset
offset:
selector:
time: null
""",
"""
.anchor: &anchor
- sunrise
- sunset
sun:
fields:
event:
example: sunrise
selector:
select:
options: *anchor
offset:
selector:
time: null
""",
],
)
async def test_async_get_all_descriptions(
hass: HomeAssistant, sun_service_descriptions: str
) -> None:
"""Test async_get_all_descriptions."""
assert await async_setup_component(hass, DOMAIN_SUN, {})
assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {})
await hass.async_block_till_done()
def _load_yaml(fname, secrets=None):
with io.StringIO(sun_service_descriptions) as file:
return parse_yaml(file)
with (
patch(
"homeassistant.helpers.trigger._load_triggers_files",
side_effect=trigger._load_triggers_files,
) as proxy_load_triggers_files,
patch(
"annotatedyaml.loader.load_yaml",
side_effect=_load_yaml,
),
patch.object(Integration, "has_triggers", return_value=True),
):
descriptions = await trigger.async_get_all_descriptions(hass)
# Test we only load triggers.yaml for integrations with triggers,
# system_health has no triggers
assert proxy_load_triggers_files.mock_calls[0][1][1] == unordered(
[
await async_get_integration(hass, DOMAIN_SUN),
]
)
# system_health does not have services and should not be in descriptions
assert descriptions == {
DOMAIN_SUN: {
"fields": {
"event": {
"example": "sunrise",
"selector": {"select": {"options": ["sunrise", "sunset"]}},
},
"offset": {"selector": {"time": None}},
}
}
}
# Verify the cache returns the same object
assert await trigger.async_get_all_descriptions(hass) is descriptions
@pytest.mark.parametrize(
("yaml_error", "expected_message"),
[
(
FileNotFoundError("Blah"),
"Unable to find triggers.yaml for the sun integration",
),
(
HomeAssistantError("Test error"),
"Unable to parse triggers.yaml for the sun integration: Test error",
),
],
)
async def test_async_get_all_descriptions_with_yaml_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
yaml_error: Exception,
expected_message: str,
) -> None:
"""Test async_get_all_descriptions."""
assert await async_setup_component(hass, DOMAIN_SUN, {})
await hass.async_block_till_done()
def _load_yaml_dict(fname, secrets=None):
raise yaml_error
with (
patch(
"homeassistant.helpers.trigger.load_yaml_dict",
side_effect=_load_yaml_dict,
),
patch.object(Integration, "has_triggers", return_value=True),
):
descriptions = await trigger.async_get_all_descriptions(hass)
assert descriptions == {DOMAIN_SUN: None}
assert expected_message in caplog.text
async def test_async_get_all_descriptions_with_bad_description(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test async_get_all_descriptions."""
sun_service_descriptions = """
sun:
fields: not_a_dict
"""
assert await async_setup_component(hass, DOMAIN_SUN, {})
await hass.async_block_till_done()
def _load_yaml(fname, secrets=None):
with io.StringIO(sun_service_descriptions) as file:
return parse_yaml(file)
with (
patch(
"annotatedyaml.loader.load_yaml",
side_effect=_load_yaml,
),
patch.object(Integration, "has_triggers", return_value=True),
):
descriptions = await trigger.async_get_all_descriptions(hass)
assert descriptions == {DOMAIN_SUN: None}
assert (
"Unable to parse triggers.yaml for the sun integration: "
"expected a dictionary for dictionary value @ data['sun']['fields']"
) in caplog.text
async def test_invalid_trigger_platform(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test invalid trigger platform."""
mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True)))
mock_platform(hass, "test.trigger", MockPlatform())
await async_setup_component(hass, "test", {})
assert "Integration test does not provide trigger support, skipping" in caplog.text