diff --git a/homeassistant/components/autarco/manifest.json b/homeassistant/components/autarco/manifest.json index 0567aeba722413..6f86f5b84e3ee0 100644 --- a/homeassistant/components/autarco/manifest.json +++ b/homeassistant/components/autarco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/autarco", "iot_class": "cloud_polling", - "requirements": ["autarco==3.1.0"] + "requirements": ["autarco==3.2.0"] } diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index ca69a4b662f20c..fe260b758883dc 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -169,7 +169,7 @@ class CalendarEventListener: def __init__( self, hass: HomeAssistant, - job: HassJob[..., Coroutine[Any, Any, None]], + job: HassJob[..., Coroutine[Any, Any, None] | Any], trigger_data: dict[str, Any], fetcher: QueuedEventFetcher, ) -> None: diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 56a0b46f52b2b8..6ac5a082bee812 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -514,7 +514,7 @@ async def async_provide_llm_data( """Set the LLM system prompt.""" llm_api: llm.APIInstance | None = None - if user_llm_hass_api is None: + if not user_llm_hass_api: pass elif isinstance(user_llm_hass_api, llm.API): llm_api = await user_llm_hass_api.async_get_api_instance(llm_context) diff --git a/homeassistant/components/homeassistant_connect_zbt2/manifest.json b/homeassistant/components/homeassistant_connect_zbt2/manifest.json index 5d5c2996e47e27..b7446dcb7967d2 100644 --- a/homeassistant/components/homeassistant_connect_zbt2/manifest.json +++ b/homeassistant/components/homeassistant_connect_zbt2/manifest.json @@ -13,6 +13,12 @@ "pid": "4001", "description": "*zbt-2*", "known_devices": ["ZBT-2"] + }, + { + "vid": "303A", + "pid": "831A", + "description": "*zbt-2*", + "known_devices": ["ZBT-2"] } ] } diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 6bfb9cd4324261..93865070f189a0 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["imgw_pib==1.5.6"] } diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml index 6634c9152550a9..bf5f7a4c0927c0 100644 --- a/homeassistant/components/imgw_pib/quality_scale.yaml +++ b/homeassistant/components/imgw_pib/quality_scale.yaml @@ -50,17 +50,17 @@ rules: discovery: status: exempt comment: The integration is a cloud service and thus does not support discovery. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: status: exempt comment: This is a service, which doesn't integrate with any devices. - docs-supported-functions: todo + docs-supported-functions: done docs-troubleshooting: status: exempt comment: No known issues that could be resolved by the user. - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: This integration has a fixed single service. diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 98ccb90d4b976d..9576f8da80928d 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -270,7 +270,7 @@ class BLEScannerMode(StrEnum): CONF_GEN = "gen" -VIRTUAL_COMPONENTS = ("boolean", "button", "enum", "input", "number", "text") +VIRTUAL_COMPONENTS = ("boolean", "button", "enum", "number", "text") VIRTUAL_COMPONENTS_MAP = { "binary_sensor": {"types": ["boolean"], "modes": ["label"]}, "button": {"types": ["button"], "modes": ["button"]}, diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index dfc5cbc2e68de6..47db02e06dc947 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -50,8 +50,14 @@ "valve_status": { "default": "mdi:valve" }, + "vial_name": { + "default": "mdi:scent" + }, "illuminance_level": { "default": "mdi:brightness-5" + }, + "vial_level": { + "default": "mdi:bottle-tonic-outline" } }, "switch": { @@ -61,6 +67,13 @@ "off": "mdi:valve-closed", "on": "mdi:valve-open" } + }, + "cury_slot": { + "default": "mdi:scent", + "state": { + "off": "mdi:scent-off", + "on": "mdi:scent" + } } } } diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index dfb5fb95038311..8ba67233a68376 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -72,6 +72,7 @@ class RpcNumberDescription(RpcEntityDescription, NumberEntityDescription): min_fn: Callable[[dict], float] | None = None step_fn: Callable[[dict], float] | None = None mode_fn: Callable[[dict], NumberMode] | None = None + slot: str | None = None method: str @@ -121,6 +122,22 @@ async def async_set_native_value(self, value: float) -> None: await method(self._id, value) +class RpcCuryIntensityNumber(RpcNumber): + """Represent a RPC Cury Intensity entity.""" + + @rpc_call + async def async_set_native_value(self, value: float) -> None: + """Change the value.""" + method = getattr(self.coordinator.device, self.entity_description.method) + + if TYPE_CHECKING: + assert method is not None + + await method( + self._id, slot=self.entity_description.slot, intensity=round(value) + ) + + class RpcBluTrvNumber(RpcNumber): """Represent a RPC BluTrv number.""" @@ -274,6 +291,38 @@ async def async_set_native_value(self, value: float) -> None: is True, entity_class=RpcBluTrvNumber, ), + "left_slot_intensity": RpcNumberDescription( + key="cury", + sub_key="slots", + name="Left slot intensity", + value=lambda status, _: status["left"]["intensity"], + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=PERCENTAGE, + method="cury_set", + slot="left", + available=lambda status: (left := status["left"]) is not None + and left.get("vial", {}).get("level", -1) != -1, + entity_class=RpcCuryIntensityNumber, + ), + "right_slot_intensity": RpcNumberDescription( + key="cury", + sub_key="slots", + name="Right slot intensity", + value=lambda status, _: status["right"]["intensity"], + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_unit_of_measurement=PERCENTAGE, + method="cury_set", + slot="right", + available=lambda status: (right := status["right"]) is not None + and right.get("vial", {}).get("level", -1) != -1, + entity_class=RpcCuryIntensityNumber, + ), } diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index fb399fd80d4e8d..824d44f2fb50be 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1658,6 +1658,50 @@ def __init__( state_class=SensorStateClass.MEASUREMENT, role="phase_info", ), + "cury_left_level": RpcSensorDescription( + key="cury", + sub_key="slots", + name="Left slot level", + translation_key="vial_level", + value=lambda status, _: status["left"]["vial"]["level"], + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + available=lambda status: (left := status["left"]) is not None + and left.get("vial", {}).get("level", -1) != -1, + ), + "cury_left_vial": RpcSensorDescription( + key="cury", + sub_key="slots", + name="Left slot vial", + translation_key="vial_name", + value=lambda status, _: status["left"]["vial"]["name"], + entity_category=EntityCategory.DIAGNOSTIC, + available=lambda status: (left := status["left"]) is not None + and left.get("vial", {}).get("level", -1) != -1, + ), + "cury_right_level": RpcSensorDescription( + key="cury", + sub_key="slots", + name="Right slot level", + translation_key="vial_level", + value=lambda status, _: status["right"]["vial"]["level"], + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + available=lambda status: (right := status["right"]) is not None + and right.get("vial", {}).get("level", -1) != -1, + ), + "cury_right_vial": RpcSensorDescription( + key="cury", + sub_key="slots", + name="Right slot vial", + translation_key="vial_name", + value=lambda status, _: status["right"]["vial"]["name"], + entity_category=EntityCategory.DIAGNOSTIC, + available=lambda status: (right := status["right"]) is not None + and right.get("vial", {}).get("level", -1) != -1, + ), } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index ce55f6d98ade2c..43395f3f8da3ab 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -230,6 +230,32 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), + "cury_left": RpcSwitchDescription( + key="cury", + sub_key="slots", + name="Left slot", + translation_key="cury_slot", + is_on=lambda status: bool(status["slots"]["left"]["on"]), + method_on="cury_set", + method_off="cury_set", + method_params_fn=lambda id, value: (id, "left", value), + entity_registry_enabled_default=True, + available=lambda status: (left := status["left"]) is not None + and left.get("vial", {}).get("level", -1) != -1, + ), + "cury_right": RpcSwitchDescription( + key="cury", + sub_key="slots", + name="Right slot", + translation_key="cury_slot", + is_on=lambda status: bool(status["slots"]["right"]["on"]), + method_on="cury_set", + method_off="cury_set", + method_params_fn=lambda id, value: (id, "right", value), + entity_registry_enabled_default=True, + available=lambda status: (right := status["right"]) is not None + and right.get("vial", {}).get("level", -1) != -1, + ), } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 8024fe64446b67..e044b2c0fcd92d 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -402,12 +402,12 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None: if key in device.config and key != "em:0": # workaround for Pro 3EM, we don't want to get name for em:0 if component_name := device.config[key].get("name"): - if component in (*VIRTUAL_COMPONENTS, "presencezone", "script"): + if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"): return cast(str, component_name) return cast(str, component_name) if instances == 1 else None - if component in VIRTUAL_COMPONENTS: + if component in (*VIRTUAL_COMPONENTS, "input"): return f"{component.title()} {component_id}" return None diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index a9775873f0c245..bfc37328bfb497 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from typing import Any import voluptuous as vol @@ -20,7 +21,7 @@ CONF_PLATFORM, CONF_TYPE, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, @@ -454,8 +455,28 @@ async def async_attach_trigger( zwave_js_config = await validate_value_updated_trigger_config( hass, zwave_js_config ) + + @callback + def run_action( + extra_trigger_payload: dict[str, Any], + description: str, + context: Context | None = None, + ) -> asyncio.Task[Any]: + """Run action with trigger variables.""" + + payload = { + "trigger": { + **trigger_info["trigger_data"], + CONF_PLATFORM: VALUE_UPDATED_PLATFORM_TYPE, + "description": description, + **extra_trigger_payload, + } + } + + return hass.async_create_task(action(payload, context)) + return await attach_value_updated_trigger( - hass, zwave_js_config[CONF_OPTIONS], action, trigger_info + hass, zwave_js_config[CONF_OPTIONS], run_action ) raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 4273bf653c2712..fb5259f7582202 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -17,19 +17,12 @@ ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_OPTIONS, - CONF_PLATFORM, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.trigger import ( - Trigger, - TriggerActionType, - TriggerConfig, - TriggerData, - TriggerInfo, -) +from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig from homeassistant.helpers.typing import ConfigType from ..const import ( @@ -127,17 +120,13 @@ def validate_event_data(obj: dict) -> dict: class EventTrigger(Trigger): """Z-Wave JS event trigger.""" - _hass: HomeAssistant _options: dict[str, Any] _event_source: str _event_name: str _event_data_filter: dict - _job: HassJob - _trigger_data: TriggerData _unsubs: list[Callable] - - _platform_type = PLATFORM_TYPE + _action_runner: TriggerActionRunner @classmethod async def async_validate_complete_config( @@ -176,14 +165,12 @@ async def async_validate_config( def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" - self._hass = hass + super().__init__(hass, config) assert config.options is not None self._options = config.options - async def async_attach( - self, - action: TriggerActionType, - trigger_info: TriggerInfo, + async def async_attach_runner( + self, run_action: TriggerActionRunner ) -> CALLBACK_TYPE: """Attach a trigger.""" dev_reg = dr.async_get(self._hass) @@ -198,8 +185,7 @@ async def async_attach( self._event_source = options[ATTR_EVENT_SOURCE] self._event_name = options[ATTR_EVENT] self._event_data_filter = options.get(ATTR_EVENT_DATA, {}) - self._job = HassJob(action) - self._trigger_data = trigger_info["trigger_data"] + self._action_runner = run_action self._unsubs: list[Callable] = [] self._create_zwave_listeners() @@ -225,9 +211,7 @@ def _async_on_event( if event_data[key] != val: return - payload = { - **self._trigger_data, - CONF_PLATFORM: self._platform_type, + payload: dict[str, Any] = { ATTR_EVENT_SOURCE: self._event_source, ATTR_EVENT: self._event_name, ATTR_EVENT_DATA: event_data, @@ -237,21 +221,17 @@ def _async_on_event( f"Z-Wave JS '{self._event_source}' event '{self._event_name}' was emitted" ) + description = primary_desc if device: device_name = device.name_by_user or device.name payload[ATTR_DEVICE_ID] = device.id home_and_node_id = get_home_and_node_id_from_device_entry(device) assert home_and_node_id payload[ATTR_NODE_ID] = home_and_node_id[1] - payload["description"] = f"{primary_desc} on {device_name}" - else: - payload["description"] = primary_desc - - payload["description"] = ( - f"{payload['description']} with event data: {event_data}" - ) + description = f"{primary_desc} on {device_name}" - self._hass.async_run_hass_job(self._job, {"trigger": payload}) + description = f"{description} with event data: {event_data}" + self._action_runner(payload, description) @callback def _async_remove(self) -> None: diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 7ea565299d64d5..22f8ab78dc7763 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -11,23 +11,12 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value, get_value_id_str -from homeassistant.const import ( - ATTR_DEVICE_ID, - ATTR_ENTITY_ID, - CONF_OPTIONS, - CONF_PLATFORM, - MATCH_ALL, -) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_OPTIONS, MATCH_ALL +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.trigger import ( - Trigger, - TriggerActionType, - TriggerConfig, - TriggerInfo, -) +from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig from homeassistant.helpers.typing import ConfigType from ..config_validation import VALUE_SCHEMA @@ -100,12 +89,7 @@ async def async_validate_trigger_config( async def async_attach_trigger( - hass: HomeAssistant, - options: ConfigType, - action: TriggerActionType, - trigger_info: TriggerInfo, - *, - platform_type: str = PLATFORM_TYPE, + hass: HomeAssistant, options: ConfigType, run_action: TriggerActionRunner ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" dev_reg = dr.async_get(hass) @@ -121,9 +105,6 @@ async def async_attach_trigger( endpoint = options.get(ATTR_ENDPOINT) property_key = options.get(ATTR_PROPERTY_KEY) unsubs: list[Callable] = [] - job = HassJob(action) - - trigger_data = trigger_info["trigger_data"] @callback def async_on_value_updated( @@ -152,10 +133,8 @@ def async_on_value_updated( return device_name = device.name_by_user or device.name - + description = f"Z-Wave value {value.value_id} updated on {device_name}" payload = { - **trigger_data, - CONF_PLATFORM: platform_type, ATTR_DEVICE_ID: device.id, ATTR_NODE_ID: value.node.node_id, ATTR_COMMAND_CLASS: value.command_class, @@ -169,10 +148,9 @@ def async_on_value_updated( ATTR_PREVIOUS_VALUE_RAW: prev_value_raw, ATTR_CURRENT_VALUE: curr_value, ATTR_CURRENT_VALUE_RAW: curr_value_raw, - "description": f"Z-Wave value {value.value_id} updated on {device_name}", } - hass.async_run_hass_job(job, {"trigger": payload}) + run_action(payload, description) @callback def async_remove() -> None: @@ -223,7 +201,6 @@ def _create_zwave_listeners() -> None: class ValueUpdatedTrigger(Trigger): """Z-Wave JS value updated trigger.""" - _hass: HomeAssistant _options: dict[str, Any] @classmethod @@ -245,16 +222,12 @@ async def async_validate_config( def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" - self._hass = hass + super().__init__(hass, config) assert config.options is not None self._options = config.options - async def async_attach( - self, - action: TriggerActionType, - trigger_info: TriggerInfo, + async def async_attach_runner( + self, run_action: TriggerActionRunner ) -> CALLBACK_TYPE: """Attach a trigger.""" - return await async_attach_trigger( - self._hass, self._options, action, trigger_info - ) + return await async_attach_trigger(self._hass, self._options, run_action) diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 96cf67524054e2..2fcf3ac9076a5e 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -10,6 +10,12 @@ "pid": "4001", "vid": "303A", }, + { + "description": "*zbt-2*", + "domain": "homeassistant_connect_zbt2", + "pid": "831A", + "vid": "303A", + }, { "description": "*skyconnect v1.0*", "domain": "homeassistant_sky_connect", diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 5c844c81cf432c..aac8be5c0ff359 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -28,8 +28,10 @@ CALLBACK_TYPE, Context, HassJob, + HassJobType, HomeAssistant, callback, + get_hassjob_callable_job_type, is_callback, ) from homeassistant.exceptions import HomeAssistantError, TemplateError @@ -178,6 +180,8 @@ async def _register_trigger_platform( class Trigger(abc.ABC): """Trigger class.""" + _hass: HomeAssistant + @classmethod async def async_validate_complete_config( cls, hass: HomeAssistant, complete_config: ConfigType @@ -212,14 +216,33 @@ async def async_validate_config( def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" + self._hass = hass - @abc.abstractmethod - async def async_attach( + async def async_attach_action( self, - action: TriggerActionType, - trigger_info: TriggerInfo, + action: TriggerAction, + action_payload_builder: TriggerActionPayloadBuilder, + ) -> CALLBACK_TYPE: + """Attach the trigger to an action.""" + + @callback + def run_action( + extra_trigger_payload: dict[str, Any], + description: str, + context: Context | None = None, + ) -> asyncio.Task[Any]: + """Run action with trigger variables.""" + + payload = action_payload_builder(extra_trigger_payload, description) + return self._hass.async_create_task(action(payload, context)) + + return await self.async_attach_runner(run_action) + + @abc.abstractmethod + async def async_attach_runner( + self, run_action: TriggerActionRunner ) -> CALLBACK_TYPE: - """Attach the trigger.""" + """Attach the trigger to an action runner.""" class TriggerProtocol(Protocol): @@ -257,14 +280,52 @@ class TriggerConfig: options: dict[str, Any] | None = None -class TriggerActionType(Protocol): +class TriggerActionRunner(Protocol): + """Protocol type for the trigger action runner helper callback.""" + + @callback + def __call__( + self, + extra_trigger_payload: dict[str, Any], + description: str, + context: Context | None = None, + ) -> asyncio.Task[Any]: + """Define trigger action runner type. + + Returns: + A Task that allows awaiting for the action to finish. + """ + + +class TriggerActionPayloadBuilder(Protocol): + """Protocol type for the trigger action payload builder.""" + + def __call__( + self, extra_trigger_payload: dict[str, Any], description: str + ) -> dict[str, Any]: + """Define trigger action payload builder type.""" + + +class TriggerAction(Protocol): """Protocol type for trigger action callback.""" async def __call__( + self, run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Define action callback type.""" + + +class TriggerActionType(Protocol): + """Protocol type for trigger action callback. + + Contrary to TriggerAction, this type supports both sync and async callables. + """ + + def __call__( self, run_variables: dict[str, Any], context: Context | None = None, - ) -> Any: + ) -> Coroutine[Any, Any, Any] | Any: """Define action callback type.""" @@ -294,7 +355,7 @@ class PluggableActionsEntry: actions: dict[ object, tuple[ - HassJob[[dict[str, Any], Context | None], Coroutine[Any, Any, None]], + HassJob[[dict[str, Any], Context | None], Coroutine[Any, Any, None] | Any], dict[str, Any], ], ] = field(default_factory=dict) @@ -477,7 +538,7 @@ async def async_with_vars( else: @functools.wraps(action) - async def with_vars( + def with_vars( run_variables: dict[str, Any], context: Context | None = None ) -> Any: """Wrap action with extra vars.""" @@ -493,6 +554,72 @@ async def with_vars( return wrapper_func +async def _async_attach_trigger_cls( + hass: HomeAssistant, + trigger_cls: type[Trigger], + trigger_key: str, + conf: ConfigType, + action: Callable, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Initialize a new Trigger class and attach it.""" + + def action_payload_builder( + extra_trigger_payload: dict[str, Any], description: str + ) -> dict[str, Any]: + """Build action variables.""" + payload = { + "trigger": { + **trigger_info["trigger_data"], + CONF_PLATFORM: trigger_key, + "description": description, + **extra_trigger_payload, + } + } + if CONF_VARIABLES in conf: + trigger_variables = conf[CONF_VARIABLES] + payload.update(trigger_variables.async_render(hass, payload)) + return payload + + # Wrap sync action so that it is always async. + # This simplifies the Trigger action runner interface by always returning a coroutine, + # removing the need for integrations to check for the return type when awaiting the action. + match get_hassjob_callable_job_type(action): + case HassJobType.Executor: + original_action = action + + async def wrapped_executor_action( + run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Wrap sync action to be called in executor.""" + return await hass.async_add_executor_job( + original_action, run_variables, context + ) + + action = wrapped_executor_action + + case HassJobType.Callback: + original_action = action + + async def wrapped_callback_action( + run_variables: dict[str, Any], context: Context | None = None + ) -> Any: + """Wrap callback action to be awaitable.""" + return original_action(run_variables, context) + + action = wrapped_callback_action + + trigger = trigger_cls( + hass, + TriggerConfig( + key=trigger_key, + target=conf.get(CONF_TARGET), + options=conf.get(CONF_OPTIONS), + ), + ) + return await trigger.async_attach_action(action, action_payload_builder) + + async def async_initialize_triggers( hass: HomeAssistant, trigger_config: list[ConfigType], @@ -532,23 +659,17 @@ async def async_initialize_triggers( trigger_data=trigger_data, ) - action_wrapper = _trigger_action_wrapper(hass, action, conf) if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) relative_trigger_key = get_relative_description_key( platform_domain, trigger_key ) trigger_cls = trigger_descriptors[relative_trigger_key] - trigger = trigger_cls( - hass, - TriggerConfig( - key=trigger_key, - target=conf.get(CONF_TARGET), - options=conf.get(CONF_OPTIONS), - ), + coro = _async_attach_trigger_cls( + hass, trigger_cls, trigger_key, conf, action, info ) - coro = trigger.async_attach(action_wrapper, info) else: + action_wrapper = _trigger_action_wrapper(hass, action, conf) coro = platform.async_attach_trigger(hass, conf, action_wrapper, info) triggers.append(create_eager_task(coro)) diff --git a/requirements_all.txt b/requirements_all.txt index 82ec86670c4cf3..ecedaec9a99318 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -563,7 +563,7 @@ auroranoaa==0.0.5 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==3.1.0 +autarco==3.2.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16b7c3075479a2..4f5efa151b63b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -518,7 +518,7 @@ auroranoaa==0.0.5 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==3.1.0 +autarco==3.2.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.7 diff --git a/tests/components/shelly/snapshots/test_number.ambr b/tests/components/shelly/snapshots/test_number.ambr index 138a0148ecbbcf..93d4198b7594f8 100644 --- a/tests/components/shelly/snapshots/test_number.ambr +++ b/tests/components/shelly/snapshots/test_number.ambr @@ -115,3 +115,119 @@ 'state': '0', }) # --- +# name: test_cury_number_entity[number.test_name_left_slot_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_name_left_slot_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Left slot intensity', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cury:0-left_slot_intensity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cury_number_entity[number.test_name_left_slot_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Left slot intensity', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_name_left_slot_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_cury_number_entity[number.test_name_right_slot_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_name_right_slot_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Right slot intensity', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cury:0-right_slot_intensity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cury_number_entity[number.test_name_right_slot_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Right slot intensity', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_name_right_slot_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- diff --git a/tests/components/shelly/snapshots/test_sensor.ambr b/tests/components/shelly/snapshots/test_sensor.ambr index 2f09492351e7dc..09dc06c514a710 100644 --- a/tests/components/shelly/snapshots/test_sensor.ambr +++ b/tests/components/shelly/snapshots/test_sensor.ambr @@ -157,6 +157,206 @@ 'state': '0', }) # --- +# name: test_cury_sensor_entity[sensor.test_name_left_slot_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_left_slot_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Left slot level', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vial_level', + 'unique_id': '123456789ABC-cury:0-cury_left_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cury_sensor_entity[sensor.test_name_left_slot_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Left slot level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_name_left_slot_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_cury_sensor_entity[sensor.test_name_left_slot_vial-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_left_slot_vial', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Left slot vial', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vial_name', + 'unique_id': '123456789ABC-cury:0-cury_left_vial', + 'unit_of_measurement': None, + }) +# --- +# name: test_cury_sensor_entity[sensor.test_name_left_slot_vial-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Left slot vial', + }), + 'context': , + 'entity_id': 'sensor.test_name_left_slot_vial', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Forest Dream', + }) +# --- +# name: test_cury_sensor_entity[sensor.test_name_right_slot_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_right_slot_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Right slot level', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vial_level', + 'unique_id': '123456789ABC-cury:0-cury_right_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cury_sensor_entity[sensor.test_name_right_slot_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Right slot level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_name_right_slot_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '84', + }) +# --- +# name: test_cury_sensor_entity[sensor.test_name_right_slot_vial-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_name_right_slot_vial', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Right slot vial', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'vial_name', + 'unique_id': '123456789ABC-cury:0-cury_right_vial', + 'unit_of_measurement': None, + }) +# --- +# name: test_cury_sensor_entity[sensor.test_name_right_slot_vial-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Right slot vial', + }), + 'context': , + 'entity_id': 'sensor.test_name_right_slot_vial', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Velvet Rose', + }) +# --- # name: test_rpc_shelly_ev_sensors[sensor.test_name_charger_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/snapshots/test_switch.ambr b/tests/components/shelly/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..a85f7c12b4d63d --- /dev/null +++ b/tests/components/shelly/snapshots/test_switch.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_cury_switch_entity[switch.test_name_left_slot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_name_left_slot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Left slot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cury_slot', + 'unique_id': '123456789ABC-cury:0-cury_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_cury_switch_entity[switch.test_name_left_slot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Left slot', + }), + 'context': , + 'entity_id': 'switch.test_name_left_slot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_cury_switch_entity[switch.test_name_right_slot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_name_right_slot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Right slot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cury_slot', + 'unique_id': '123456789ABC-cury:0-cury_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_cury_switch_entity[switch.test_name_right_slot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Right slot', + }), + 'context': , + 'entity_id': 'switch.test_name_right_slot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 0c42a20d8226a6..56eb4e7ccd8856 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -9,7 +9,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.shelly.const import UPDATE_PERIOD_MULTIPLIER +from homeassistant.components.shelly.const import DOMAIN, UPDATE_PERIOD_MULTIPLIER from homeassistant.const import ( STATE_OFF, STATE_ON, @@ -22,6 +22,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from . import ( + MOCK_MAC, init_integration, mock_rest_update, mutate_rpc_device_status, @@ -670,3 +671,57 @@ async def test_rpc_presencezone_component( assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("old_id", "new_id", "role"), + [ + ("boolean", "boolean_generic", None), + ("boolean", "boolean_has_power", "has_power"), + ("input", "input", None), # negative test, input is not a virtual component + ], +) +async def test_migrate_unique_id_virtual_components_roles( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + old_id: str, + new_id: str, + role: str | None, +) -> None: + """Test migration of unique_id for virtual components to include role.""" + entry = await init_integration(hass, 3, skip_setup=True) + unique_base = f"{MOCK_MAC}-{old_id}:200" + old_unique_id = f"{unique_base}-{old_id}" + new_unique_id = f"{unique_base}-{new_id}" + config = deepcopy(mock_rpc_device.config) + if role: + config[f"{old_id}:200"] = { + "role": role, + } + else: + config[f"{old_id}:200"] = {} + monkeypatch.setattr(mock_rpc_device, "config", config) + + entity = entity_registry.async_get_or_create( + suggested_object_id="test_name_test_sensor", + disabled_by=None, + domain=BINARY_SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("binary_sensor.test_name_test_sensor") + assert entity_entry + assert entity_entry.unique_id == new_unique_id + + assert ( + "Migrating unique_id for binary_sensor.test_name_test_sensor" in caplog.text + ) == (old_id != new_id) diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 5f42f9a131c0e1..d267c6a46c7584 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -568,3 +568,50 @@ async def test_blu_trv_number_reauth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_cury_number_entity( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test number entities for cury component.""" + status = { + "cury:0": { + "id": 0, + "slots": { + "left": { + "intensity": 70, + "on": True, + "vial": {"level": 27, "name": "Forest Dream"}, + }, + "right": { + "intensity": 70, + "on": False, + "vial": {"level": 84, "name": "Velvet Rose"}, + }, + }, + } + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + for entity in ("left_slot_intensity", "right_slot_intensity"): + entity_id = f"{NUMBER_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.test_name_left_slot_intensity", ATTR_VALUE: 80.0}, + blocking=True, + ) + mock_rpc_device.mock_update() + mock_rpc_device.cury_set.assert_called_once_with(0, slot="left", intensity=80) diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 44e13f7c1fbabc..9889ef6257e372 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1949,3 +1949,46 @@ async def test_rpc_pm1_energy_consumed_sensor_non_float_value( assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNKNOWN + + +async def test_cury_sensor_entity( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test sensor entities for cury component.""" + status = { + "cury:0": { + "id": 0, + "slots": { + "left": { + "intensity": 70, + "on": True, + "vial": {"level": 27, "name": "Forest Dream"}, + }, + "right": { + "intensity": 70, + "on": False, + "vial": {"level": 84, "name": "Velvet Rose"}, + }, + }, + } + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + for entity in ( + "left_slot_level", + "right_slot_level", + "left_slot_vial", + "right_slot_vial", + ): + entity_id = f"{SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 59245c17c08255..d026e767557449 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -8,6 +8,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.shelly.const import ( @@ -24,6 +25,7 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) @@ -35,6 +37,7 @@ from . import ( init_integration, inject_rpc_device_event, + mutate_rpc_device_status, patch_platforms, register_device, register_entity, @@ -829,3 +832,119 @@ async def test_rpc_device_script_switch( assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON mock_rpc_device.script_start.assert_called_once_with(1) + + +async def test_cury_switch_entity( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test switch entities for cury component.""" + status = { + "cury:0": { + "id": 0, + "slots": { + "left": { + "intensity": 70, + "on": True, + "vial": {"level": 27, "name": "Forest Dream"}, + }, + "right": { + "intensity": 70, + "on": False, + "vial": {"level": 84, "name": "Velvet Rose"}, + }, + }, + } + } + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + for entity in ("left_slot", "right_slot"): + entity_id = f"{SWITCH_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry") + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_name_left_slot"}, + blocking=True, + ) + mock_rpc_device.mock_update() + mock_rpc_device.cury_set.assert_called_once_with(0, "left", False) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_name_right_slot"}, + blocking=True, + ) + mock_rpc_device.mock_update() + mock_rpc_device.cury_set.assert_called_with(0, "right", True) + + +async def test_cury_switch_availability( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test availability of switch entities for cury component.""" + slots = { + "left": { + "intensity": 70, + "on": True, + "vial": {"level": 27, "name": "Forest Dream"}, + }, + "right": { + "intensity": 70, + "on": False, + "vial": {"level": 84, "name": "Velvet Rose"}, + }, + } + status = {"cury:0": {"id": 0, "slots": slots}} + monkeypatch.setattr(mock_rpc_device, "status", status) + await init_integration(hass, 3) + + entity_id = f"{SWITCH_DOMAIN}.test_name_left_slot" + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON + + slots["left"]["vial"]["level"] = -1 + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cury:0", "slots", slots) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + slots["left"].pop("vial") + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cury:0", "slots", slots) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + slots["left"] = None + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cury:0", "slots", slots) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + slots["left"] = { + "intensity": 70, + "on": True, + "vial": {"level": 27, "name": "Forest Dream"}, + } + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cury:0", "slots", slots) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_ON diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 489cb034ac23d3..5ec33d99012f63 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest -from telegram import Bot, Chat, ChatFullInfo, Message, User +from telegram import Bot, Chat, ChatFullInfo, Message, User, WebhookInfo from telegram.constants import AccentColor, ChatType from homeassistant.components.telegram_bot import ( @@ -74,11 +74,22 @@ def mock_register_webhook() -> Generator[None]: """Mock calls made by telegram_bot when (de)registering webhook.""" with ( patch( - "homeassistant.components.telegram_bot.webhooks.PushBot.register_webhook", - return_value=True, + "homeassistant.components.telegram_bot.webhooks.Bot.delete_webhook", + AsyncMock(), + ), + patch( + "homeassistant.components.telegram_bot.webhooks.Bot.get_webhook_info", + AsyncMock( + return_value=WebhookInfo( + url="mock url", + last_error_date=datetime.now(), + has_custom_certificate=False, + pending_update_count=0, + ) + ), ), patch( - "homeassistant.components.telegram_bot.webhooks.PushBot.deregister_webhook", + "homeassistant.components.telegram_bot.webhooks.Bot.set_webhook", return_value=True, ), ): @@ -113,9 +124,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._bot_user = test_user - async def delete_webhook(self) -> bool: - return True - with ( patch("homeassistant.components.telegram_bot.bot.Bot", BotMock), patch.object(BotMock, "get_chat", return_value=test_chat), diff --git a/tests/components/telegram_bot/test_webhooks.py b/tests/components/telegram_bot/test_webhooks.py index a02bb3e3358606..507e625efd7929 100644 --- a/tests/components/telegram_bot/test_webhooks.py +++ b/tests/components/telegram_bot/test_webhooks.py @@ -1,12 +1,11 @@ """Tests for webhooks.""" -from datetime import datetime from ipaddress import IPv4Network -from unittest.mock import AsyncMock, patch +from unittest.mock import patch -from telegram import WebhookInfo from telegram.error import TimedOut +from homeassistant.components.telegram_bot.const import DOMAIN from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -19,91 +18,61 @@ async def test_set_webhooks_failed( hass: HomeAssistant, mock_webhooks_config_entry: MockConfigEntry, mock_external_calls: None, - mock_generate_secret_token, + mock_register_webhook: None, ) -> None: """Test set webhooks failed.""" mock_webhooks_config_entry.add_to_hass(hass) with ( patch( - "homeassistant.components.telegram_bot.webhooks.Bot.get_webhook_info", - AsyncMock( - return_value=WebhookInfo( - url="mock url", - last_error_date=datetime.now(), - has_custom_certificate=False, - pending_update_count=0, - ) - ), - ) as mock_webhook_info, + "homeassistant.components.telegram_bot.webhooks.secrets.choice", + return_value="DEADBEEF12345678DEADBEEF87654321", + ), patch( "homeassistant.components.telegram_bot.webhooks.Bot.set_webhook", ) as mock_set_webhook, - patch( - "homeassistant.components.telegram_bot.webhooks.ApplicationBuilder" - ) as application_builder_class, ): mock_set_webhook.side_effect = [TimedOut("mock timeout"), False] - application = application_builder_class.return_value.bot.return_value.updater.return_value.build.return_value - application.initialize = AsyncMock() - application.start = AsyncMock() await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id) await hass.async_block_till_done() await hass.async_stop() - mock_webhook_info.assert_called_once() - application.initialize.assert_called_once() - application.start.assert_called_once() - assert mock_set_webhook.call_count > 0 + # first fail with exception, second fail with False + assert mock_set_webhook.call_count == 2 # SETUP_ERROR is result of ConfigEntryNotReady("Failed to register webhook with Telegram") in webhooks.py assert mock_webhooks_config_entry.state == ConfigEntryState.SETUP_ERROR + # test fail after retries + + mock_set_webhook.reset_mock() + mock_set_webhook.side_effect = TimedOut("mock timeout") + + await hass.config_entries.async_reload(mock_webhooks_config_entry.entry_id) + await hass.async_block_till_done() + + # 3 retries + assert mock_set_webhook.call_count == 3 + + assert mock_webhooks_config_entry.state == ConfigEntryState.SETUP_ERROR + await hass.async_block_till_done() + async def test_set_webhooks( hass: HomeAssistant, mock_webhooks_config_entry: MockConfigEntry, mock_external_calls: None, + mock_register_webhook: None, mock_generate_secret_token, ) -> None: """Test set webhooks success.""" mock_webhooks_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id) - with ( - patch( - "homeassistant.components.telegram_bot.webhooks.Bot.get_webhook_info", - AsyncMock( - return_value=WebhookInfo( - url="mock url", - last_error_date=datetime.now(), - has_custom_certificate=False, - pending_update_count=0, - ) - ), - ) as mock_webhook_info, - patch( - "homeassistant.components.telegram_bot.webhooks.Bot.set_webhook", - AsyncMock(return_value=True), - ) as mock_set_webhook, - patch( - "homeassistant.components.telegram_bot.webhooks.ApplicationBuilder" - ) as application_builder_class, - ): - application = application_builder_class.return_value.bot.return_value.updater.return_value.build.return_value - application.initialize = AsyncMock() - application.start = AsyncMock() - - await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id) - await hass.async_block_till_done() - await hass.async_stop() - - mock_webhook_info.assert_called_once() - application.initialize.assert_called_once() - application.start.assert_called_once() - mock_set_webhook.assert_called_once() + await hass.async_block_till_done() - assert mock_webhooks_config_entry.state == ConfigEntryState.LOADED + assert mock_webhooks_config_entry.state == ConfigEntryState.LOADED async def test_webhooks_update_invalid_json( @@ -148,3 +117,24 @@ async def test_webhooks_unauthorized_network( await hass.async_block_till_done() mock_remote.assert_called_once() + + +async def test_webhooks_deregister_failed( + hass: HomeAssistant, + webhook_platform, + mock_external_calls: None, + mock_generate_secret_token, +) -> None: + """Test deregister webhooks.""" + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.state == ConfigEntryState.LOADED + + with patch( + "homeassistant.components.telegram_bot.webhooks.Bot.delete_webhook", + ) as mock_delete_webhook: + mock_delete_webhook.side_effect = TimedOut("mock timeout") + await hass.config_entries.async_unload(config_entry.entry_id) + + mock_delete_webhook.assert_called_once() + assert config_entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 0a271057ad5fd6..cb09f6c3e9fedf 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -24,9 +24,7 @@ DATA_PLUGGABLE_ACTIONS, PluggableAction, Trigger, - TriggerActionType, - TriggerConfig, - TriggerInfo, + TriggerActionRunner, _async_get_trigger_platform, async_initialize_triggers, async_validate_trigger_config, @@ -449,7 +447,31 @@ async def test_pluggable_action( assert not plug_2 -async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: +class TriggerActionFunctionTypeHelper: + """Helper for testing different trigger action function types.""" + + def __init__(self) -> None: + """Init helper.""" + self.action_calls = [] + + @callback + def cb_action(self, *args): + """Callback action.""" + self.action_calls.append([*args]) + + def sync_action(self, *args): + """Sync action.""" + self.action_calls.append([*args]) + + async def async_action(self, *args): + """Async action.""" + self.action_calls.append([*args]) + + +@pytest.mark.parametrize("action_method", ["cb_action", "sync_action", "async_action"]) +async def test_platform_multiple_triggers( + hass: HomeAssistant, action_method: str +) -> None: """Test a trigger platform with multiple trigger.""" class MockTrigger(Trigger): @@ -462,30 +484,23 @@ async def async_validate_config( """Validate config.""" return config - def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: - """Initialize trigger.""" - class MockTrigger1(MockTrigger): """Mock trigger 1.""" - async def async_attach( - self, - action: TriggerActionType, - trigger_info: TriggerInfo, + async def async_attach_runner( + self, run_action: TriggerActionRunner ) -> CALLBACK_TYPE: """Attach a trigger.""" - action({"trigger": "test_trigger_1"}) + run_action({"extra": "test_trigger_1"}, "trigger 1 desc") class MockTrigger2(MockTrigger): """Mock trigger 2.""" - async def async_attach( - self, - action: TriggerActionType, - trigger_info: TriggerInfo, + async def async_attach_runner( + self, run_action: TriggerActionRunner ) -> CALLBACK_TYPE: """Attach a trigger.""" - action({"trigger": "test_trigger_2"}) + run_action({"extra": "test_trigger_2"}, "trigger 2 desc") async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: return { @@ -508,22 +523,41 @@ async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: log_cb = MagicMock() - action_calls = [] - - @callback - def cb_action(*args): - action_calls.append([*args]) - - await async_initialize_triggers(hass, config_1, cb_action, "test", "", log_cb) - assert action_calls == [[{"trigger": "test_trigger_1"}]] - action_calls.clear() - - await async_initialize_triggers(hass, config_2, cb_action, "test", "", log_cb) - assert action_calls == [[{"trigger": "test_trigger_2"}]] - action_calls.clear() + action_helper = TriggerActionFunctionTypeHelper() + action_method = getattr(action_helper, action_method) + + await async_initialize_triggers(hass, config_1, action_method, "test", "", log_cb) + assert len(action_helper.action_calls) == 1 + assert action_helper.action_calls[0][0] == { + "trigger": { + "alias": None, + "description": "trigger 1 desc", + "extra": "test_trigger_1", + "id": "0", + "idx": "0", + "platform": "test", + } + } + action_helper.action_calls.clear() + + await async_initialize_triggers(hass, config_2, action_method, "test", "", log_cb) + assert len(action_helper.action_calls) == 1 + assert action_helper.action_calls[0][0] == { + "trigger": { + "alias": None, + "description": "trigger 2 desc", + "extra": "test_trigger_2", + "id": "0", + "idx": "0", + "platform": "test.trig_2", + } + } + action_helper.action_calls.clear() with pytest.raises(KeyError): - await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) + await async_initialize_triggers( + hass, config_3, action_method, "test", "", log_cb + ) async def test_platform_migrate_trigger(hass: HomeAssistant) -> None: