"""Provides device automations for MQTT.""" from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, field import logging from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE, CONF_VALUE_TEMPLATE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import debug_info, trigger as mqtt_trigger from .config import MQTT_BASE_SCHEMA from .const import ( ATTR_DISCOVERY_HASH, CONF_ENCODING, CONF_PAYLOAD, CONF_QOS, CONF_TOPIC, DOMAIN, ) from .discovery import MQTTDiscoveryPayload, clear_discovery_hash from .entity import MqttDiscoveryDeviceUpdateMixin, send_discovery_done, update_device from .models import DATA_MQTT from .schemas import MQTT_ENTITY_DEVICE_INFO_SCHEMA _LOGGER = logging.getLogger(__name__) CONF_AUTOMATION_TYPE = "automation_type" CONF_DISCOVERY_ID = "discovery_id" CONF_SUBTYPE = "subtype" DEFAULT_ENCODING = "utf-8" DEVICE = "device" MQTT_TRIGGER_BASE = { # Trigger when MQTT message is received CONF_PLATFORM: DEVICE, CONF_DOMAIN: DOMAIN, } TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): DEVICE, vol.Required(CONF_DOMAIN): DOMAIN, vol.Required(CONF_DEVICE_ID): str, # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. # By default, a MQTT device trigger now will be referenced by # device_id, type and subtype instead. vol.Optional(CONF_DISCOVERY_ID): str, vol.Required(CONF_TYPE): cv.string, vol.Required(CONF_SUBTYPE): cv.string, }, ) TRIGGER_DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( { vol.Required(CONF_AUTOMATION_TYPE): str, vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, vol.Optional(CONF_PAYLOAD, default=None): vol.Any(None, cv.string), vol.Required(CONF_SUBTYPE): cv.string, vol.Required(CONF_TOPIC): cv.string, vol.Required(CONF_TYPE): cv.string, vol.Optional(CONF_VALUE_TEMPLATE, default=None): vol.Any(None, cv.string), }, extra=vol.REMOVE_EXTRA, ) LOG_NAME = "Device trigger" @dataclass(slots=True) class TriggerInstance: """Attached trigger settings.""" action: TriggerActionType trigger_info: TriggerInfo trigger: Trigger remove: CALLBACK_TYPE | None = None async def async_attach_trigger(self) -> None: """Attach MQTT trigger.""" mqtt_config: dict[str, Any] = { CONF_PLATFORM: DOMAIN, CONF_TOPIC: self.trigger.topic, CONF_ENCODING: DEFAULT_ENCODING, CONF_QOS: self.trigger.qos, } if self.trigger.payload: mqtt_config[CONF_PAYLOAD] = self.trigger.payload if self.trigger.value_template: mqtt_config[CONF_VALUE_TEMPLATE] = self.trigger.value_template mqtt_config = mqtt_trigger.TRIGGER_SCHEMA(mqtt_config) if self.remove: self.remove() self.remove = await mqtt_trigger.async_attach_trigger( self.trigger.hass, mqtt_config, self.action, self.trigger_info, ) @dataclass(slots=True, kw_only=True) class Trigger: """Device trigger settings.""" device_id: str discovery_data: DiscoveryInfoType | None = None discovery_id: str | None = None hass: HomeAssistant payload: str | None qos: int | None subtype: str topic: str | None type: str value_template: str | None trigger_instances: list[TriggerInstance] = field(default_factory=list) async def add_trigger( self, action: TriggerActionType, trigger_info: TriggerInfo ) -> Callable[[], None]: """Add MQTT trigger.""" instance = TriggerInstance(action, trigger_info, self) self.trigger_instances.append(instance) if self.topic is not None: # If we know about the trigger, subscribe to MQTT topic await instance.async_attach_trigger() @callback def async_remove() -> None: """Remove trigger.""" if instance not in self.trigger_instances: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="mqtt_trigger_cannot_remove_twice", ) if instance.remove: instance.remove() self.trigger_instances.remove(instance) return async_remove async def update_trigger(self, config: ConfigType) -> None: """Update MQTT device trigger.""" self.type = config[CONF_TYPE] self.subtype = config[CONF_SUBTYPE] self.payload = config[CONF_PAYLOAD] self.qos = config[CONF_QOS] topic_changed = self.topic != config[CONF_TOPIC] self.topic = config[CONF_TOPIC] self.value_template = config[CONF_VALUE_TEMPLATE] # Unsubscribe+subscribe if this trigger is in use and topic has changed # If topic is same unsubscribe+subscribe will execute in the wrong order # because unsubscribe is done with help of async_create_task if topic_changed: for trig in self.trigger_instances: await trig.async_attach_trigger() def detach_trigger(self) -> None: """Remove MQTT device trigger.""" # Mark trigger as unknown self.topic = None # Unsubscribe if this trigger is in use for trig in self.trigger_instances: if trig.remove: trig.remove() trig.remove = None class MqttDeviceTrigger(MqttDiscoveryDeviceUpdateMixin): """Setup a MQTT device trigger with auto discovery.""" def __init__( self, hass: HomeAssistant, config: ConfigType, device_id: str, discovery_data: DiscoveryInfoType, config_entry: ConfigEntry, ) -> None: """Initialize.""" self._config = config self._config_entry = config_entry self.device_id = device_id self.discovery_data = discovery_data self.hass = hass self._mqtt_data = hass.data[DATA_MQTT] self.trigger_id = f"{device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" MqttDiscoveryDeviceUpdateMixin.__init__( self, hass, discovery_data, device_id, config_entry, LOG_NAME, ) async def async_setup(self) -> None: """Initialize the device trigger.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] discovery_id = discovery_hash[1] # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. # To make sure old automation keep working we determine the trigger_id # based on the discovery_id if it is set. for trigger_id, trigger in self._mqtt_data.device_triggers.items(): if trigger.discovery_id == discovery_id: self.trigger_id = trigger_id break if self.trigger_id not in self._mqtt_data.device_triggers: self._mqtt_data.device_triggers[self.trigger_id] = Trigger( hass=self.hass, device_id=self.device_id, discovery_data=self.discovery_data, discovery_id=discovery_id, type=self._config[CONF_TYPE], subtype=self._config[CONF_SUBTYPE], topic=self._config[CONF_TOPIC], payload=self._config[CONF_PAYLOAD], qos=self._config[CONF_QOS], value_template=self._config[CONF_VALUE_TEMPLATE], ) else: await self._mqtt_data.device_triggers[self.trigger_id].update_trigger( self._config ) debug_info.add_trigger_discovery_data( self.hass, discovery_hash, self.discovery_data, self.device_id ) async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None: """Handle MQTT device trigger discovery updates.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] debug_info.update_trigger_discovery_data( self.hass, discovery_hash, discovery_data ) config = TRIGGER_DISCOVERY_SCHEMA(discovery_data) new_trigger_id = f"{self.device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}" if new_trigger_id != self.trigger_id: mqtt_data = self.hass.data[DATA_MQTT] if new_trigger_id in mqtt_data.device_triggers: _LOGGER.error( "Cannot update device trigger %s due to an existing duplicate " "device trigger with the same device_id, " "type and subtype. Got: %s", discovery_hash, config, ) return # Update trigger_id based index after update of type or subtype mqtt_data.device_triggers[new_trigger_id] = mqtt_data.device_triggers.pop( self.trigger_id ) self.trigger_id = new_trigger_id update_device(self.hass, self._config_entry, config) device_trigger: Trigger = self._mqtt_data.device_triggers[self.trigger_id] await device_trigger.update_trigger(config) async def async_tear_down(self) -> None: """Cleanup device trigger.""" discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] if self.trigger_id in self._mqtt_data.device_triggers: _LOGGER.info("Removing trigger: %s", discovery_hash) trigger: Trigger = self._mqtt_data.device_triggers[self.trigger_id] trigger.discovery_data = None trigger.detach_trigger() debug_info.remove_trigger_discovery_data(self.hass, discovery_hash) async def async_setup_trigger( hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry, discovery_data: DiscoveryInfoType, ) -> None: """Set up the MQTT device trigger.""" config = TRIGGER_DISCOVERY_SCHEMA(config) # We update the device based on the trigger config to obtain the device_id. # In all cases the setup will lead to device entry to be created or updated. # If the trigger is a duplicate, trigger creation will be cancelled but we allow # the device data to be updated to not add additional complexity to the code. device_id = update_device(hass, config_entry, config) discovery_id = discovery_data[ATTR_DISCOVERY_HASH][1] trigger_type = config[CONF_TYPE] trigger_subtype = config[CONF_SUBTYPE] trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}" mqtt_data = hass.data[DATA_MQTT] if ( trigger_id in mqtt_data.device_triggers and mqtt_data.device_triggers[trigger_id].discovery_data is not None ): _LOGGER.error( "Config for device trigger %s conflicts with existing " "device trigger, cannot set up trigger, got: %s", discovery_id, config, ) send_discovery_done(hass, discovery_data) clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) return if TYPE_CHECKING: assert isinstance(device_id, str) mqtt_device_trigger = MqttDeviceTrigger( hass, config, device_id, discovery_data, config_entry ) await mqtt_device_trigger.async_setup() send_discovery_done(hass, discovery_data) async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None: """Handle Mqtt removed from a device.""" mqtt_data = hass.data[DATA_MQTT] triggers = await async_get_triggers(hass, device_id) for trig in triggers: trigger_id = f"{device_id}_{trig[CONF_TYPE]}_{trig[CONF_SUBTYPE]}" if trigger_id in mqtt_data.device_triggers: device_trigger = mqtt_data.device_triggers.pop(trigger_id) device_trigger.detach_trigger() discovery_data = device_trigger.discovery_data if TYPE_CHECKING: assert discovery_data is not None discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] debug_info.remove_trigger_discovery_data(hass, discovery_hash) async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for MQTT devices.""" mqtt_data = hass.data[DATA_MQTT] if not mqtt_data.device_triggers: return [] return [ { **MQTT_TRIGGER_BASE, "device_id": device_id, "type": trig.type, "subtype": trig.subtype, } for trig in mqtt_data.device_triggers.values() if trig.device_id == device_id and trig.topic is not None ] async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, action: TriggerActionType, trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" trigger_id: str | None = None mqtt_data = hass.data[DATA_MQTT] device_id = config[CONF_DEVICE_ID] # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2. # In case CONF_DISCOVERY_ID is still used in an automation, # we reference the device trigger by discovery_id instead of # referencing it by device_id, type and subtype, which is the default. discovery_id: str | None = config.get(CONF_DISCOVERY_ID) if discovery_id is not None: for trig_id, trig in mqtt_data.device_triggers.items(): if trig.discovery_id == discovery_id: trigger_id = trig_id break # Reference the device trigger by device_id, type and subtype. if trigger_id is None: trigger_type = config[CONF_TYPE] trigger_subtype = config[CONF_SUBTYPE] trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}" if trigger_id not in mqtt_data.device_triggers: mqtt_data.device_triggers[trigger_id] = Trigger( hass=hass, device_id=device_id, discovery_data=None, discovery_id=discovery_id, type=config[CONF_TYPE], subtype=config[CONF_SUBTYPE], topic=None, payload=None, qos=None, value_template=None, ) return await mqtt_data.device_triggers[trigger_id].add_trigger(action, trigger_info)