Simplify MQTT device triggers in automations (#108309)

* Simplify MQTT device trigger

* Add test non unique trigger_id

* Adjust deprecation warning

* Make discovery_id optional

* refactor double if

* Improve validation, add tests and deprecation comments

* Avoid breaking change

* Inmprove error message

* Match on discovery_id instead of discovery_info

* Revert an unrelated change

* follow up comments

* Add comment and test on device update with non unique trigger

* Update homeassistant/components/mqtt/device_trigger.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Update homeassistant/components/mqtt/device_trigger.py

Co-authored-by: Erik Montnemery <erik@montnemery.com>

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Jan Bouwhuis 2024-01-30 20:50:39 +01:00 committed by GitHub
parent 066a0ccc6d
commit 04f0128a1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 349 additions and 49 deletions

View file

@ -34,7 +34,7 @@ from .const import (
CONF_TOPIC,
DOMAIN,
)
from .discovery import MQTTDiscoveryPayload
from .discovery import MQTTDiscoveryPayload, clear_discovery_hash
from .mixins import (
MQTT_ENTITY_DEVICE_INFO_SCHEMA,
MqttDiscoveryDeviceUpdate,
@ -62,10 +62,13 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
vol.Required(CONF_PLATFORM): DEVICE,
vol.Required(CONF_DOMAIN): DOMAIN,
vol.Required(CONF_DEVICE_ID): str,
vol.Required(CONF_DISCOVERY_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(
@ -123,6 +126,7 @@ class Trigger:
device_id: str = attr.ib()
discovery_data: DiscoveryInfoType | None = attr.ib()
discovery_id: str | None = attr.ib()
hass: HomeAssistant = attr.ib()
payload: str | None = attr.ib()
qos: int | None = attr.ib()
@ -202,6 +206,7 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate):
self.discovery_data = discovery_data
self.hass = hass
self._mqtt_data = get_mqtt_data(hass)
self.trigger_id = f"{device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}"
MqttDiscoveryDeviceUpdate.__init__(
self,
@ -216,11 +221,19 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate):
"""Initialize the device trigger."""
discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH]
discovery_id = discovery_hash[1]
if discovery_id not in self._mqtt_data.device_triggers:
self._mqtt_data.device_triggers[discovery_id] = Trigger(
# 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],
@ -229,7 +242,7 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate):
value_template=self._config[CONF_VALUE_TEMPLATE],
)
else:
await self._mqtt_data.device_triggers[discovery_id].update_trigger(
await self._mqtt_data.device_triggers[self.trigger_id].update_trigger(
self._config
)
debug_info.add_trigger_discovery_data(
@ -239,22 +252,39 @@ class MqttDeviceTrigger(MqttDiscoveryDeviceUpdate):
async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None:
"""Handle MQTT device trigger discovery updates."""
discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH]
discovery_id = discovery_hash[1]
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 = get_mqtt_data(self.hass)
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[discovery_id]
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]
discovery_id = discovery_hash[1]
if discovery_id in self._mqtt_data.device_triggers:
if self.trigger_id in self._mqtt_data.device_triggers:
_LOGGER.info("Removing trigger: %s", discovery_hash)
trigger: Trigger = self._mqtt_data.device_triggers[discovery_id]
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)
@ -267,7 +297,30 @@ async def async_setup_trigger(
) -> 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 = get_mqtt_data(hass)
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 None
if TYPE_CHECKING:
assert isinstance(device_id, str)
@ -283,8 +336,9 @@ async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None
mqtt_data = get_mqtt_data(hass)
triggers = await async_get_triggers(hass, device_id)
for trig in triggers:
device_trigger: Trigger = mqtt_data.device_triggers.pop(trig[CONF_DISCOVERY_ID])
if device_trigger:
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:
@ -303,7 +357,7 @@ async def async_get_triggers(
if not mqtt_data.device_triggers:
return triggers
for discovery_id, trig in mqtt_data.device_triggers.items():
for trig in mqtt_data.device_triggers.values():
if trig.device_id != device_id or trig.topic is None:
continue
@ -312,7 +366,6 @@ async def async_get_triggers(
"device_id": device_id,
"type": trig.type,
"subtype": trig.subtype,
"discovery_id": discovery_id,
}
triggers.append(trigger)
@ -326,15 +379,33 @@ async def async_attach_trigger(
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
trigger_id: str | None = None
mqtt_data = get_mqtt_data(hass)
device_id = config[CONF_DEVICE_ID]
discovery_id = config[CONF_DISCOVERY_ID]
if discovery_id not in mqtt_data.device_triggers:
mqtt_data.device_triggers[discovery_id] = Trigger(
# 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,
@ -342,6 +413,5 @@ async def async_attach_trigger(
qos=None,
value_template=None,
)
return await mqtt_data.device_triggers[discovery_id].add_trigger(
action, trigger_info
)
return await mqtt_data.device_triggers[trigger_id].add_trigger(action, trigger_info)