diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd7db66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +logs/ +meshtastic/ +.vscode/ +__pycache__/ \ No newline at end of file diff --git a/MeshtasticDataClasses.py b/MeshtasticDataClasses.py new file mode 100644 index 0000000..41a731e --- /dev/null +++ b/MeshtasticDataClasses.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass +from typing import Optional + +@dataclass +class Message: + portnum: str + payload: str + text: str + bitfield: Optional[str] = None + replyId: Optional[str] = None + emoji: Optional[str] = None + +@dataclass +class RxPacket: + _from: int + to: int + decoded: Message + id: int + rxTime: int + rxSnr: float + rxRssi: float + hopStart: int + raw: str + fromId: str + toId: str + hopLimit: Optional[int] = None + channel: Optional[int] = None + + +@dataclass +class Position: + latitudeI: int + longitudeI: int + altitude: float + time: int + locationSource: str + PDOP: int + groundSpeed: int + groundTrack: int + satsInView: int + precisionBits: int + raw: str + latitude: float + longitude: float + +@dataclass +class User: + id: str + longName: str + shortName: str + macaddr: str + hwModel: str + publicKey: str + role: Optional[str] = None + raw: Optional[str] = None + +@dataclass +class DeviceMetrics: + batteryLevel: float + voltage: float + channelUtilization: float + airUtilTx: float + uptimeSeconds: int + +@dataclass +class NodeData: + num: int + user: User + snr: float + lastHeard: int + hopsAway: int + deviceMetrics: Optional[DeviceMetrics] = None + position: Optional[Position] = None + lastReceived: Optional[str] = None + hopLimit: Optional[int] = None + +def to_rx_packet(rawPacket: dict) -> RxPacket: + if "from" in rawPacket: + rawPacket["_from"] = rawPacket.pop("from") + + parsed_packet = RxPacket(**rawPacket) + parsed_packet.decoded = Message(**parsed_packet.decoded) + return parsed_packet + +def to_node_data(rawNode: dict) -> NodeData: + # parse the dictionary into a dataclass + parsed_node = NodeData(**rawNode) + parsed_node.user = User(**parsed_node.user) + parsed_node.deviceMetrics = DeviceMetrics(**parsed_node.deviceMetrics) + return parsed_node + +if __name__ == "__main__": + test_packet = { + 'from': 541024136, + 'to': 4294967295, + 'channel': 1, + 'decoded': + { + 'portnum': 'TEXT_MESSAGE_APP', + 'payload': b'Test', + 'bitfield': 0, + 'text': 'Test' + }, + 'id': 1489259583, + 'rxTime': 1743434610, + 'rxSnr': 6.25, + 'hopLimit': 3, + 'rxRssi': -32, + 'hopStart': 3, + 'raw':"", + 'fromId':"", + 'toId':"", + } + + test = to_rx_packet(test_packet) + print(test) + + + \ No newline at end of file diff --git a/MeshtasticLogger.py b/MeshtasticLogger.py new file mode 100644 index 0000000..7f3151c --- /dev/null +++ b/MeshtasticLogger.py @@ -0,0 +1,73 @@ +import meshtastic +import meshtastic.serial_interface +from pubsub import pub +from MeshtasticDataClasses import RxPacket,to_rx_packet +from MeshtasticDataClasses import NodeData,to_node_data +from datetime import datetime + +class MeshtasticLogger: + def __init__(self, interface: meshtastic.serial_interface.SerialInterface, log_path : str, channel : int = 1, message_received_callback = None): + # Subscribe to the recieve text event topic + pub.subscribe(self.onReceive, "meshtastic.receive.text") + self.interface = interface + self.channel: int = channel + self.log_path: str = log_path + self.message_received_callback = message_received_callback + + def _cap_string_length_bytes(self, message : str, max_bytes: int): + ''' + Cap the length of a string to be under max_bytes. It will just drop the excess from the string. + ''' + capped_message = "" + capped_message_byte_length = 0 + for char in message: + next_char_byte_size = len(char.encode('utf-8')) + if capped_message_byte_length + next_char_byte_size <= max_bytes: + capped_message_byte_length += next_char_byte_size + capped_message += char + return capped_message + + def _log(self, message: str): + print(message) + with open(self.log_path, "a") as file: + file.write(message) + + def send(self, message: str): + self.interface.sendText(message, channelIndex=self.channel) + timestamp: str = datetime.now().strftime("%m-%d %I:%M:%S %p") + self._log(f"{timestamp} You on channel {self.channel}: {message}\n") + + + def onReceive(self, packet: dict, interface: meshtastic.serial_interface.SerialInterface): + # parse the packet we recieved into a data class so we have nice type hints + try: + parsed_packet: RxPacket = to_rx_packet(packet) + except Exception as e: + self._log(e) + return + + # get the current time + timestamp: str = datetime.now().strftime("%m-%d %I:%M:%S %p") + + # try to grab the sender's ID. Otherwise it will just be unkown sender + sender_node_name = "Unkown Sender" + if parsed_packet.fromId in interface.nodes: + try: + sender_node: NodeData = to_node_data(interface.nodes[parsed_packet.fromId]) + except Exception as e: + self._log(e) + return + sender_node_name = sender_node.user.longName + + # construct and _log the message + message = f"{timestamp} {sender_node_name}: On Channel {parsed_packet.channel}, message: {parsed_packet.decoded.text}\n" + self._log(message) + + # parrot out the message but make sure the message length doesn't go above 233 bytes + if parsed_packet.channel == 1: + print("Parroting message") + interface.sendText(self._cap_string_length_bytes(message, 233), channelIndex=self.channel) + + if not self.message_received_callback is None: + self.message_received_callback(message) + diff --git a/MeshtasticLogger.service b/MeshtasticLogger.service new file mode 100644 index 0000000..c10633e --- /dev/null +++ b/MeshtasticLogger.service @@ -0,0 +1,14 @@ +[Unit] +Description=A simple chat interface and autoresponder for meshtastic + +[Service] +Type=simple +WorkingDirectory=/home/quinn/Projects/meshtastic/ +ExecStart=sudo /home/quinn/Projects/meshtastic/meshtastic/bin/python -m flask --app /home/quinn/Projects/meshtastic/app.py run --host=0.0.0.0 --port=80 +Restart=always +User=quinn +StandardOutput=append:/home/quinn/Projects/meshtastic/logs/meshtastic_system.log +StandardError=append:/home/quinn/Projects/meshtastic/logs/meshtastic_system_error.log + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..585b30e --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Create a Virtual Environment +Create the virtual environment and activate it +`python -m venv meshtastic && source meshtastic/bin/activate` + +Install the requirements + +pip install requirements.txt + +# Deploying Service Script Changes +Type the following commands: + +`sudo cp /home/quinn/Projects/meshtastic/MeshtasticLogger.service /etc/systemd/system/ && sudo systemctl daemon-reload` + +To start the service run +`sudo systemctl start MeshtasticLogger` + +You can get the status of the service by running: + +`sudo systemctl status MeshtasticLogger` + +And you can stop the service by running: + +`sudo systemctl stop MeshtasticLogger` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..e5342e5 --- /dev/null +++ b/app.py @@ -0,0 +1,89 @@ +from flask import Flask, render_template, request, jsonify +import meshtastic +from MeshtasticLogger import MeshtasticLogger +import os + +messages = [] +app = Flask(__name__) +interface = meshtastic.serial_interface.SerialInterface() +log_path = "logs/mesh_logs.log" + +try: + with open(log_path, 'r') as file: + for line in file: + messages.append(line) +except FileNotFoundError: + # make the log directory if it isn't already there + with open(log_path, "w") as file: + file.write("Begin logs") + +def web_print(message: str): + with app.app_context(): + print(message) + messages.append(message) + get_messages() + +def callback(message: str): + web_print(message) + +def help_response(): + help_message: str = "Commands:\nhelp - show this message\clear - clear the text log\nsend - sends text to all channels\nchannel - sets the current channel (0-7)\n" + web_print(help_message) + +logger = MeshtasticLogger(interface, "logs/mesh_logs.log", channel=1, message_received_callback=callback) + +def channel_response(args: list[str]): + if(len(args) < 2): + web_print(f"Current channel number: {logger.channel}") + return + channel: int = int(args[1]) + if channel > 7 or channel < 0: + web_print("Channel must be between 0 and 7") + else: + try: + logger.channel = channel + except Exception as e: + web_print(e) + else: + web_print(f"Channel set to {channel}") + +def send_response(args: list[str], command: str): + if len(args) < 2: + web_print("Please provide text to send") + else: + logger.send(command[5:]) + web_print(f"Sent: {command[5:]}") + +def parse_command(command): + args = command.split(" ") + if len(args) < 1: + return True + + if args[0] == "clear": + messages.clear() + elif args[0] == "help": + help_response() + elif args[0] == "channel": + channel_response(args) + elif args[0] == "send": + send_response(args, command) + else: + web_print("Command not recognized. Type \'help\' for a list of commands.") + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/send', methods=['POST']) +def send(): + data = request.json + message = data.get("message", "") + parse_command(message) + return jsonify({"messages": messages}) + +@app.route('/messages', methods=['GET']) +def get_messages(): + return jsonify({"messages": messages}) + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=80, debug=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..effee5d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +argcomplete==3.6.1 +bleak==0.22.3 +blinker==1.9.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.1.8 +dbus-fast==2.44.0 +dotmap==1.3.30 +Flask==3.1.0 +idna==3.10 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +meshtastic==2.6.0 +packaging==24.2 +print-color==0.4.6 +protobuf==6.30.2 +Pypubsub==4.0.3 +PyQRCode==1.2.1 +pyserial==3.5 +pytap2==2.3.0 +PyYAML==6.0.2 +requests==2.32.3 +tabulate==0.9.0 +typing_extensions==4.13.0 +urllib3==2.3.0 +wcwidth==0.2.13 +Werkzeug==3.1.3 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..819f813 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,101 @@ + + + + + + Simple Chat + + + + + + + + + + +