From 4922f153261c9be656dccab406bb89f502e9e0b1 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Tue, 29 Jul 2025 15:01:27 +0200 Subject: [PATCH 1/4] Create sensor admin commands --- meshtastic/__main__.py | 71 ++++++++++++++++++++++++++++++++++++++++++ meshtastic/node.py | 4 +++ 2 files changed, 75 insertions(+) diff --git a/meshtastic/__main__.py b/meshtastic/__main__.py index 978da0abd..e9e969319 100644 --- a/meshtastic/__main__.py +++ b/meshtastic/__main__.py @@ -854,6 +854,53 @@ def onConnected(interface): print(f"Deleting channel {channelIndex}") ch = interface.getNode(args.dest, **getNode_kwargs).deleteChannel(channelIndex) + if args.sensor_admin: + + closeNow = True + waitForAckNak = True + node = interface.getNode(args.dest, False, **getNode_kwargs) + + # Handle the int/float/bool arguments + pref = None + fields = set() + for pref in args.sensor_admin: + found = False + field = splitCompoundName(pref[0].lower())[0] + + for config in [node.sensorConfig]: + config_type = config.DESCRIPTOR.fields_by_name.get(field) + if config_type: + if len(config.ListFields()) == 0: + node.requestConfig( + config.DESCRIPTOR.fields_by_name.get(field) + ) + found = setPref(config, pref[0], pref[1]) + if found: + fields.add(field) + break + + if found: + print("Writing modified preferences to device") + if len(fields) > 1: + print("Using a configuration transaction") + node.beginSettingsTransaction() + for field in fields: + print(f"Writing {field} configuration to device") + node.writeConfig(field) + if len(fields) > 1: + node.commitSettingsTransaction() + else: + if mt_config.camel_case: + print( + f"{node.localConfig.__class__.__name__} and {node.moduleConfig.__class__.__name__} do not have an attribute {pref[0]}." + ) + else: + print( + f"{node.localConfig.__class__.__name__} and {node.moduleConfig.__class__.__name__} do not have attribute {pref[0]}." + ) + print("Choices are...") + printConfig(node.sensorConfig) + def setSimpleConfig(modem_preset): """Set one of the simple modem_config""" channelIndex = mt_config.channel_index @@ -1940,6 +1987,27 @@ def addRemoteAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentPars return parser +def addSensorAdminArgs(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: + """Add arguments concerning admin actions that interact with sensors""" + + group = parser.add_argument_group( + "Sensor Admin Actions", + "Arguments that interact with local node or remote nodes via the mesh, for sensor configuration.", + ) + + group.add_argument( + "--sensor-admin", + help=( + "Set a preferences field. Can use either snake_case or camelCase format." + " (ex: 'scdxx.asc' or 'power.lsSecs')" + ), + nargs=2, + action="append", + metavar=("FIELD", "VALUE"), + ) + + return parser + def initParser(): """Initialize the command line argument parsing.""" parser = mt_config.parser @@ -1980,6 +2048,9 @@ def initParser(): parser = addRemoteActionArgs(parser) parser = addRemoteAdminArgs(parser) + # Arguments for controlling sensors + parser = addSensorAdminArgs(parser) + # All the rest of the arguments group = parser.add_argument_group("Miscellaneous arguments") diff --git a/meshtastic/node.py b/meshtastic/node.py index e6bd3ca6c..34ff77f55 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -31,6 +31,7 @@ def __init__(self, iface, nodeNum, noProto=False, timeout: int = 300): self.nodeNum = nodeNum self.localConfig = localonly_pb2.LocalConfig() self.moduleConfig = localonly_pb2.LocalModuleConfig() + self.sensorConfig = admin_pb2.SensorConfig() self.channels = None self._timeout = Timeout(maxSecs=timeout) self.partialChannels: Optional[List] = None @@ -221,6 +222,9 @@ def writeConfig(self, config_name): p.set_module_config.ambient_lighting.CopyFrom(self.moduleConfig.ambient_lighting) elif config_name == "paxcounter": p.set_module_config.paxcounter.CopyFrom(self.moduleConfig.paxcounter) + # TODO - Currently not working + elif config_name == "scdxx_config": + p.set_module_config.scdxx_config.CopyFrom(self.sensorConfig.scdxx_config) else: our_exit(f"Error: No valid config with name {config_name}") From c18ec771712f52a84ab5575eff8c20726a1b1ade Mon Sep 17 00:00:00 2001 From: Pral2a Date: Sun, 6 Jul 2025 14:38:22 +0200 Subject: [PATCH 2/4] Add MQTT bridge example for Meshtastic with optional payload decryption This script connects to a Meshtastic MQTT broker, subscribes to specified topics, and processes incoming packets. It supports optional decryption of encrypted payloads using a key from environment variables. The script parses and transforms protobuf messages, mapping sensor data to IDs and formatting timestamps. Environment variables for broker connection and topics are loaded via dotenv. The script demonstrates how to bridge Meshtastic MQTT data for further processing or integration. --- examples/mqtt_bridge.py | 152 ++++++++++++++++++++++++++++++++++++++++ meshtastic/.gitignore | 1 + 2 files changed, 153 insertions(+) create mode 100644 examples/mqtt_bridge.py diff --git a/examples/mqtt_bridge.py b/examples/mqtt_bridge.py new file mode 100644 index 000000000..ec5315444 --- /dev/null +++ b/examples/mqtt_bridge.py @@ -0,0 +1,152 @@ +# Meshtastic MQTT Packet Bridge Example +# +# This script connects to a Meshtastic MQTT broker, subscribes to mesh topics, +# and prints (optionally decrypts) incoming packets. +# +# Dependencies: +# pip install paho-mqtt cryptography meshtastic --user +# +# Usage: +# python mqtt-read.py +# +# Edit BROKER, USER, PASS, TOPICS, and KEY as needed for your setup. +# See https://github.com/meshtastic/Meshtastic-python for more info. +# --------------------------------------------------------------- + +import paho.mqtt.client as mqtt +import base64 +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend +from meshtastic.protobuf import mqtt_pb2, mesh_pb2 +from meshtastic import protocols +from google.protobuf.json_format import MessageToDict +import pprint +import datetime +import os +from dotenv import load_dotenv + +# Load environment variables from a .env file if present +load_dotenv() + +BROKER = os.getenv("MQTT_BROKER", "mqtt.smartcitizen.me") +USER = os.getenv("MQTT_USER", "") +PASS = os.getenv("MQTT_PASS", "") +PORT = int(os.getenv("MQTT_PORT", 1883)) + +TOPICS = os.getenv("MQTT_TOPICS", "device/sck/mesh12/2/#").split(",") +KEY = os.getenv("MQTT_KEY", "") +KEY = "" if KEY == "AQ==" else KEY + +# Callback when the client connects to the broker +def on_connect(client, userdata, flags, rc): + if rc == 0: + print("Connected to MQTT broker!") + for topic in TOPICS: + client.subscribe(topic) + print(f"Subscribed to topic: {topic}") + else: + print(f"Failed to connect, return code {rc}") + +# Callback when a message is received +def on_message(client, userdata, msg): + se = mqtt_pb2.ServiceEnvelope() + print (msg.payload) + se.ParseFromString(msg.payload) + print ('---') + decoded_mp = se.packet + + # Try to decrypt the payload if it is encrypted + if decoded_mp.HasField("encrypted") and not decoded_mp.HasField("decoded"): + decoded_data = decrypt_packet(decoded_mp, KEY) + if decoded_data is None: + print("Decryption failed; retaining original encrypted payload") + else: + decoded_mp.decoded.CopyFrom(decoded_data) + + # Attempt to process the decrypted or encrypted payload + portNumInt = decoded_mp.decoded.portnum if decoded_mp.HasField("decoded") else None + handler = protocols.get(portNumInt) if portNumInt else None + + pb = None + if handler is not None and handler.protobufFactory is not None: + pb = handler.protobufFactory() + pb.ParseFromString(decoded_mp.decoded.payload) + + if pb: + # Pretty print the protobuf as a dictionary + pb_dict = MessageToDict(pb, preserving_proto_field_name=True) + decoded_mp.decoded.payload = str(pb_dict).encode("utf-8") + print("Original Meshtastic protobuf:") + pprint.pprint(pb_dict) + print("Transformed SC payload:") + print(transform_meshtastic_json(pb_dict)) + + +def decrypt_packet(mp, key): + try: + key_bytes = base64.b64decode(key.encode('ascii')) + + # Build the nonce from message ID and sender + nonce_packet_id = getattr(mp, "id").to_bytes(8, "little") + nonce_from_node = getattr(mp, "from").to_bytes(8, "little") + nonce = nonce_packet_id + nonce_from_node + + # Decrypt the encrypted payload + cipher = Cipher(algorithms.AES(key_bytes), modes.CTR(nonce), backend=default_backend()) + decryptor = cipher.decryptor() + decrypted_bytes = decryptor.update(getattr(mp, "encrypted")) + decryptor.finalize() + + # Parse the decrypted bytes into a Data object + data = mesh_pb2.Data() + data.ParseFromString(decrypted_bytes) + return data + + except Exception as e: + return None + +def transform_meshtastic_json(pb_dict): + """ + Transforms Meshtastic-style dictionary into the desired output format. + Maps sensor names (with their parent key) to IDs and converts unix time to ISO8601. + Only includes sensors present in SENSOR_ID_MAP. + """ + SENSOR_ID_MAP = { + "air_quality_metrics.co2": 99, + "device_metrics.voltage": 98, + # Extend this mapping as needed, e.g. "env.pm25": 100 + } + + sensors = [] + for top_key, sub_dict in pb_dict.items(): + if isinstance(sub_dict, dict): + for sensor_name, value in sub_dict.items(): + map_key = f"{top_key}.{sensor_name}" + if map_key in SENSOR_ID_MAP: + sensors.append({ + "id": SENSOR_ID_MAP[map_key], + "value": value + }) + + # Convert unix time to ISO8601 UTC string + recorded_at = None + if "time" in pb_dict: + recorded_at = datetime.datetime.utcfromtimestamp(pb_dict["time"]).strftime("%Y-%m-%dT%H:%M:%SZ") + + return { + "data": [ + { + "recorded_at": recorded_at, + "sensors": sensors + } + ] + } + +client = mqtt.Client() +client.on_connect = on_connect +client.on_message = on_message +client.username_pw_set(USER, PASS) +try: + client.connect(BROKER, PORT, keepalive=60) + client.loop_forever() +except Exception as e: + print(f"An error occurred: {e}") \ No newline at end of file diff --git a/meshtastic/.gitignore b/meshtastic/.gitignore index bee8a64b7..4863d269b 100644 --- a/meshtastic/.gitignore +++ b/meshtastic/.gitignore @@ -1 +1,2 @@ __pycache__ +**/.env \ No newline at end of file From 53db3bd7ada673bcf2404c5cc64ccfbb42003b51 Mon Sep 17 00:00:00 2001 From: Pral2a Date: Sun, 6 Jul 2025 19:35:48 +0200 Subject: [PATCH 3/4] Pretty print for hackathon --- examples/mqtt_bridge.py | 64 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/examples/mqtt_bridge.py b/examples/mqtt_bridge.py index ec5315444..3385bebd2 100644 --- a/examples/mqtt_bridge.py +++ b/examples/mqtt_bridge.py @@ -37,6 +37,25 @@ KEY = os.getenv("MQTT_KEY", "") KEY = "" if KEY == "AQ==" else KEY + +# Map of device IDs to friendly names +# This is a dictionary that maps device IDs to their friendly names only for debuggin purposes during the hackathon. +DEVICE_NAME_MAP = { + # Example: device_id: "Friendly Name" + 2534365592: "mesh25-jm (jm25)", + 3665041700: "Meshtastic 1924 (1924)", + 2925623876: "mesh25-ze (zerg)" + # Add more mappings as needed +} + +# Add these color codes near the top, after imports +RESET = "\033[0m" +BOLD = "\033[1m" +CYAN = "\033[36m" +YELLOW = "\033[33m" +GREEN = "\033[32m" +RED = "\033[31m" + # Callback when the client connects to the broker def on_connect(client, userdata, flags, rc): if rc == 0: @@ -73,14 +92,49 @@ def on_message(client, userdata, msg): pb.ParseFromString(decoded_mp.decoded.payload) if pb: - # Pretty print the protobuf as a dictionary + # Pretty print the protobuf as a dictionary with extra identifying info pb_dict = MessageToDict(pb, preserving_proto_field_name=True) decoded_mp.decoded.payload = str(pb_dict).encode("utf-8") - print("Original Meshtastic protobuf:") - pprint.pprint(pb_dict) - print("Transformed SC payload:") - print(transform_meshtastic_json(pb_dict)) + # Gather extra info if available + device_id = getattr(decoded_mp, "from", None) + packet_id = getattr(decoded_mp, "id", None) + timestamp = pb_dict.get("time") or pb_dict.get("timestamp") + iso_time = None + if timestamp: + try: + iso_time = datetime.datetime.utcfromtimestamp(int(timestamp)).strftime("%Y-%m-%dT%H:%M:%SZ") + except Exception: + pass + + print(f"{BOLD}{CYAN}=== Meshtastic Packet Info ==={RESET}") + print(f"{BOLD}🔑 Device ID:{RESET} {device_id}") + # Pretty print device name using the map + device_name = DEVICE_NAME_MAP.get(device_id, f"{RED}Unknown device{RESET}") + print(f"{BOLD}📟 Device Name:{RESET} {device_name}") + print(f"{BOLD}🗂️ Packet ID:{RESET} {packet_id}") + print(f"{BOLD}⏰ Timestamp:{RESET} {iso_time if iso_time else 'N/A'}") + print(f"{BOLD}📡 Port Number:{RESET} {portNumInt if portNumInt is not None else 'N/A'}") + + # portnum_name = None + # if portNumInt is not None: + # try: + # portnum_name = mesh_pb2.PortNum.Name(portNumInt) + # except Exception: + # portnum_name = "UNKNOWN" + # print(f"{BOLD}📡 Port Number:{RESET} {portNumInt if portNumInt is not None else 'N/A'} ({portnum_name})") + + print(f"{BOLD}📦 Full protobuf payload:{RESET}") + pprint.pprint(pb_dict, sort_dicts=False, indent=2) + + + + # Only print transformed SC payload if portNumInt == 67 (TELEMETRY_APP) + # Todo: Change for Protobuf definition. + if portNumInt == 67: + print(f"{BOLD}🔄 Transformed SC payload:{RESET}") + print(transform_meshtastic_json(pb_dict)) + def decrypt_packet(mp, key): try: From 1a5a3a3cdb87abf591995616dddc2116c684a2cd Mon Sep 17 00:00:00 2001 From: Pral2a Date: Sun, 6 Jul 2025 19:36:35 +0200 Subject: [PATCH 4/4] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d6f5bdc16..0f0f7863f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ examples/__pycache__ meshtastic.spec .hypothesis/ coverage.xml -.ipynb_checkpoints \ No newline at end of file +.ipynb_checkpoints +.env \ No newline at end of file