From ea2f17025f9315b2a0103d8dfe4eba16f35fc8f4 Mon Sep 17 00:00:00 2001 From: Alex Wolden Date: Sat, 12 Apr 2025 13:02:01 -0700 Subject: [PATCH] Add trace packet type --- examples/serial_trace.py | 70 ++++++++++++++++++++++++++++++++++++++++ src/meshcore/commands.py | 50 +++++++++++++++++++++++++++- src/meshcore/events.py | 5 +-- src/meshcore/packets.py | 3 +- src/meshcore/reader.py | 44 +++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 examples/serial_trace.py diff --git a/examples/serial_trace.py b/examples/serial_trace.py new file mode 100644 index 0000000..cd609e9 --- /dev/null +++ b/examples/serial_trace.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import asyncio +import argparse +from meshcore import MeshCore +from meshcore.events import EventType + +async def get_repeater_name(mc, hash_prefix): + """Find a contact by its 2-character hash prefix and return its name""" + # Ensure contacts are available + await mc.ensure_contacts() + + # Find contact with matching hash prefix + contact = mc.get_contact_by_key_prefix(hash_prefix) + if contact: + return contact.get("adv_name", f"Unknown ({hash_prefix})") + else: + return f"Unknown ({hash_prefix})" + +async def main(): + parser = argparse.ArgumentParser(description='MeshCore Serial Trace Example') + parser.add_argument('-p', '--port', required=True, help='Serial port path') + parser.add_argument('--path', type=str, help='Trace path (comma-separated hex values)') + args = parser.parse_args() + + try: + # Connect to device + print(f"Connecting to {args.port}...") + mc = await MeshCore.create_serial(args.port, 115200, debug=True) + + # Send trace packet + print(f"Sending trace packet...") + result = await mc.commands.send_trace(path=args.path) + + if result: + print("Trace packet sent successfully") + print("Waiting for trace response...") + + # Wait for a trace response with 15-second timeout + event = await mc.wait_for_event(EventType.TRACE_DATA, timeout=15) + + if event: + trace = event.payload + print(f"Trace data received:") + print(f" Tag: {trace['tag']}") + print(f" Flags: {trace.get('flags', 0)}") + print(f" Path Length: {trace.get('path_len', 0)}") + + if trace.get('path'): + print(f" Path ({len(trace['path'])} nodes):") + + # Process nodes with hash (repeaters) + for i, node in enumerate(trace['path']): + if 'hash' in node: + # Look up repeater name + repeater_name = await get_repeater_name(mc, node['hash']) + print(f" Node {i+1}: {repeater_name}, SNR={node['snr']:.1f} dB") + else: + print(f" Node {i+1}: SNR={node['snr']:.1f} dB (final node)") + else: + print("No trace response received within timeout") + else: + print("Failed to send trace packet") + + await mc.disconnect() + + except Exception as e: + print(f"Error: {e}") + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/src/meshcore/commands.py b/src/meshcore/commands.py index 67f5f0b..293c0e6 100644 --- a/src/meshcore/commands.py +++ b/src/meshcore/commands.py @@ -224,4 +224,52 @@ class CommandHandler: async def send_cli(self, cmd): logger.debug(f"Sending CLI command: {cmd}") data = b"\x32" + cmd.encode('ascii') - return await self.send(data, [EventType.CLI_RESPONSE, EventType.ERROR]) \ No newline at end of file + return await self.send(data, [EventType.CLI_RESPONSE, EventType.ERROR]) + + async def send_trace(self, auth_code=0, tag=None, flags=0, path=None): + """ + Send a trace packet to test routing through specific repeaters + + Args: + auth_code: 32-bit authentication code (default: 0) + tag: 32-bit integer to identify this trace (default: random) + flags: 8-bit flags field (default: 0) + path: Optional string with comma-separated hex values representing repeater pubkeys (e.g. "23,5f,3a") + or a bytes/bytearray object with the raw path data + + Returns: + Dictionary with sent status, tag, and estimated timeout in milliseconds, or False if command failed + """ + # Generate random tag if not provided + if tag is None: + import random + tag = random.randint(1, 0xFFFFFFFF) + + logger.debug(f"Sending trace: tag={tag}, auth={auth_code}, flags={flags}, path={path}") + + # Prepare the command packet: CMD(1) + tag(4) + auth_code(4) + flags(1) + [path] + cmd_data = bytearray([36]) # CMD_SEND_TRACE_PATH + cmd_data.extend(tag.to_bytes(4, 'little')) + cmd_data.extend(auth_code.to_bytes(4, 'little')) + cmd_data.append(flags) + + # Process path if provided + if path: + if isinstance(path, str): + # Convert comma-separated hex values to bytes + try: + path_bytes = bytearray() + for hex_val in path.split(','): + hex_val = hex_val.strip() + path_bytes.append(int(hex_val, 16)) + cmd_data.extend(path_bytes) + except ValueError as e: + logger.error(f"Invalid path format: {e}") + return False + elif isinstance(path, (bytes, bytearray)): + cmd_data.extend(path) + else: + logger.error(f"Unsupported path type: {type(path)}") + return False + + return await self.send(cmd_data, [EventType.MSG_SENT, EventType.ERROR]) \ No newline at end of file diff --git a/src/meshcore/events.py b/src/meshcore/events.py index 8688a0e..d24991c 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -2,7 +2,7 @@ from enum import Enum import logging from typing import Any, Dict, Optional, Callable, List, Union import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field logger = logging.getLogger("meshcore") @@ -30,6 +30,7 @@ class EventType(Enum): LOGIN_FAILED = "login_failed" STATUS_RESPONSE = "status_response" LOG_DATA = "log_data" + TRACE_DATA = "trace_data" # Command response types OK = "command_ok" @@ -40,7 +41,7 @@ class EventType(Enum): class Event: type: EventType payload: Any - attributes: Dict[str, Any] = {} + attributes: Dict[str, Any] = field(default_factory=dict) def __post_init__(self): if self.attributes is None: diff --git a/src/meshcore/packets.py b/src/meshcore/packets.py index 2539982..6797489 100644 --- a/src/meshcore/packets.py +++ b/src/meshcore/packets.py @@ -27,4 +27,5 @@ class PacketType(Enum): LOGIN_SUCCESS = 0x85 LOGIN_FAILED = 0x86 STATUS_RESPONSE = 0x87 - LOG_DATA = 0x88 \ No newline at end of file + LOG_DATA = 0x88 + TRACE_DATA = 0x89 \ No newline at end of file diff --git a/src/meshcore/reader.py b/src/meshcore/reader.py index 761bdbb..eb795e9 100644 --- a/src/meshcore/reader.py +++ b/src/meshcore/reader.py @@ -230,6 +230,50 @@ class MessageReader: logger.debug("Received log data") await self.dispatcher.dispatch(Event(EventType.LOG_DATA, data[1:].decode('utf-8', errors='replace'))) + elif packet_type_value == PacketType.TRACE_DATA.value: + logger.debug(f"Received trace data: {data.hex()}") + res = {} + + # According to the source, format is: + # 0x89, reserved(0), path_len, flags, tag(4), auth(4), path_hashes[], path_snrs[], final_snr + + reserved = data[1] + path_len = data[2] + flags = data[3] + tag = int.from_bytes(data[4:8], byteorder='little') + auth_code = int.from_bytes(data[8:12], byteorder='little') + + # Initialize result + res["tag"] = tag + res["auth"] = auth_code + res["flags"] = flags + res["path_len"] = path_len + + # Process path as array of objects with hash and SNR + path_nodes = [] + + if path_len > 0 and len(data) >= 12 + path_len*2 + 1: + # Extract path with hash and SNR pairs + for i in range(path_len): + node = { + "hash": f"{data[12+i]:02x}", + # SNR is stored as a signed byte representing SNR * 4 + "snr": (data[12+path_len+i] if data[12+path_len+i] < 128 else data[12+path_len+i] - 256) / 4.0 + } + path_nodes.append(node) + + # Add the final node (our device) with its SNR + final_snr_byte = data[12+path_len*2] + final_snr = (final_snr_byte if final_snr_byte < 128 else final_snr_byte - 256) / 4.0 + path_nodes.append({ + "snr": final_snr + }) + + res["path"] = path_nodes + + logger.debug(f"Parsed trace data: {res}") + await self.dispatcher.dispatch(Event(EventType.TRACE_DATA, res)) + else: logger.debug(f"Unhandled data received {data}") logger.debug(f"Unhandled packet type: {packet_type_value}") \ No newline at end of file