chatwire-mqtt 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatwire-mqtt
3
+ Version: 1.0.0
4
+ Summary: Publish every inbound iMessage to an MQTT broker (Home Assistant, Node-RED, etc.).
5
+ Author: Allen Bina
6
+ License: MIT
7
+ Keywords: chatwire,home-assistant,imessage,mqtt,node-red,plugin
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: paho-mqtt>=1.6
16
+ Description-Content-Type: text/markdown
17
+
18
+ # chatwire-mqtt\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
@@ -0,0 +1 @@
1
+ # chatwire-mqtt\n\nChatwire plugin. See https://github.com/allenbina/chatwire for details.
@@ -0,0 +1,487 @@
1
+ """chatwire-mqtt — Publish every inbound iMessage to an MQTT broker,
2
+ and optionally send iMessages via an outbound MQTT topic.
3
+
4
+ Useful for home-automation pipelines (Home Assistant, Node-RED, OpenHAB)
5
+ and any subscriber that can speak MQTT.
6
+
7
+ Install with:
8
+ pipx inject chatwire chatwire-mqtt
9
+ # or: pip install chatwire-mqtt
10
+
11
+ Then add to config.json:
12
+ {
13
+ "integrations": {
14
+ "chatwire_mqtt": {
15
+ "enabled": true,
16
+ "host": "192.168.1.100",
17
+ "port": 1883,
18
+ "topic": "chatwire/messages",
19
+ "username": "mqttuser",
20
+ "password": "s3cr3t",
21
+ "qos": 0,
22
+ "use_tls": false,
23
+ "ca_cert": "",
24
+ "send_topic": "chatwire/send"
25
+ }
26
+ }
27
+ }
28
+
29
+ TLS / encrypted brokers
30
+ -----------------------
31
+ Set ``use_tls: true`` to connect over TLS (typical port: 8883).
32
+ Leave ``ca_cert`` blank to verify with the system CA bundle, or set it to
33
+ the path of a PEM-formatted CA certificate file for self-signed brokers::
34
+
35
+ "use_tls": true,
36
+ "port": 8883,
37
+ "ca_cert": "/etc/ssl/certs/my-broker-ca.pem"
38
+
39
+ Published topic layout
40
+ -----------------------
41
+ 1:1 message → <topic>/<sanitized_handle>
42
+ Group chat → <topic>/group/<sanitized_chat_identifier>
43
+
44
+ The JSON payload (v=1) schema:
45
+ {
46
+ "v": 1,
47
+ "rowid": 12345,
48
+ "handle": "+15551234567",
49
+ "text": "Hey!",
50
+ "is_from_me": false,
51
+ "chat": {
52
+ "guid": "iMessage;-;+15551234567",
53
+ "identifier": "+15551234567",
54
+ "name": null,
55
+ "is_group": false
56
+ }
57
+ }
58
+
59
+ Outbound relay (MQTT → iMessage)
60
+ ---------------------------------
61
+ Set ``send_topic`` to a topic string (e.g. ``chatwire/send``) to subscribe
62
+ for outbound sends. Publish a JSON payload to that topic and chatwire will
63
+ send an iMessage on your behalf::
64
+
65
+ # 1:1 message
66
+ {"handle": "+15551234567", "text": "Hello from Node-RED!"}
67
+
68
+ # Group chat (use the chat GUID from the inbound payload's chat.guid)
69
+ {"chat": "iMessage;+;chat629...", "text": "Hi group!", "label": "My Group"}
70
+
71
+ Both ``handle`` and ``text`` (or ``chat`` and ``text``) are required.
72
+ ``label`` is optional and used only for logging.
73
+
74
+ Leave ``send_topic`` blank (the default) to disable inbound subscriptions.
75
+ """
76
+ from __future__ import annotations
77
+
78
+ import asyncio
79
+ import json
80
+ import logging
81
+ import re
82
+ from typing import Any
83
+
84
+ log = logging.getLogger(__name__)
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Lazy imports: integrations.base is only available inside the chatwire bridge.
88
+ # ---------------------------------------------------------------------------
89
+ try:
90
+ from integrations.base import BridgeContext, InboundMessage, SendTarget # type: ignore[import]
91
+ except ImportError: # pragma: no cover
92
+ BridgeContext = object # type: ignore[misc,assignment]
93
+ InboundMessage = object # type: ignore[misc,assignment]
94
+ SendTarget = None # type: ignore[assignment]
95
+
96
+ # paho-mqtt is optional at import time so unit tests can import this module
97
+ # without the library installed.
98
+ try:
99
+ import paho.mqtt.client as _paho # type: ignore[import]
100
+ _PAHO_AVAILABLE = True
101
+ except ImportError: # pragma: no cover
102
+ _paho = None # type: ignore[assignment]
103
+ _PAHO_AVAILABLE = False
104
+
105
+ try:
106
+ from web import log_stream as _ls # type: ignore[import]
107
+ _HAS_LOG_STREAM = True
108
+ except ImportError: # pragma: no cover
109
+ _ls = None # type: ignore[assignment]
110
+ _HAS_LOG_STREAM = False
111
+
112
+
113
+ def _log_send_future_error(fut: Any, label: str) -> None:
114
+ """Done-callback for run_coroutine_threadsafe futures.
115
+
116
+ Logs BroadcastBlockedError / RateLimitError so silent swallowing of
117
+ anti-spam blocks is avoided. Never raises (callbacks must not raise).
118
+ """
119
+ try:
120
+ exc = fut.exception()
121
+ except Exception:
122
+ return
123
+ if exc is None:
124
+ return
125
+ # Import lazily — chat_send is not available in plugin unit-test env.
126
+ try:
127
+ from chat_send import BroadcastBlockedError, RateLimitError # noqa: PLC0415
128
+ except ImportError:
129
+ log.error("%s send failed: %s: %s", label, type(exc).__name__, exc)
130
+ return
131
+ if isinstance(exc, BroadcastBlockedError):
132
+ log.error(
133
+ "%s blocked by anti-spam fuse (step=%d): %s",
134
+ label, exc.step, exc,
135
+ )
136
+ _ls_error("mqtt", f"outbound blocked: {exc}")
137
+ elif isinstance(exc, RateLimitError):
138
+ log.warning("%s rate-limited: %s", label, exc)
139
+ _ls_warn("mqtt", f"outbound rate-limited: {exc}")
140
+ else:
141
+ log.error("%s send failed: %s: %s", label, type(exc).__name__, exc)
142
+ _ls_error("mqtt", f"outbound error: {type(exc).__name__}: {exc}")
143
+
144
+
145
+ def _ls_info(tag: str, msg: str) -> None:
146
+ if _HAS_LOG_STREAM and _ls is not None:
147
+ _ls.info(tag, msg)
148
+
149
+
150
+ def _ls_warn(tag: str, msg: str) -> None:
151
+ if _HAS_LOG_STREAM and _ls is not None:
152
+ _ls.warn(tag, msg)
153
+
154
+
155
+ def _ls_error(tag: str, msg: str) -> None:
156
+ if _HAS_LOG_STREAM and _ls is not None:
157
+ _ls.error(tag, msg)
158
+
159
+
160
+ # MQTT topic segments may not contain +, #, NUL, or / (per spec §4.7).
161
+ _UNSAFE_TOPIC = re.compile(r'[+#/\x00]')
162
+
163
+
164
+ def _sanitize_topic_segment(s: str) -> str:
165
+ """Replace MQTT-reserved characters with underscores for safe topic use."""
166
+ return _UNSAFE_TOPIC.sub('_', s) or "_"
167
+
168
+
169
+ class MQTTIntegration:
170
+ """Publish every inbound iMessage to an MQTT broker.
171
+
172
+ Each message is serialised as JSON and published to:
173
+ <topic>/<handle> (1:1 conversations)
174
+ <topic>/group/<chat_id> (group chats)
175
+
176
+ Handle and chat_id are sanitized to replace MQTT wildcard characters
177
+ (+, #, /) with underscores.
178
+ """
179
+
180
+ NAME = "chatwire_mqtt"
181
+ TIER = "official"
182
+ DISPLAY_NAME = "MQTT"
183
+ DESCRIPTION = "Publish every inbound iMessage to an MQTT broker (Home Assistant, Node-RED, etc.)"
184
+ ICON = "📡"
185
+
186
+ SETTINGS_SCHEMA: dict[str, Any] = {
187
+ "type": "object",
188
+ "properties": {
189
+ "enabled": {
190
+ "type": "boolean",
191
+ "default": False,
192
+ "title": "Enable MQTT integration",
193
+ "x-ui-order": 0,
194
+ },
195
+ "host": {
196
+ "type": "string",
197
+ "title": "Broker host",
198
+ "description": "Hostname or IP of your MQTT broker.",
199
+ "x-ui-placeholder": "192.168.1.100",
200
+ "x-ui-order": 1,
201
+ },
202
+ "port": {
203
+ "type": "integer",
204
+ "default": 1883,
205
+ "minimum": 1,
206
+ "maximum": 65535,
207
+ "title": "Broker port",
208
+ "x-ui-order": 2,
209
+ },
210
+ "topic": {
211
+ "type": "string",
212
+ "default": "chatwire/messages",
213
+ "title": "Base topic",
214
+ "description": (
215
+ "Messages are published to <topic>/<handle> (1:1) "
216
+ "or <topic>/group/<chat_id> (group)."
217
+ ),
218
+ "x-ui-order": 3,
219
+ },
220
+ "username": {
221
+ "type": "string",
222
+ "default": "",
223
+ "title": "Username (optional)",
224
+ "x-ui-order": 4,
225
+ },
226
+ "password": {
227
+ "type": "string",
228
+ "default": "",
229
+ "title": "Password (optional)",
230
+ "x-ui-type": "password",
231
+ "x-ui-order": 5,
232
+ },
233
+ "qos": {
234
+ "type": "integer",
235
+ "default": 0,
236
+ "enum": [0, 1, 2],
237
+ "title": "QoS level",
238
+ "description": "0 = at-most-once, 1 = at-least-once, 2 = exactly-once.",
239
+ "x-ui-order": 6,
240
+ },
241
+ "client_id": {
242
+ "type": "string",
243
+ "default": "chatwire",
244
+ "title": "Client ID (optional)",
245
+ "description": "MQTT client identifier. Must be unique on the broker.",
246
+ "x-ui-order": 7,
247
+ },
248
+ "use_tls": {
249
+ "type": "boolean",
250
+ "default": False,
251
+ "title": "Use TLS/SSL",
252
+ "description": (
253
+ "Encrypt the broker connection with TLS. "
254
+ "Set port to 8883 for standard MQTT-over-TLS."
255
+ ),
256
+ "x-ui-order": 8,
257
+ },
258
+ "ca_cert": {
259
+ "type": "string",
260
+ "default": "",
261
+ "title": "CA certificate path (optional)",
262
+ "description": (
263
+ "Absolute path to a PEM CA certificate file. "
264
+ "Leave blank to use the system CA bundle."
265
+ ),
266
+ "x-ui-placeholder": "/etc/ssl/certs/broker-ca.pem",
267
+ "x-ui-order": 9,
268
+ },
269
+ "send_topic": {
270
+ "type": "string",
271
+ "default": "",
272
+ "title": "Outbound send topic (optional)",
273
+ "description": (
274
+ "Subscribe to this topic to send iMessages from automations. "
275
+ "Payload: {\"handle\": \"+1...\", \"text\": \"...\"} for 1:1, "
276
+ "or {\"chat\": \"iMessage;+;...\", \"text\": \"...\"} for groups. "
277
+ "Leave blank to disable."
278
+ ),
279
+ "x-ui-placeholder": "chatwire/send",
280
+ "x-ui-order": 10,
281
+ },
282
+ },
283
+ "required": ["host"],
284
+ }
285
+
286
+ def __init__(self, config: dict[str, Any]) -> None:
287
+ self._host: str = config.get("host") or ""
288
+ self._port: int = int(config.get("port") or 1883)
289
+ self._topic: str = (config.get("topic") or "chatwire/messages").rstrip("/")
290
+ self._username: str = config.get("username") or ""
291
+ self._password: str = config.get("password") or ""
292
+ self._qos: int = int(config.get("qos") or 0)
293
+ self._client_id: str = config.get("client_id") or "chatwire"
294
+ self._use_tls: bool = bool(config.get("use_tls", False))
295
+ self._ca_cert: str = config.get("ca_cert") or ""
296
+ self._send_topic: str = config.get("send_topic") or ""
297
+ self._client: Any = None # paho.mqtt.client.Client instance
298
+ self._ctx: Any = None # BridgeContext stashed in start()
299
+ self._loop: asyncio.AbstractEventLoop | None = None # bridge event loop
300
+
301
+ async def start(self, ctx: Any) -> None:
302
+ if not self._host:
303
+ raise ValueError("chatwire_mqtt: 'host' is required")
304
+ if not _PAHO_AVAILABLE:
305
+ raise RuntimeError(
306
+ "chatwire_mqtt: paho-mqtt is not installed. "
307
+ "Run: pip install paho-mqtt"
308
+ )
309
+
310
+ client = _paho.Client(client_id=self._client_id)
311
+ if self._username:
312
+ client.username_pw_set(self._username, self._password or None)
313
+
314
+ if self._use_tls:
315
+ try:
316
+ client.tls_set(ca_certs=self._ca_cert or None)
317
+ except Exception as exc:
318
+ raise RuntimeError(
319
+ f"chatwire_mqtt: TLS setup failed: {exc}"
320
+ ) from exc
321
+
322
+ self._ctx = ctx
323
+ self._loop = asyncio.get_event_loop()
324
+
325
+ # on_connect / on_disconnect for logging.
326
+ def _on_connect(c: Any, userdata: Any, flags: Any, rc: int) -> None:
327
+ if rc == 0:
328
+ log.info("mqtt: connected to %s:%d", self._host, self._port)
329
+ _ls_info("mqtt", f"connected to {self._host}:{self._port}")
330
+ if self._send_topic:
331
+ c.subscribe(self._send_topic)
332
+ log.info("mqtt: subscribed to send_topic=%s", self._send_topic)
333
+ _ls_info("mqtt", f"subscribed to send_topic={self._send_topic}")
334
+ else:
335
+ log.warning("mqtt: connect failed rc=%d", rc)
336
+ _ls_warn("mqtt", f"connect failed rc={rc}")
337
+
338
+ def _on_disconnect(c: Any, userdata: Any, rc: int) -> None:
339
+ if rc != 0:
340
+ log.warning("mqtt: unexpected disconnect rc=%d", rc)
341
+
342
+ client.on_connect = _on_connect
343
+ client.on_disconnect = _on_disconnect
344
+ if self._send_topic:
345
+ client.on_message = self._on_outbound_message
346
+
347
+ try:
348
+ client.connect(self._host, self._port, keepalive=60)
349
+ except OSError as exc:
350
+ raise RuntimeError(
351
+ f"chatwire_mqtt: cannot connect to {self._host}:{self._port}: {exc}"
352
+ ) from exc
353
+
354
+ client.loop_start()
355
+ self._client = client
356
+ log.info(
357
+ "mqtt integration started; broker=%s:%d topic=%s qos=%d tls=%s send_topic=%r",
358
+ self._host, self._port, self._topic, self._qos, self._use_tls, self._send_topic,
359
+ )
360
+
361
+ async def stop(self) -> None:
362
+ if self._client is not None:
363
+ try:
364
+ self._client.loop_stop()
365
+ self._client.disconnect()
366
+ except Exception:
367
+ pass
368
+ self._client = None
369
+ self._ctx = None
370
+ self._loop = None
371
+ log.info("mqtt integration stopped")
372
+
373
+ async def on_inbound(self, msg: Any) -> None:
374
+ """Publish the inbound message as JSON to the configured MQTT topic."""
375
+ if self._client is None:
376
+ return
377
+
378
+ is_group = bool(getattr(msg, "is_group", False))
379
+ handle = getattr(msg, "handle", "") or ""
380
+ chat_identifier = getattr(msg, "chat_identifier", "") or handle
381
+
382
+ if is_group:
383
+ seg = _sanitize_topic_segment(chat_identifier)
384
+ full_topic = f"{self._topic}/group/{seg}"
385
+ else:
386
+ seg = _sanitize_topic_segment(handle)
387
+ full_topic = f"{self._topic}/{seg}"
388
+
389
+ payload = self._payload_for(msg)
390
+ body = json.dumps(payload, ensure_ascii=False, default=str)
391
+
392
+ try:
393
+ result = self._client.publish(full_topic, body, qos=self._qos)
394
+ # result.rc == 0 (MQTT_ERR_SUCCESS) on success.
395
+ if result.rc != 0:
396
+ log.warning("mqtt: publish to %s failed rc=%d", full_topic, result.rc)
397
+ _ls_warn("mqtt", f"publish {full_topic} failed rc={result.rc}")
398
+ else:
399
+ _ls_info("mqtt", f"publish → {full_topic}")
400
+ except Exception as exc:
401
+ log.warning("mqtt: publish failed: %s: %s", type(exc).__name__, exc)
402
+ _ls_error("mqtt", f"publish failed: {type(exc).__name__}: {exc}")
403
+
404
+ def _on_outbound_message(self, client: Any, userdata: Any, message: Any) -> None:
405
+ """Handle an MQTT message on send_topic and relay it as an iMessage.
406
+
407
+ Called by paho on its network thread. Parses the JSON payload,
408
+ builds a SendTarget, and schedules ctx.send_text() on the bridge loop.
409
+
410
+ Payload (1:1): {"handle": "+15551234567", "text": "Hello!"}
411
+ Payload (group): {"chat": "iMessage;+;chat123", "text": "Hi!", "label": "My Group"}
412
+
413
+ ``text`` and either ``handle`` or ``chat`` are required.
414
+ ``label`` is optional (used for logging only).
415
+ """
416
+ if self._ctx is None or SendTarget is None:
417
+ return # pragma: no cover
418
+
419
+ try:
420
+ payload = json.loads(message.payload)
421
+ except (json.JSONDecodeError, Exception) as exc:
422
+ log.warning("mqtt: bad outbound payload: %s", exc)
423
+ _ls_warn("mqtt", f"bad outbound payload: {exc}")
424
+ return
425
+
426
+ text = (payload.get("text") or "").strip()
427
+ if not text:
428
+ log.debug("mqtt: outbound message has no text; ignored")
429
+ return
430
+
431
+ handle = (payload.get("handle") or "").strip()
432
+ chat_guid = (payload.get("chat") or "").strip()
433
+ label = (payload.get("label") or "").strip()
434
+
435
+ if handle:
436
+ target = SendTarget(
437
+ kind="handle",
438
+ value=handle,
439
+ label=label or (self._ctx.name_for(handle) if hasattr(self._ctx, "name_for") else None) or handle,
440
+ )
441
+ log.debug("mqtt: outbound → handle=%s text=%r", handle, text[:80])
442
+ _ls_info("mqtt", f"outbound → handle={handle}")
443
+ elif chat_guid:
444
+ target = SendTarget(
445
+ kind="chat",
446
+ value=chat_guid,
447
+ label=label or chat_guid,
448
+ )
449
+ log.debug("mqtt: outbound → chat=%s text=%r", chat_guid, text[:80])
450
+ _ls_info("mqtt", f"outbound → chat={chat_guid}")
451
+ else:
452
+ log.warning("mqtt: outbound payload missing 'handle' or 'chat'; ignored")
453
+ _ls_warn("mqtt", "outbound payload missing 'handle' or 'chat'")
454
+ return
455
+
456
+ loop = self._loop or asyncio.get_event_loop()
457
+ fut = asyncio.run_coroutine_threadsafe(
458
+ self._ctx.send_text(target, text), loop
459
+ )
460
+ label = f"mqtt:{handle or chat_guid}"
461
+ fut.add_done_callback(lambda f: _log_send_future_error(f, label))
462
+
463
+ @staticmethod
464
+ def _payload_for(msg: Any) -> dict[str, Any]:
465
+ """Serialise an InboundMessage to a stable, versioned dict (v=1)."""
466
+ chat_guid = getattr(msg, "chat_guid", None)
467
+ chat_identifier = getattr(msg, "chat_identifier", None)
468
+ chat_name = getattr(msg, "chat_name", None)
469
+ is_group = bool(getattr(msg, "is_group", False))
470
+
471
+ return {
472
+ "v": 1,
473
+ "rowid": getattr(msg, "rowid", None),
474
+ "handle": getattr(msg, "handle", "") or "",
475
+ "text": getattr(msg, "text", "") or "",
476
+ "is_from_me": bool(getattr(msg, "is_from_me", False)),
477
+ "chat": (
478
+ {
479
+ "guid": chat_guid,
480
+ "identifier": chat_identifier,
481
+ "name": chat_name,
482
+ "is_group": is_group,
483
+ }
484
+ if chat_guid
485
+ else None
486
+ ),
487
+ }
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chatwire-mqtt"
7
+ version = "1.0.0"
8
+ description = "Publish every inbound iMessage to an MQTT broker (Home Assistant, Node-RED, etc.)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Allen Bina" }]
13
+ keywords = ["chatwire", "mqtt", "imessage", "home-assistant", "node-red", "plugin"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: End Users/Desktop",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = [
23
+ "paho-mqtt>=1.6",
24
+ ]
25
+
26
+ [project.entry-points."chatwire.integrations"]
27
+ chatwire_mqtt = "chatwire_mqtt:MQTTIntegration"