mmrelay 1.0__py3-none-any.whl

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.

Potentially problematic release.


This version of mmrelay might be problematic. Click here for more details.

@@ -0,0 +1,569 @@
1
+ import asyncio
2
+ import contextlib
3
+ import io
4
+ import threading
5
+ import time
6
+ from typing import List
7
+
8
+ import meshtastic.ble_interface
9
+ import meshtastic.serial_interface
10
+ import meshtastic.tcp_interface
11
+ import serial # For serial port exceptions
12
+ import serial.tools.list_ports # Import serial tools for port listing
13
+ from bleak.exc import BleakDBusError, BleakError
14
+ from pubsub import pub
15
+
16
+ from mmrelay.db_utils import (
17
+ get_longname,
18
+ get_message_map_by_meshtastic_id,
19
+ get_shortname,
20
+ save_longname,
21
+ save_shortname,
22
+ )
23
+ from mmrelay.log_utils import get_logger
24
+
25
+ # Global config variable that will be set from config.py
26
+ config = None
27
+
28
+ # Do not import plugin_loader here to avoid circular imports
29
+
30
+ # Initialize matrix rooms configuration
31
+ matrix_rooms: List[dict] = []
32
+
33
+ # Initialize logger for Meshtastic
34
+ logger = get_logger(name="Meshtastic")
35
+
36
+ # Global variables for the Meshtastic connection and event loop management
37
+ meshtastic_client = None
38
+ event_loop = None # Will be set from main.py
39
+
40
+ meshtastic_lock = (
41
+ threading.Lock()
42
+ ) # To prevent race conditions on meshtastic_client access
43
+
44
+ reconnecting = False
45
+ shutting_down = False
46
+ reconnect_task = None # To keep track of the reconnect task
47
+
48
+
49
+ def serial_port_exists(port_name):
50
+ """
51
+ Check if the specified serial port exists.
52
+ This prevents attempting connections on non-existent ports.
53
+ """
54
+ ports = [p.device for p in serial.tools.list_ports.comports()]
55
+ return port_name in ports
56
+
57
+
58
+ def connect_meshtastic(passed_config=None, force_connect=False):
59
+ """
60
+ Establish a connection to the Meshtastic device.
61
+ Attempts a connection based on connection_type (serial/ble/network).
62
+ Retries until successful or shutting_down is set.
63
+ If already connected and not force_connect, returns the existing client.
64
+
65
+ Args:
66
+ passed_config: The configuration dictionary to use (will update global config)
67
+ force_connect: Whether to force a new connection even if one exists
68
+ """
69
+ global meshtastic_client, shutting_down, config, matrix_rooms
70
+ if shutting_down:
71
+ logger.debug("Shutdown in progress. Not attempting to connect.")
72
+ return None
73
+
74
+ # Update the global config if a config is passed
75
+ if passed_config is not None:
76
+ config = passed_config
77
+
78
+ # If config is valid, extract matrix_rooms
79
+ if config and "matrix_rooms" in config:
80
+ matrix_rooms = config["matrix_rooms"]
81
+
82
+ with meshtastic_lock:
83
+ if meshtastic_client and not force_connect:
84
+ return meshtastic_client
85
+
86
+ # Close previous connection if exists
87
+ if meshtastic_client:
88
+ try:
89
+ meshtastic_client.close()
90
+ except Exception as e:
91
+ logger.warning(f"Error closing previous connection: {e}")
92
+ meshtastic_client = None
93
+
94
+ # Check if config is available
95
+ if config is None:
96
+ logger.error("No configuration available. Cannot connect to Meshtastic.")
97
+ return None
98
+
99
+ # Determine connection type and attempt connection
100
+ connection_type = config["meshtastic"]["connection_type"]
101
+
102
+ # Support legacy "network" connection type (now "tcp")
103
+ if connection_type == "network":
104
+ connection_type = "tcp"
105
+ logger.warning(
106
+ "Using 'network' connection type (legacy). 'tcp' is now the preferred name and 'network' will be deprecated in a future version."
107
+ )
108
+ retry_limit = 0 # 0 means infinite retries
109
+ attempts = 1
110
+ successful = False
111
+
112
+ while (
113
+ not successful
114
+ and (retry_limit == 0 or attempts <= retry_limit)
115
+ and not shutting_down
116
+ ):
117
+ try:
118
+ if connection_type == "serial":
119
+ # Serial connection
120
+ serial_port = config["meshtastic"]["serial_port"]
121
+ logger.info(f"Connecting to serial port {serial_port} ...")
122
+
123
+ # Check if serial port exists before connecting
124
+ if not serial_port_exists(serial_port):
125
+ logger.warning(
126
+ f"Serial port {serial_port} does not exist. Waiting..."
127
+ )
128
+ time.sleep(5)
129
+ attempts += 1
130
+ continue
131
+
132
+ meshtastic_client = meshtastic.serial_interface.SerialInterface(
133
+ serial_port
134
+ )
135
+
136
+ elif connection_type == "ble":
137
+ # BLE connection
138
+ ble_address = config["meshtastic"].get("ble_address")
139
+ if ble_address:
140
+ logger.info(f"Connecting to BLE address {ble_address} ...")
141
+ meshtastic_client = meshtastic.ble_interface.BLEInterface(
142
+ address=ble_address,
143
+ noProto=False,
144
+ debugOut=None,
145
+ noNodes=False,
146
+ )
147
+ else:
148
+ logger.error("No BLE address provided.")
149
+ return None
150
+
151
+ elif connection_type == "tcp":
152
+ # TCP connection
153
+ target_host = config["meshtastic"]["host"]
154
+ logger.info(f"Connecting to host {target_host} ...")
155
+ meshtastic_client = meshtastic.tcp_interface.TCPInterface(
156
+ hostname=target_host
157
+ )
158
+ else:
159
+ logger.error(f"Unknown connection type: {connection_type}")
160
+ return None
161
+
162
+ successful = True
163
+ nodeInfo = meshtastic_client.getMyNodeInfo()
164
+ logger.info(
165
+ f"Connected to {nodeInfo['user']['shortName']} / {nodeInfo['user']['hwModel']}"
166
+ )
167
+
168
+ # Subscribe to message and connection lost events
169
+ pub.subscribe(on_meshtastic_message, "meshtastic.receive")
170
+ pub.subscribe(
171
+ on_lost_meshtastic_connection, "meshtastic.connection.lost"
172
+ )
173
+
174
+ except (
175
+ serial.SerialException,
176
+ BleakDBusError,
177
+ BleakError,
178
+ Exception,
179
+ ) as e:
180
+ if shutting_down:
181
+ logger.debug("Shutdown in progress. Aborting connection attempts.")
182
+ break
183
+ attempts += 1
184
+ if retry_limit == 0 or attempts <= retry_limit:
185
+ wait_time = min(
186
+ attempts * 2, 30
187
+ ) # Exponential backoff capped at 30s
188
+ logger.warning(
189
+ f"Attempt #{attempts - 1} failed. Retrying in {wait_time} secs: {e}"
190
+ )
191
+ time.sleep(wait_time)
192
+ else:
193
+ logger.error(f"Could not connect after {retry_limit} attempts: {e}")
194
+ return None
195
+
196
+ return meshtastic_client
197
+
198
+
199
+ def on_lost_meshtastic_connection(interface=None):
200
+ """
201
+ Callback invoked when the Meshtastic connection is lost.
202
+ Initiates a reconnect sequence unless shutting_down is True.
203
+ """
204
+ global meshtastic_client, reconnecting, shutting_down, event_loop, reconnect_task
205
+ with meshtastic_lock:
206
+ if shutting_down:
207
+ logger.debug("Shutdown in progress. Not attempting to reconnect.")
208
+ return
209
+ if reconnecting:
210
+ logger.debug(
211
+ "Reconnection already in progress. Skipping additional reconnection attempt."
212
+ )
213
+ return
214
+ reconnecting = True
215
+ logger.error("Lost connection. Reconnecting...")
216
+
217
+ if meshtastic_client:
218
+ try:
219
+ meshtastic_client.close()
220
+ except OSError as e:
221
+ if e.errno == 9:
222
+ # Bad file descriptor, already closed
223
+ pass
224
+ else:
225
+ logger.warning(f"Error closing Meshtastic client: {e}")
226
+ except Exception as e:
227
+ logger.warning(f"Error closing Meshtastic client: {e}")
228
+ meshtastic_client = None
229
+
230
+ if event_loop:
231
+ reconnect_task = asyncio.run_coroutine_threadsafe(reconnect(), event_loop)
232
+
233
+
234
+ async def reconnect():
235
+ """
236
+ Asynchronously attempts to reconnect with exponential backoff.
237
+ Stops if shutting_down is set.
238
+ """
239
+ global meshtastic_client, reconnecting, shutting_down
240
+ backoff_time = 10
241
+ try:
242
+ while not shutting_down:
243
+ try:
244
+ logger.info(
245
+ f"Reconnection attempt starting in {backoff_time} seconds..."
246
+ )
247
+ await asyncio.sleep(backoff_time)
248
+ if shutting_down:
249
+ logger.debug(
250
+ "Shutdown in progress. Aborting reconnection attempts."
251
+ )
252
+ break
253
+ meshtastic_client = connect_meshtastic(force_connect=True)
254
+ if meshtastic_client:
255
+ logger.info("Reconnected successfully.")
256
+ break
257
+ except Exception as e:
258
+ if shutting_down:
259
+ break
260
+ logger.error(f"Reconnection attempt failed: {e}")
261
+ backoff_time = min(backoff_time * 2, 300) # Cap backoff at 5 minutes
262
+ except asyncio.CancelledError:
263
+ logger.info("Reconnection task was cancelled.")
264
+ finally:
265
+ reconnecting = False
266
+
267
+
268
+ def on_meshtastic_message(packet, interface):
269
+ """
270
+ Handle incoming Meshtastic messages. For reaction messages, if relay_reactions is False,
271
+ we do not store message maps and thus won't be able to relay reactions back to Matrix.
272
+ If relay_reactions is True, message maps are stored inside matrix_relay().
273
+ """
274
+ global config, matrix_rooms
275
+
276
+ # Log that we received a message (without the full packet details)
277
+ if packet.get("decoded", {}).get("text"):
278
+ logger.info(
279
+ f"Received Meshtastic message: {packet.get('decoded', {}).get('text')}"
280
+ )
281
+ else:
282
+ logger.debug("Received non-text Meshtastic message")
283
+
284
+ # Check if config is available
285
+ if config is None:
286
+ logger.error("No configuration available. Cannot process Meshtastic message.")
287
+ return
288
+
289
+ # Apply reaction filtering based on config
290
+ relay_reactions = config["meshtastic"].get("relay_reactions", False)
291
+
292
+ # If relay_reactions is False, filter out reaction/tapback packets to avoid complexity
293
+ if packet.get("decoded", {}).get("portnum") == "TEXT_MESSAGE_APP":
294
+ decoded = packet.get("decoded", {})
295
+ if not relay_reactions and ("emoji" in decoded or "replyId" in decoded):
296
+ logger.debug(
297
+ "Filtered out reaction/tapback packet due to relay_reactions=false."
298
+ )
299
+ return
300
+
301
+ from mmrelay.matrix_utils import matrix_relay
302
+
303
+ global event_loop
304
+
305
+ if shutting_down:
306
+ logger.debug("Shutdown in progress. Ignoring incoming messages.")
307
+ return
308
+
309
+ if event_loop is None:
310
+ logger.error("Event loop is not set. Cannot process message.")
311
+ return
312
+
313
+ loop = event_loop
314
+
315
+ sender = packet.get("fromId") or packet.get("from")
316
+ toId = packet.get("to")
317
+
318
+ decoded = packet.get("decoded", {})
319
+ text = decoded.get("text")
320
+ replyId = decoded.get("replyId")
321
+ emoji_flag = "emoji" in decoded and decoded["emoji"] == 1
322
+
323
+ # Determine if this is a direct message to the relay node
324
+ from meshtastic.mesh_interface import BROADCAST_NUM
325
+
326
+ myId = interface.myInfo.my_node_num
327
+
328
+ if toId == myId:
329
+ is_direct_message = True
330
+ elif toId == BROADCAST_NUM:
331
+ is_direct_message = False
332
+ else:
333
+ # Message to someone else; ignoring for broadcasting logic
334
+ is_direct_message = False
335
+
336
+ meshnet_name = config["meshtastic"]["meshnet_name"]
337
+
338
+ # Reaction handling (Meshtastic -> Matrix)
339
+ # If replyId and emoji_flag are present and relay_reactions is True, we relay as text reactions in Matrix
340
+ if replyId and emoji_flag and relay_reactions:
341
+ longname = get_longname(sender) or str(sender)
342
+ shortname = get_shortname(sender) or str(sender)
343
+ orig = get_message_map_by_meshtastic_id(replyId)
344
+ if orig:
345
+ # orig = (matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
346
+ matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet = orig
347
+ abbreviated_text = (
348
+ meshtastic_text[:40] + "..."
349
+ if len(meshtastic_text) > 40
350
+ else meshtastic_text
351
+ )
352
+
353
+ # Ensure that meshnet_name is always included, using our own meshnet for accuracy.
354
+ full_display_name = f"{longname}/{meshnet_name}"
355
+
356
+ reaction_symbol = text.strip() if (text and text.strip()) else "⚠️"
357
+ reaction_message = f'\n [{full_display_name}] reacted {reaction_symbol} to "{abbreviated_text}"'
358
+
359
+ # Relay the reaction as emote to Matrix, preserving the original meshnet name
360
+ asyncio.run_coroutine_threadsafe(
361
+ matrix_relay(
362
+ matrix_room_id,
363
+ reaction_message,
364
+ longname,
365
+ shortname,
366
+ meshnet_name,
367
+ decoded.get("portnum"),
368
+ meshtastic_id=packet.get("id"),
369
+ meshtastic_replyId=replyId,
370
+ meshtastic_text=meshtastic_text,
371
+ emote=True,
372
+ emoji=True,
373
+ ),
374
+ loop=loop,
375
+ )
376
+ else:
377
+ logger.debug("Original message for reaction not found in DB.")
378
+ return
379
+
380
+ # Normal text messages or detection sensor messages
381
+ if text:
382
+ # Determine the channel for this message
383
+ channel = packet.get("channel")
384
+ if channel is None:
385
+ # If channel not specified, deduce from portnum
386
+ if (
387
+ decoded.get("portnum") == "TEXT_MESSAGE_APP"
388
+ or decoded.get("portnum") == 1
389
+ ):
390
+ channel = 0
391
+ elif decoded.get("portnum") == "DETECTION_SENSOR_APP":
392
+ channel = 0
393
+ else:
394
+ logger.debug(
395
+ f"Unknown portnum {decoded.get('portnum')}, cannot determine channel"
396
+ )
397
+ return
398
+
399
+ # Check if channel is mapped to a Matrix room
400
+ channel_mapped = False
401
+ for room in matrix_rooms:
402
+ if room["meshtastic_channel"] == channel:
403
+ channel_mapped = True
404
+ break
405
+
406
+ if not channel_mapped:
407
+ logger.debug(f"Skipping message from unmapped channel {channel}")
408
+ return
409
+
410
+ # If detection_sensor is disabled and this is a detection sensor packet, skip it
411
+ if decoded.get("portnum") == "DETECTION_SENSOR_APP" and not config[
412
+ "meshtastic"
413
+ ].get("detection_sensor", False):
414
+ logger.debug(
415
+ "Detection sensor packet received, but detection sensor processing is disabled."
416
+ )
417
+ return
418
+
419
+ # Attempt to get longname/shortname from database or nodes
420
+ longname = get_longname(sender)
421
+ shortname = get_shortname(sender)
422
+
423
+ if not longname or not shortname:
424
+ node = interface.nodes.get(sender)
425
+ if node:
426
+ user = node.get("user")
427
+ if user:
428
+ if not longname:
429
+ longname_val = user.get("longName")
430
+ if longname_val:
431
+ save_longname(sender, longname_val)
432
+ longname = longname_val
433
+ if not shortname:
434
+ shortname_val = user.get("shortName")
435
+ if shortname_val:
436
+ save_shortname(sender, shortname_val)
437
+ shortname = shortname_val
438
+ else:
439
+ logger.debug(f"Node info for sender {sender} not available yet.")
440
+
441
+ # If still not available, fallback to sender ID
442
+ if not longname:
443
+ longname = str(sender)
444
+ if not shortname:
445
+ shortname = str(sender)
446
+
447
+ formatted_message = f"[{longname}/{meshnet_name}]: {text}"
448
+
449
+ # Plugin functionality - Check if any plugin handles this message before relaying
450
+ from mmrelay.plugin_loader import load_plugins
451
+
452
+ plugins = load_plugins()
453
+
454
+ found_matching_plugin = False
455
+ for plugin in plugins:
456
+ if not found_matching_plugin:
457
+ result = asyncio.run_coroutine_threadsafe(
458
+ plugin.handle_meshtastic_message(
459
+ packet, formatted_message, longname, meshnet_name
460
+ ),
461
+ loop=loop,
462
+ )
463
+ found_matching_plugin = result.result()
464
+ if found_matching_plugin:
465
+ logger.debug(f"Processed by plugin {plugin.plugin_name}")
466
+
467
+ # If message is a DM or handled by plugin, do not relay further
468
+ if is_direct_message:
469
+ logger.debug(
470
+ f"Received a direct message from {longname}: {text}. Not relaying to Matrix."
471
+ )
472
+ return
473
+ if found_matching_plugin:
474
+ logger.debug("Message was handled by a plugin. Not relaying to Matrix.")
475
+ return
476
+
477
+ # Relay the message to all Matrix rooms mapped to this channel
478
+ logger.info(f"Relaying Meshtastic message from {longname} to Matrix")
479
+
480
+ # Check if matrix_rooms is empty
481
+ if not matrix_rooms:
482
+ logger.error("matrix_rooms is empty. Cannot relay message to Matrix.")
483
+ return
484
+
485
+ for room in matrix_rooms:
486
+ if room["meshtastic_channel"] == channel:
487
+ # Storing the message_map (if enabled) occurs inside matrix_relay() now,
488
+ # controlled by relay_reactions.
489
+ try:
490
+ asyncio.run_coroutine_threadsafe(
491
+ matrix_relay(
492
+ room["id"],
493
+ formatted_message,
494
+ longname,
495
+ shortname,
496
+ meshnet_name,
497
+ decoded.get("portnum"),
498
+ meshtastic_id=packet.get("id"),
499
+ meshtastic_text=text,
500
+ ),
501
+ loop=loop,
502
+ )
503
+ except Exception as e:
504
+ logger.error(f"Error relaying message to Matrix: {e}")
505
+ else:
506
+ # Non-text messages via plugins
507
+ portnum = decoded.get("portnum")
508
+ from mmrelay.plugin_loader import load_plugins
509
+
510
+ plugins = load_plugins()
511
+ found_matching_plugin = False
512
+ for plugin in plugins:
513
+ if not found_matching_plugin:
514
+ result = asyncio.run_coroutine_threadsafe(
515
+ plugin.handle_meshtastic_message(
516
+ packet,
517
+ formatted_message=None,
518
+ longname=None,
519
+ meshnet_name=None,
520
+ ),
521
+ loop=loop,
522
+ )
523
+ found_matching_plugin = result.result()
524
+ if found_matching_plugin:
525
+ logger.debug(
526
+ f"Processed {portnum} with plugin {plugin.plugin_name}"
527
+ )
528
+
529
+
530
+ async def check_connection():
531
+ """
532
+ Periodically checks the Meshtastic connection by calling localNode.getMetadata().
533
+ If it fails or doesn't return the firmware version, we assume the connection is lost
534
+ and attempt to reconnect.
535
+ """
536
+ global meshtastic_client, shutting_down, config
537
+
538
+ # Check if config is available
539
+ if config is None:
540
+ logger.error("No configuration available. Cannot check connection.")
541
+ return
542
+
543
+ connection_type = config["meshtastic"]["connection_type"]
544
+ while not shutting_down:
545
+ if meshtastic_client:
546
+ try:
547
+ output_capture = io.StringIO()
548
+ with contextlib.redirect_stdout(
549
+ output_capture
550
+ ), contextlib.redirect_stderr(output_capture):
551
+ meshtastic_client.localNode.getMetadata()
552
+
553
+ console_output = output_capture.getvalue()
554
+ if "firmware_version" not in console_output:
555
+ raise Exception("No firmware_version in getMetadata output.")
556
+
557
+ except Exception as e:
558
+ logger.error(f"{connection_type.capitalize()} connection lost: {e}")
559
+ on_lost_meshtastic_connection(meshtastic_client)
560
+ await asyncio.sleep(30) # Check connection every 30 seconds
561
+
562
+
563
+ if __name__ == "__main__":
564
+ # If running this standalone (normally the main.py does the loop), just try connecting and run forever.
565
+ meshtastic_client = connect_meshtastic()
566
+ loop = asyncio.get_event_loop()
567
+ event_loop = loop # Set the event loop for use in callbacks
568
+ loop.create_task(check_connection())
569
+ loop.run_forever()