diff --git a/README.md b/README.md index 6ce5b18..ebd53ff 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ @@ -149,6 +150,36 @@ it is still busy with receiving / sending other mesh messages. ``` +
+React to every message in a channel with a 👋 emoji + +```yaml +- id: '1757011496522' + alias: React to every message in a channel with a 👋 emoji + description: '' + triggers: + - domain: meshtastic + device_id: f1837e8872a6de5d76ed0d6abd5462e6 + type: channel_message.received + entity_id: meshtastic.gateway_brig_channel_foo + trigger: device + conditions: + - condition: template + value_template: '{{ trigger.event.data.emoji == 0 }}' + alias: Do not react to reactions + actions: + - action: meshtastic.broadcast_channel_message + metadata: {} + data: + ack: false + emoji: true + channel: meshtastic.gateway_brig_channel_foo + message: 👋 + reply_id: '{{ trigger.event.data.message_id }}' + mode: single +``` +
+
Advanced: Handling incoming text messages from any node without notification platform and its entities @@ -219,6 +250,9 @@ trigger: channel: 0 gateway: 862525748 message: Sample Message + reply_id: 0 + emoji: 0 + message_id: 2770141804 ``` From contains the node id of the sender of the message, to will have the node id of the gateway for direct messages, or a gateway channel id if the message is directed at the channel. diff --git a/custom_components/meshtastic/aiomeshtastic/connection/__init__.py b/custom_components/meshtastic/aiomeshtastic/connection/__init__.py index d0a5640..feeb88e 100644 --- a/custom_components/meshtastic/aiomeshtastic/connection/__init__.py +++ b/custom_components/meshtastic/aiomeshtastic/connection/__init__.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep # SPDX-FileCopyrightText: 2025 Ovidiu D. Nițan @ov1d1u +# SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle # # SPDX-License-Identifier: MIT @@ -251,7 +252,8 @@ async def send_mesh_packet( # noqa: PLR0913 *, from_node: int | None = None, ack: bool = False, - reply_id: int | None = None, + reply_id: int = 0, + emoji: int = 0, want_response: bool = False, out_callback: Callable[[Packet], Awaitable[None]] | None = None, ack_callback: Callable[[Packet[mesh_pb2.Routing]], Awaitable[None]] | None = None, @@ -265,8 +267,8 @@ async def send_mesh_packet( # noqa: PLR0913 ) mesh_packet.decoded.portnum = port_num mesh_packet.decoded.want_response = want_response - if reply_id is not None: - mesh_packet.decoded.reply_id = reply_id + mesh_packet.decoded.reply_id = reply_id + mesh_packet.decoded.emoji = emoji mesh_packet.id = self._generate_packet_id() if from_node is not None: mesh_packet.__setattr__("from", from_node) diff --git a/custom_components/meshtastic/aiomeshtastic/interface.py b/custom_components/meshtastic/aiomeshtastic/interface.py index c6fc13a..f33d3d5 100644 --- a/custom_components/meshtastic/aiomeshtastic/interface.py +++ b/custom_components/meshtastic/aiomeshtastic/interface.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep # SPDX-FileCopyrightText: 2025 Hendrik @novag # SPDX-FileCopyrightText: 2025 Ovidiu D. Nițan @ov1d1u +# SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle # # SPDX-License-Identifier: MIT @@ -1060,7 +1061,8 @@ async def send_text_message( # noqa: PLR0912, PLR0913 want_ack: bool = False, channel_index: int | None = None, priority: MeshPacket.Priority | None = None, - reply_id: int | None = None, + reply_id: int = 0, + emoji: int = 0, on_message_sent: Callable[[Packet], Awaitable[None]] | None = None, ) -> None: if isinstance(destination, MeshNode): @@ -1109,6 +1111,7 @@ async def out_callback(packet: Packet) -> None: want_response=False, ack=want_ack, reply_id=reply_id, + emoji=emoji, out_callback=out_callback, ) diff --git a/custom_components/meshtastic/api.py b/custom_components/meshtastic/api.py index 29458a9..1922d31 100644 --- a/custom_components/meshtastic/api.py +++ b/custom_components/meshtastic/api.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep # SPDX-FileCopyrightText: 2025 Ovidiu D. Nițan @ov1d1u +# SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle # # SPDX-License-Identifier: MIT @@ -211,12 +212,15 @@ def _transform_node_info(self, node_info: Mapping[str, Any]) -> Mapping[str, Any return transformed - async def _publish_event_text_message_out( + async def _publish_event_text_message_out( # noqa: PLR0913 self, text: str, message_id: int, destination_id: int | str = MeshInterface.BROADCAST_ADDR, channel_index: int | None = None, + *, + reply_id: int, + emoji: int, ) -> None: if destination_id == MeshInterface.BROADCAST_NUM or channel_index is not None: to_channel = channel_index @@ -233,25 +237,33 @@ async def _publish_event_text_message_out( "to": {"node": to_node, "channel": to_channel}, "gateway": gateway_id, "message": text, + "reply_id": reply_id, + "emoji": emoji, }, ) event_data["message_id"] = message_id self._hass.bus.async_fire(EVENT_MESHTASTIC_API_TEXT_MESSAGE_OUT, event_data) - async def send_text( + async def send_text( # noqa: PLR0913 self, text: str, destination_id: int | str = MeshInterface.BROADCAST_ADDR, *, want_ack: bool = False, channel_index: int | None = None, - reply_id: int | None = None, + reply_id: int = 0, + emoji: int = 0, ) -> bool: async def _on_message_sent(packet: Packet) -> None: # publish event so that outgoing messages are recorded to logbook await self._publish_event_text_message_out( - text, packet.mesh_packet.id, destination_id=destination_id, channel_index=channel_index + text, + packet.mesh_packet.id, + destination_id=destination_id, + channel_index=channel_index, + reply_id=reply_id, + emoji=emoji, ) try: @@ -262,6 +274,7 @@ async def _on_message_sent(packet: Packet) -> None: want_ack=want_ack, channel_index=channel_index, reply_id=reply_id, + emoji=emoji, on_message_sent=_on_message_sent, ), timeout=30, @@ -310,17 +323,34 @@ async def _on_text_message(self, node: MeshNode, packet: Packet) -> None: to_channel = None to_node = packet.to_id + data = packet.data + if data is None: + self._logger.debug("No decoded data in text message packet, ignoring") + return + + payload = packet.app_payload + if payload is None: + self._logger.debug("No payload in text message packet, ignoring") + return + + mesh_packet = packet.mesh_packet + if mesh_packet is None: + self._logger.debug("No mesh packet in text message packet, ignoring") + return + event_data = self._build_event_data( node.id, { "from": packet.from_id, "to": {"node": to_node, "channel": to_channel}, "gateway": self.get_own_node()["num"], - "message": packet.app_payload, + "message": payload, + "reply_id": data.reply_id, + "emoji": data.emoji, }, ) - event_data["message_id"] = packet.mesh_packet.id + event_data["message_id"] = mesh_packet.id self._hass.bus.async_fire(EVENT_MESHTASTIC_API_TEXT_MESSAGE, event_data) async def _on_telemetry(self, node: MeshNode, telemetry: dict[str, Any]) -> None: diff --git a/custom_components/meshtastic/const.py b/custom_components/meshtastic/const.py index 017e6d1..314486f 100644 --- a/custom_components/meshtastic/const.py +++ b/custom_components/meshtastic/const.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep # SPDX-FileCopyrightText: 2025 Ovidiu D. Nițan @ov1d1u +# SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle # # SPDX-License-Identifier: MIT @@ -67,6 +68,7 @@ class ConfigOptionNotifyPlatformNodes(enum.StrEnum): ATTR_SERVICE_DATA_FROM = "from" ATTR_SERVICE_DATA_ACK = "ack" ATTR_SERVICE_DATA_REPLY_ID = "reply_id" +ATTR_SERVICE_DATA_EMOJI = "emoji" ATTR_SERVICE_SEND_TEXT_DATA_TEXT = "text" @@ -92,12 +94,19 @@ class MeshtasticDomainEventType(enum.StrEnum): EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE: Final = "message" +EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE_ID: Final = "message_id" +EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_REPLY_ID: Final = "reply_id" +EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_EMOJI: Final = "emoji" class MeshtasticDomainEventData(TypedDict): - CONF_DEVICE_ID: str - CONF_ENTITY_ID: str | None - EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE: str + type: MeshtasticDomainEventType + device_id: str + entity_id: str | None + message: str + message_id: int + reply_id: int + emoji: int # Primary user facing event @@ -110,12 +119,11 @@ class MeshtasticDomainEventData(TypedDict): class MeshtasticDomainMessageLogEventData(TypedDict): - CONF_DEVICE_ID: str - CONF_ENTITY_ID: str - EVENT_MESHTASTIC_MESSAGE_LOG_EVENT_DATA_ATTR_MESSAGE: str - EVENT_MESHTASTIC_MESSAGE_LOG_EVENT_DATA_ATTR_FROM_NAME: str - EVENT_MESHTASTIC_MESSAGE_LOG_EVENT_DATA_ATTR_PKI: bool - EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE: str + device_id: str + entity_id: str + message: str + from_name: str + pki: bool # Event used for logbook diff --git a/custom_components/meshtastic/logbook.py b/custom_components/meshtastic/logbook.py index 1ecc479..17133a5 100644 --- a/custom_components/meshtastic/logbook.py +++ b/custom_components/meshtastic/logbook.py @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep +# SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle # # SPDX-License-Identifier: MIT @@ -29,7 +30,10 @@ from .const import ( DOMAIN, EVENT_MESHTASTIC_DOMAIN_EVENT, + EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_EMOJI, EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE, + EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE_ID, + EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_REPLY_ID, EVENT_MESHTASTIC_DOMAIN_MESSAGE_LOG, EVENT_MESHTASTIC_MESSAGE_LOG_EVENT_DATA_ATTR_FROM_NAME, EVENT_MESHTASTIC_MESSAGE_LOG_EVENT_DATA_ATTR_MESSAGE, @@ -113,6 +117,19 @@ def _publish_message_log_event( # noqa: PLR0913 } hass.bus.async_fire(event_type=EVENT_MESHTASTIC_DOMAIN_MESSAGE_LOG, event_data=message_log_event_data) + def _build_domain_event_data( + event_type: MeshtasticDomainEventType, device_id: str, event_data: Mapping[str, Any], data: Mapping[str, Any] + ) -> MeshtasticDomainEventData: + return { + CONF_TYPE: event_type, + CONF_DEVICE_ID: device_id, + CONF_ENTITY_ID: None, + EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE: data["message"], + EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE_ID: event_data["message_id"], + EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_REPLY_ID: data["reply_id"], + EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_EMOJI: data["emoji"], + } + async def _on_text_message( event: Event, *, produce_domain_event: bool = True, produce_log_event: bool = True ) -> None: @@ -138,11 +155,9 @@ async def _on_text_message( if produce_domain_event: if from_device: - domain_event_data: MeshtasticDomainEventData = { - CONF_DEVICE_ID: from_device.id, - CONF_TYPE: MeshtasticDomainEventType.MESSAGE_SENT, - EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE: message, - } + domain_event_data = _build_domain_event_data( + MeshtasticDomainEventType.MESSAGE_SENT, from_device.id, event_data, data + ) if to_channel_entity_id: domain_event_data[CONF_ENTITY_ID] = to_channel_entity_id if to_dm_entity_id: @@ -150,17 +165,13 @@ async def _on_text_message( hass.bus.async_fire(event_type=EVENT_MESHTASTIC_DOMAIN_EVENT, event_data=domain_event_data) if to_device: - domain_event_data: MeshtasticDomainEventData = { - CONF_DEVICE_ID: to_device.id, - CONF_TYPE: MeshtasticDomainEventType.MESSAGE_RECEIVED, - EVENT_MESHTASTIC_DOMAIN_EVENT_DATA_ATTR_MESSAGE: message, - } - + domain_event_data = _build_domain_event_data( + MeshtasticDomainEventType.MESSAGE_RECEIVED, to_device.id, event_data, data + ) if to_channel_entity_id: domain_event_data[CONF_ENTITY_ID] = to_channel_entity_id if to_dm_entity_id: domain_event_data[CONF_ENTITY_ID] = to_dm_entity_id - hass.bus.async_fire(event_type=EVENT_MESHTASTIC_DOMAIN_EVENT, event_data=domain_event_data) if produce_log_event and (to_dm_entity_id or to_channel_entity_id): diff --git a/custom_components/meshtastic/services.py b/custom_components/meshtastic/services.py index 7777ff3..c9b9eee 100644 --- a/custom_components/meshtastic/services.py +++ b/custom_components/meshtastic/services.py @@ -1,5 +1,6 @@ # SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep # SPDX-FileCopyrightText: 2025 Ovidiu D. Nițan @ov1d1u +# SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle # # SPDX-License-Identifier: MIT @@ -36,6 +37,7 @@ ATTR_SERVICE_BROADCAST_CHANNEL_MESSAGE_DATA_MESSAGE, ATTR_SERVICE_DATA_ACK, ATTR_SERVICE_DATA_CHANNEL, + ATTR_SERVICE_DATA_EMOJI, ATTR_SERVICE_DATA_FROM, ATTR_SERVICE_DATA_REPLY_ID, ATTR_SERVICE_DATA_TO, @@ -62,7 +64,8 @@ vol.Optional(ATTR_SERVICE_DATA_FROM): cv.string, vol.Optional(ATTR_SERVICE_DATA_CHANNEL): cv.string, vol.Required(ATTR_SERVICE_DATA_ACK, default=False): cv.boolean, - vol.Optional(ATTR_SERVICE_DATA_REPLY_ID): cv.positive_int, + vol.Required(ATTR_SERVICE_DATA_REPLY_ID, default=0): cv.positive_int, + vol.Required(ATTR_SERVICE_DATA_EMOJI, default=False): cv.boolean, } ) @@ -71,7 +74,8 @@ vol.Required(ATTR_SERVICE_DATA_TO): cv.string, vol.Required(ATTR_SERVICE_SEND_DIRECT_MESSAGE_DATA_MESSAGE): cv.string, vol.Required(ATTR_SERVICE_DATA_ACK, default=True): cv.boolean, - vol.Optional(ATTR_SERVICE_DATA_REPLY_ID): cv.positive_int, + vol.Required(ATTR_SERVICE_DATA_REPLY_ID, default=0): cv.positive_int, + vol.Required(ATTR_SERVICE_DATA_EMOJI, default=False): cv.boolean, } ) @@ -80,7 +84,8 @@ vol.Required(ATTR_SERVICE_BROADCAST_CHANNEL_MESSAGE_DATA_CHANNEL): cv.string, vol.Required(ATTR_SERVICE_BROADCAST_CHANNEL_MESSAGE_DATA_MESSAGE): cv.string, vol.Required(ATTR_SERVICE_DATA_ACK, default=True): cv.boolean, - vol.Optional(ATTR_SERVICE_DATA_REPLY_ID): cv.positive_int, + vol.Required(ATTR_SERVICE_DATA_REPLY_ID, default=0): cv.positive_int, + vol.Required(ATTR_SERVICE_DATA_EMOJI, default=False): cv.boolean, } ) @@ -292,7 +297,8 @@ async def handle_service_call(call: ServiceCall) -> ServiceResponse | object: text=text, destination_id=to_node_id, want_ack=call.data[ATTR_SERVICE_DATA_ACK], - reply_id=call.data.get(ATTR_SERVICE_DATA_REPLY_ID, None), + reply_id=call.data[ATTR_SERVICE_DATA_REPLY_ID], + emoji=1 if call.data[ATTR_SERVICE_DATA_EMOJI] else 0, ) return None @@ -328,7 +334,8 @@ async def handle_service_call(call: ServiceCall) -> ServiceResponse | object: text=text, channel_index=channel_index, want_ack=call.data[ATTR_SERVICE_DATA_ACK], - reply_id=call.data.get(ATTR_SERVICE_DATA_REPLY_ID, None), + reply_id=call.data[ATTR_SERVICE_DATA_REPLY_ID], + emoji=1 if call.data[ATTR_SERVICE_DATA_EMOJI] else 0, ) return None @@ -372,7 +379,8 @@ async def handler(call: ServiceCall, to: int, channel_index: int | None) -> None destination_id=to, channel_index=channel_index, want_ack=call.data[ATTR_SERVICE_DATA_ACK], - reply_id=call.data.get(ATTR_SERVICE_DATA_REPLY_ID, None), + reply_id=call.data[ATTR_SERVICE_DATA_REPLY_ID], + emoji=1 if call.data[ATTR_SERVICE_DATA_EMOJI] else 0, ) _service_handlers[entry.entry_id][SERVICE_SEND_TEXT] = await _build_default_handler(hass, client, handler) diff --git a/custom_components/meshtastic/services.yaml b/custom_components/meshtastic/services.yaml index 7820875..06203a9 100644 --- a/custom_components/meshtastic/services.yaml +++ b/custom_components/meshtastic/services.yaml @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep +# SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle # # SPDX-License-Identifier: MIT @@ -37,8 +38,11 @@ send_text: reply_id: required: false selector: - text: - multiline: false + template: + emoji: + required: true + selector: + boolean: send_direct_message: fields: @@ -60,8 +64,11 @@ send_direct_message: reply_id: required: false selector: - text: - multiline: false + template: + emoji: + required: true + selector: + boolean: broadcast_channel_message: fields: @@ -85,8 +92,11 @@ broadcast_channel_message: reply_id: required: false selector: - text: - multiline: false + template: + emoji: + required: true + selector: + boolean: request_telemetry: fields: diff --git a/custom_components/meshtastic/translations/en.json b/custom_components/meshtastic/translations/en.json index 8713c8c..def7543 100644 --- a/custom_components/meshtastic/translations/en.json +++ b/custom_components/meshtastic/translations/en.json @@ -179,7 +179,11 @@ }, "reply_id": { "name": "Reply ID", - "description": "A message ID to reply to." + "description": "If set and non-zero, this message is intended to be a reply to a previously sent message with the defined ID. Using a constant value for this field is not very useful, you probably want to use a template such as '{{ trigger.event.data.message_id }}'." + }, + "emoji": { + "name": "Emoji", + "description": "If true, then what is in the payload should be treated as an emoji, like giving a message a heart or poop emoji." } } }, @@ -201,7 +205,11 @@ }, "reply_id": { "name": "Reply ID", - "description": "A message ID to reply to." + "description": "If set and non-zero, this message is intended to be a reply to a previously sent message with the defined ID. Using a constant value for this field is not very useful, you probably want to use a template such as '{{ trigger.event.data.message_id }}'." + }, + "emoji": { + "name": "Emoji", + "description": "If true, then what is in the payload should be treated as an emoji, like giving a message a heart or poop emoji." } } }, @@ -223,7 +231,11 @@ }, "reply_id": { "name": "Reply ID", - "description": "A message ID to reply to." + "description": "If set and non-zero, this message is intended to be a reply to a previously sent message with the defined ID. Using a constant value for this field is not very useful, you probably want to use a template such as '{{ trigger.event.data.message_id }}'." + }, + "emoji": { + "name": "Emoji", + "description": "If true, then what is in the payload should be treated as an emoji, like giving a message a heart or poop emoji." } } }, diff --git a/custom_components/meshtastic/translations/en.json.license b/custom_components/meshtastic/translations/en.json.license index ca40bd5..5e213e3 100644 --- a/custom_components/meshtastic/translations/en.json.license +++ b/custom_components/meshtastic/translations/en.json.license @@ -1,3 +1,4 @@ SPDX-FileCopyrightText: 2024-2025 Pascal Brogle @broglep +SPDX-FileCopyrightText: 2025 Zdeněk Biberle @zdenek-biberle SPDX-License-Identifier: MIT