From 895a40a389cf12e15b5fc0a27d709680f6361eb3 Mon Sep 17 00:00:00 2001 From: Cynopolis Date: Tue, 1 Apr 2025 22:08:06 -0400 Subject: [PATCH] Recreating broken commits --- .gitignore | 4 ++ MeshtasticDataClasses.py | 119 +++++++++++++++++++++++++++++++++++++++ MeshtasticLogger.py | 73 ++++++++++++++++++++++++ MeshtasticLogger.service | 14 +++++ README.md | 23 ++++++++ app.py | 89 +++++++++++++++++++++++++++++ requirements.txt | 28 +++++++++ templates/index.html | 101 +++++++++++++++++++++++++++++++++ 8 files changed, 451 insertions(+) create mode 100644 .gitignore create mode 100644 MeshtasticDataClasses.py create mode 100644 MeshtasticLogger.py create mode 100644 MeshtasticLogger.service create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 templates/index.html 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 + + + + + + + + + + +