2023-04-20 20:21:09 +00:00
"""
This script connects a Meshtastic mesh network to Matrix chat rooms by relaying messages between them .
It uses Meshtastic - python and Matrix nio client library to interface with the radio and the Matrix server respectively .
"""
2023-04-17 23:50:28 +00:00
import asyncio
2023-04-18 05:44:31 +00:00
import time
2023-04-17 23:50:28 +00:00
import logging
2023-04-18 16:45:19 +00:00
import re
2023-04-18 16:25:42 +00:00
import sqlite3
2023-04-18 05:44:31 +00:00
import yaml
2023-04-21 06:00:42 +00:00
import certifi
import ssl
2023-04-17 23:50:28 +00:00
import meshtastic . tcp_interface
2023-04-18 01:24:51 +00:00
import meshtastic . serial_interface
2023-04-21 01:18:31 +00:00
from nio import AsyncClient , AsyncClientConfig , MatrixRoom , RoomMessageText , RoomAliasEvent , RoomMessageNotice
2023-04-17 23:50:28 +00:00
from pubsub import pub
from yaml . loader import SafeLoader
2023-04-21 01:18:31 +00:00
from typing import List , Union
2023-04-22 19:44:56 +00:00
from datetime import datetime
2023-04-17 23:50:28 +00:00
2023-04-22 23:16:46 +00:00
class CustomFormatter ( logging . Formatter ) :
def __init__ ( self , fmt = None , datefmt = None , style = " % " , converter = None ) :
super ( ) . __init__ ( fmt , datefmt , style )
self . converter = converter or time . localtime
def formatTime ( self , record , datefmt = None ) :
ct = self . converter ( record . created , None ) # Add None as the second argument
if datefmt :
s = time . strftime ( datefmt , ct )
else :
t = time . strftime ( self . default_time_format , ct )
s = self . default_msec_format % ( t , record . msecs )
return s
def utc_converter ( timestamp , _ ) :
return time . gmtime ( timestamp )
2023-04-20 20:21:09 +00:00
bot_start_time = int ( time . time ( ) * 1000 ) # Timestamp when the bot starts, used to filter out old messages
2023-04-18 05:44:31 +00:00
# Load configuration
2023-04-17 23:50:28 +00:00
with open ( " config.yaml " , " r " ) as f :
relay_config = yaml . load ( f , Loader = SafeLoader )
2023-04-22 19:44:56 +00:00
# Configure logging
2023-04-22 22:43:48 +00:00
logger = logging . getLogger ( name = " MMRELAY " )
2023-04-22 19:44:56 +00:00
log_level = getattr ( logging , relay_config [ " logging " ] [ " level " ] . upper ( ) )
show_timestamps = relay_config [ " logging " ] [ " show_timestamps " ]
timestamp_format = relay_config [ " logging " ] [ " timestamp_format " ]
if show_timestamps :
log_format = f " %(asctime)s %(levelname)s:%(name)s:%(message)s "
else :
log_format = " %(levelname)s : %(name)s : %(message)s "
2023-04-18 05:44:31 +00:00
logger . setLevel ( getattr ( logging , relay_config [ " logging " ] [ " level " ] . upper ( ) ) )
2023-04-22 19:44:56 +00:00
logger . propagate = False # Add this line to prevent double logging
2023-04-22 23:16:46 +00:00
formatter = CustomFormatter ( log_format , datefmt = timestamp_format , converter = utc_converter )
2023-04-22 19:44:56 +00:00
handler = logging . StreamHandler ( )
handler . setFormatter ( formatter )
logger . addHandler ( handler )
2023-04-18 16:45:19 +00:00
2023-04-18 16:25:42 +00:00
# Initialize SQLite database
def initialize_database ( ) :
2023-04-19 01:08:23 +00:00
with sqlite3 . connect ( " meshtastic.sqlite " ) as conn :
cursor = conn . cursor ( )
cursor . execute (
" CREATE TABLE IF NOT EXISTS longnames (meshtastic_id TEXT PRIMARY KEY, longname TEXT) "
)
conn . commit ( )
2023-04-18 16:25:42 +00:00
# Get the longname for a given Meshtastic ID
def get_longname ( meshtastic_id ) :
2023-04-19 01:08:23 +00:00
with sqlite3 . connect ( " meshtastic.sqlite " ) as conn :
cursor = conn . cursor ( )
cursor . execute (
" SELECT longname FROM longnames WHERE meshtastic_id=? " , ( meshtastic_id , )
)
result = cursor . fetchone ( )
2023-04-18 16:25:42 +00:00
return result [ 0 ] if result else None
2023-04-19 01:08:23 +00:00
2023-04-18 16:25:42 +00:00
def save_longname ( meshtastic_id , longname ) :
2023-04-19 01:08:23 +00:00
with sqlite3 . connect ( " meshtastic.sqlite " ) as conn :
cursor = conn . cursor ( )
cursor . execute (
" INSERT OR REPLACE INTO longnames (meshtastic_id, longname) VALUES (?, ?) " ,
( meshtastic_id , longname ) ,
)
conn . commit ( )
2023-04-17 23:50:28 +00:00
2023-04-18 16:25:42 +00:00
def update_longnames ( ) :
if meshtastic_interface . nodes :
for node in meshtastic_interface . nodes . values ( ) :
user = node . get ( " user " )
if user :
meshtastic_id = user [ " id " ]
longname = user . get ( " longName " , " N/A " )
save_longname ( meshtastic_id , longname )
2023-04-18 16:45:19 +00:00
2023-04-20 20:21:09 +00:00
async def join_matrix_room ( matrix_client , room_id_or_alias : str ) - > None :
""" Join a Matrix room by its ID or alias. """
try :
if room_id_or_alias . startswith ( " # " ) :
response = await matrix_client . resolve_room_alias ( room_id_or_alias )
if not response . room_id :
logger . error ( f " Failed to resolve room alias ' { room_id_or_alias } ' : { response . message } " )
return
room_id = response . room_id
else :
room_id = room_id_or_alias
if room_id not in matrix_client . rooms :
response = await matrix_client . join ( room_id )
if response and hasattr ( response , ' room_id ' ) :
logger . info ( f " Joined room ' { room_id_or_alias } ' successfully " )
else :
logger . error ( f " Failed to join room ' { room_id_or_alias } ' : { response . message } " )
else :
logger . debug ( f " Bot is already in room ' { room_id_or_alias } ' " )
except Exception as e :
logger . error ( f " Error joining room ' { room_id_or_alias } ' : { e } " )
2023-04-18 05:44:31 +00:00
# Initialize Meshtastic interface
2023-04-18 01:24:51 +00:00
connection_type = relay_config [ " meshtastic " ] [ " connection_type " ]
if connection_type == " serial " :
serial_port = relay_config [ " meshtastic " ] [ " serial_port " ]
logger . info ( f " Connecting to radio using serial port { serial_port } ... " )
meshtastic_interface = meshtastic . serial_interface . SerialInterface ( serial_port )
else :
target_host = relay_config [ " meshtastic " ] [ " host " ]
logger . info ( f " Connecting to radio at { target_host } ... " )
meshtastic_interface = meshtastic . tcp_interface . TCPInterface ( hostname = target_host )
2023-04-17 23:50:28 +00:00
matrix_client = None
# Matrix configuration
matrix_homeserver = relay_config [ " matrix " ] [ " homeserver " ]
matrix_access_token = relay_config [ " matrix " ] [ " access_token " ]
bot_user_id = relay_config [ " matrix " ] [ " bot_user_id " ]
2023-04-20 18:27:04 +00:00
matrix_rooms : List [ dict ] = relay_config [ " matrix_rooms " ]
2023-04-17 23:50:28 +00:00
2023-04-18 05:44:31 +00:00
# Send message to the Matrix room
2023-04-20 18:27:04 +00:00
async def matrix_relay ( matrix_client , room_id , message , longname , meshnet_name ) :
2023-04-17 23:50:28 +00:00
try :
2023-04-18 19:06:00 +00:00
content = {
" msgtype " : " m.text " ,
" body " : message ,
" meshtastic_longname " : longname ,
" meshtastic_meshnet " : meshnet_name ,
}
2023-04-17 23:50:28 +00:00
await asyncio . wait_for (
matrix_client . room_send (
2023-04-20 18:27:04 +00:00
room_id = room_id ,
2023-04-17 23:50:28 +00:00
message_type = " m.room.message " ,
2023-04-18 19:06:00 +00:00
content = content ,
2023-04-17 23:50:28 +00:00
) ,
timeout = 0.5 ,
)
2023-04-20 18:27:04 +00:00
logger . info ( f " Sent inbound radio message to matrix room: { room_id } " )
2023-04-17 23:50:28 +00:00
except asyncio . TimeoutError :
2023-04-18 05:44:31 +00:00
logger . error ( f " Timed out while waiting for Matrix response " )
2023-04-17 23:50:28 +00:00
except Exception as e :
2023-04-20 18:27:04 +00:00
logger . error ( f " Error sending radio message to matrix room { room_id } : { e } " )
2023-04-17 23:50:28 +00:00
# Callback for new messages from Meshtastic
def on_meshtastic_message ( packet , loop = None ) :
sender = packet [ " fromId " ]
if " text " in packet [ " decoded " ] and packet [ " decoded " ] [ " text " ] :
text = packet [ " decoded " ] [ " text " ]
2023-04-20 02:11:27 +00:00
if " channel " in packet :
channel = packet [ " channel " ]
else :
if packet [ " decoded " ] [ " portnum " ] == " TEXT_MESSAGE_APP " :
channel = 0
else :
logger . debug ( f " Unknown packet " )
return
2023-04-20 00:50:14 +00:00
2023-04-20 18:27:04 +00:00
# Check if the channel is mapped to a Matrix room in the configuration
channel_mapped = False
for room in matrix_rooms :
if room [ " meshtastic_channel " ] == channel :
channel_mapped = True
break
2023-04-20 00:50:14 +00:00
2023-04-20 18:27:04 +00:00
if not channel_mapped :
logger . debug ( f " Skipping message from unmapped channel { channel } " )
return
2023-04-20 00:50:14 +00:00
2023-04-20 18:27:04 +00:00
logger . info ( f " Processing inbound radio message from { sender } on channel { channel } " )
2023-04-20 02:11:27 +00:00
2023-04-20 18:27:04 +00:00
longname = get_longname ( sender ) or sender
meshnet_name = relay_config [ " meshtastic " ] [ " meshnet_name " ]
formatted_message = f " [ { longname } / { meshnet_name } ]: { text } "
logger . info ( f " Relaying Meshtastic message from { longname } to Matrix: { formatted_message } " )
for room in matrix_rooms :
if room [ " meshtastic_channel " ] == channel :
asyncio . run_coroutine_threadsafe (
matrix_relay ( matrix_client , room [ " id " ] , formatted_message , longname , meshnet_name ) ,
loop = loop ,
)
2023-04-20 02:11:27 +00:00
else :
2023-04-20 02:44:51 +00:00
portnum = packet [ " decoded " ] [ " portnum " ]
if portnum == " TELEMETRY_APP " :
logger . debug ( " Ignoring Telemetry packet " )
elif portnum == " POSITION_APP " :
logger . debug ( " Ignoring Position packet " )
elif portnum == " ADMIN_APP " :
logger . debug ( " Ignoring Admin packet " )
else :
logger . debug ( f " Ignoring Unknown packet " )
2023-04-20 02:11:27 +00:00
2023-04-17 23:50:28 +00:00
2023-04-19 21:06:07 +00:00
def truncate_message ( text , max_bytes = 234 ) : #234 is the maximum that we can run without an error. Trying it for awhile, otherwise lower this to 230 or less.
2023-04-19 01:08:23 +00:00
"""
Truncate the given text to fit within the specified byte size .
: param text : The text to truncate .
: param max_bytes : The maximum allowed byte size for the truncated text .
: return : The truncated text .
"""
2023-04-18 23:28:02 +00:00
truncated_text = text . encode ( ' utf-8 ' ) [ : max_bytes ] . decode ( ' utf-8 ' , ' ignore ' )
return truncated_text
2023-04-18 19:06:00 +00:00
2023-04-19 01:08:23 +00:00
2023-04-17 23:50:28 +00:00
# Callback for new messages in Matrix room
2023-04-21 01:18:31 +00:00
async def on_room_message ( room : MatrixRoom , event : Union [ RoomMessageText , RoomMessageNotice ] ) - > None :
2023-04-19 01:08:23 +00:00
full_display_name = " Unknown user "
2023-04-20 18:27:04 +00:00
if event . sender != bot_user_id :
2023-04-18 05:44:31 +00:00
message_timestamp = event . server_timestamp
2023-04-17 23:50:28 +00:00
2023-04-18 05:44:31 +00:00
if message_timestamp > bot_start_time :
2023-04-17 23:50:28 +00:00
text = event . body . strip ( )
2023-04-18 05:44:31 +00:00
2023-04-18 19:06:00 +00:00
longname = event . source [ ' content ' ] . get ( " meshtastic_longname " )
meshnet_name = event . source [ ' content ' ] . get ( " meshtastic_meshnet " )
2023-04-19 01:08:23 +00:00
local_meshnet_name = relay_config [ " meshtastic " ] [ " meshnet_name " ]
2023-04-18 05:44:31 +00:00
2023-04-18 19:06:00 +00:00
if longname and meshnet_name :
2023-04-21 01:37:33 +00:00
full_display_name = f " { longname } / { meshnet_name } "
2023-04-19 01:08:23 +00:00
if meshnet_name != local_meshnet_name :
short_longname = longname [ : 3 ]
short_meshnet_name = meshnet_name [ : 4 ]
2023-04-21 01:49:12 +00:00
prefix = f " { short_longname } / { short_meshnet_name } : "
2023-04-21 02:15:49 +00:00
logger . info ( f " Processing message from remote meshnet: { text } " )
2023-04-19 01:08:23 +00:00
else :
2023-04-21 02:15:49 +00:00
logger . info ( f " Processing message from local meshnet: { text } " )
2023-04-19 01:08:23 +00:00
return
2023-04-18 19:06:00 +00:00
else :
display_name_response = await matrix_client . get_displayname ( event . sender )
full_display_name = display_name_response . displayname or event . sender
short_display_name = full_display_name [ : 5 ]
2023-04-21 01:49:12 +00:00
prefix = f " { short_display_name } [M]: "
2023-04-21 02:10:35 +00:00
logger . info ( f " Processing matrix message from [ { full_display_name } ]: { text } " )
2023-04-18 05:44:31 +00:00
2023-04-18 23:28:02 +00:00
text = truncate_message ( text )
2023-04-21 01:49:12 +00:00
full_message = f " { prefix } { text } "
2023-04-18 23:28:02 +00:00
2023-04-20 18:27:04 +00:00
room_config = None
for config in matrix_rooms :
if config [ " id " ] == room . room_id :
room_config = config
break
if room_config :
meshtastic_channel = room_config [ " meshtastic_channel " ]
if relay_config [ " meshtastic " ] [ " broadcast_enabled " ] :
logger . info ( f " Sending radio message from { full_display_name } to radio broadcast " )
meshtastic_interface . sendText (
2023-04-21 01:49:12 +00:00
text = full_message , channelIndex = meshtastic_channel
2023-04-20 18:27:04 +00:00
)
else :
logger . debug ( f " Broadcast not supported: Message from { full_display_name } dropped. " )
2023-04-17 23:50:28 +00:00
async def main ( ) :
global matrix_client
2023-04-18 16:25:42 +00:00
# Initialize the SQLite database
initialize_database ( )
2023-04-21 06:00:42 +00:00
# Create SSL context using certifi's certificates
ssl_context = ssl . create_default_context ( cafile = certifi . where ( ) )
# Initialize the Matrix client with custom SSL context
2023-04-21 06:19:18 +00:00
config = AsyncClientConfig ( encryption_enabled = False )
matrix_client = AsyncClient ( matrix_homeserver , bot_user_id , config = config , ssl = ssl_context )
matrix_client . access_token = matrix_access_token
2023-04-17 23:50:28 +00:00
2023-04-20 19:18:04 +00:00
logger . info ( " Connecting to Matrix server... " )
try :
login_response = await matrix_client . login ( matrix_access_token )
logger . info ( f " Login response: { login_response } " )
except Exception as e :
logger . error ( f " Error connecting to Matrix server: { e } " )
return
2023-04-20 20:21:09 +00:00
# Join the rooms specified in the config.yaml
for room in matrix_rooms :
await join_matrix_room ( matrix_client , room [ " id " ] )
2023-04-20 19:18:04 +00:00
2023-04-17 23:50:28 +00:00
# Register the Meshtastic message callback
logger . info ( f " Listening for inbound radio messages ... " )
pub . subscribe (
on_meshtastic_message , " meshtastic.receive " , loop = asyncio . get_event_loop ( )
)
# Register the message callback
logger . info ( f " Listening for inbound matrix messages ... " )
2023-04-21 01:18:31 +00:00
matrix_client . add_event_callback ( on_room_message , ( RoomMessageText , RoomMessageNotice ) )
2023-04-17 23:50:28 +00:00
# Start the Matrix client
2023-04-18 16:25:42 +00:00
while True :
2023-04-20 19:18:04 +00:00
try :
# Update longnames
update_longnames ( )
logger . info ( " Syncing with Matrix server... " )
await matrix_client . sync_forever ( timeout = 30000 )
logger . info ( " Sync completed. " )
except Exception as e :
logger . error ( f " Error syncing with Matrix server: { e } " )
2023-04-17 23:50:28 +00:00
2023-04-18 16:25:42 +00:00
await asyncio . sleep ( 60 ) # Update longnames every 60 seconds
2023-04-18 16:45:19 +00:00
2023-04-18 17:31:03 +00:00
asyncio . run ( main ( ) )