Files
meshcore-bot/modules/packet_decode.py
T
agessaman 885a61742f refactor(utils): move packet framing and advert signature helpers to the decoders
decode_path_len_byte, encode_path_len_byte, parse_trace_payload_route_hashes and calculate_packet_hash move verbatim to packet_decode.py, which already handled packet framing and imported decode_path_len_byte from utils; verify_meshcore_advert_ed25519 moves next to the advert parser in meshcore_payload_decode.py. utils re-exports all five as the same objects, and no test patched them. get_packet_hash_placeholder stays in utils with the response template it serves. utils.py drops to 1,630 lines.
2026-10-01 14:28:06 -07:00

511 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MeshCore packet decoding: header, transport codes, path and payload split.
Pure functions matching the firmware's Packet.cpp layout. MessageHandler keeps
its old methods as thin delegates that supply the configured node-prefix width
and its logger.
"""
from __future__ import annotations
import hashlib
from typing import Any, Optional
from .enums import PayloadType, PayloadVersion, RouteType
def decode_path_len_byte(path_len_byte: int, max_path_size: int = 64) -> tuple[int, int] | None:
"""Decode the RF packet path_len byte per firmware ``Packet::isValidPathLen``.
Encoding: low 6 bits = hop count, high 2 bits = size code.
``bytes_per_hop = (path_len >> 6) + 1`` → 1, 2, 3, or 4 (4 is reserved and invalid).
Args:
path_len_byte: The single path_len byte from the packet.
max_path_size: Max path bytes (default 64, matches ``MAX_PATH_SIZE``).
Returns:
``(path_byte_length, bytes_per_hop)`` if the encoding is valid on the wire.
``None`` if reserved size class (4) or ``hop_count * bytes_per_hop > max_path_size``
— matching MeshCore where ``readFrom`` rejects the packet (no legacy reinterpretation).
"""
hop_count = path_len_byte & 63
size_code = path_len_byte >> 6
bytes_per_hop = size_code + 1 # 1, 2, 3, or 4
if bytes_per_hop == 4:
return None
path_byte_length = hop_count * bytes_per_hop
if path_byte_length > max_path_size:
return None
return (path_byte_length, bytes_per_hop)
def parse_trace_payload_route_hashes(payload: bytes) -> list[str]:
"""Extract TRACE route hash segments from mesh payload (after tag, auth, flags).
Matches MeshCore: ``bytes_per_hash = 1 << (flags & 3)`` for bytes at ``payload[9:]``.
If the tail length is not a multiple of ``bytes_per_hash``, falls back to 1-byte
segments (same as MessageHandler._process_packet_path).
Args:
payload: Full mesh payload bytes (not including header/path).
Returns:
List of uppercase hex strings, one per hop hash.
"""
if len(payload) < 9:
return []
flags = payload[8]
path_hash_len = 1 << (flags & 3)
if path_hash_len <= 0:
path_hash_len = 1
path_hashes_bytes = payload[9:]
if not path_hashes_bytes:
return []
try:
if len(path_hashes_bytes) % path_hash_len == 0:
return [
path_hashes_bytes[i : i + path_hash_len].hex().upper()
for i in range(0, len(path_hashes_bytes), path_hash_len)
]
except Exception:
pass
return [f"{b:02X}" for b in path_hashes_bytes]
def encode_path_len_byte(hop_count: int, bytes_per_hop: int) -> int:
"""Pack hop count and hash size into the single path_len wire byte (inverse of decode_path_len_byte).
Firmware: low 6 bits = hop count, high 2 bits = size code with bytes_per_hop = (code + 1).
Valid bytes_per_hop are 1, 2, or 3 (size code 4 is reserved).
"""
if bytes_per_hop not in (1, 2, 3):
raise ValueError(f"bytes_per_hop must be 1, 2, or 3, got {bytes_per_hop}")
hop_count = int(hop_count) & 0x3F
size_code = (int(bytes_per_hop) - 1) & 0x03
return (size_code << 6) | hop_count
def calculate_packet_hash(raw_hex: str, payload_type: Optional[int] = None) -> str:
"""Calculate hash for packet identification - based on packet.cpp.
Packet hashes are unique to the originally sent message, allowing
identification of the same message arriving via different paths.
Args:
raw_hex: Raw packet data as hex string.
payload_type: Optional payload type as integer (if None, extracted from header).
Must be numeric value (0-15).
Returns:
str: 16-character hex string (8 bytes) in uppercase, or "0000000000000000" on error.
"""
try:
# Parse the packet to extract payload type and payload data
byte_data = bytes.fromhex(raw_hex)
header = byte_data[0]
# Get payload type from header (bits 2-5)
if payload_type is None:
payload_type = (header >> 2) & 0x0F
else:
# Ensure payload_type is an integer (handle enum.value if passed)
if hasattr(payload_type, 'value'):
payload_type = payload_type.value
payload_type = int(payload_type) & 0x0F # Ensure it's 0-15
# Check if transport codes are present
route_type = header & 0x03
has_transport = route_type in [0x00, 0x03] # TRANSPORT_FLOOD or TRANSPORT_DIRECT
# Calculate path length offset dynamically based on transport codes
offset = 1 # After header
if has_transport:
offset += 4 # Skip 4 bytes of transport codes
# Validate we have enough bytes for path_len
if len(byte_data) <= offset:
return "0000000000000000"
path_len_byte = byte_data[offset]
offset += 1
path_parts = decode_path_len_byte(path_len_byte)
if path_parts is None:
return "0000000000000000"
path_byte_length, _ = path_parts
# Validate we have enough bytes for the path
if len(byte_data) < offset + path_byte_length:
return "0000000000000000"
# Skip past the path to get to payload
payload_start = offset + path_byte_length
# Validate we have payload data
if len(byte_data) <= payload_start:
return "0000000000000000"
payload_data = byte_data[payload_start:]
# Calculate hash exactly like MeshCore Packet::calculatePacketHash():
# 1. Payload type (1 byte)
# 2. Path length (2 bytes as uint16_t, little-endian) - ONLY for TRACE packets (type 9)
# 3. Payload data
hash_obj = hashlib.sha256()
hash_obj.update(bytes([payload_type]))
if payload_type == 9: # PAYLOAD_TYPE_TRACE
# C++ does: sha.update(&path_len, sizeof(path_len))
# path_len is the raw wire byte (uint16_t in firmware), not the decoded byte count
hash_obj.update(path_len_byte.to_bytes(2, byteorder='little'))
hash_obj.update(payload_data)
# Return first 16 hex characters (8 bytes) in uppercase
return hash_obj.hexdigest()[:16].upper()
except Exception:
# Return default hash on error (caller should handle logging)
return "0000000000000000"
def split_path_hex(path_hex: str, hex_chars: int) -> list[str]:
"""Split a hex path into lowercase node IDs of ``hex_chars`` each.
When the path does not divide evenly (or yields nothing), fall back to
one-byte (two-character) nodes, the legacy path encoding.
"""
nodes = [path_hex[i : i + hex_chars].lower() for i in range(0, len(path_hex), hex_chars)]
if (len(path_hex) % hex_chars) != 0 or not nodes:
nodes = [path_hex[i : i + 2].lower() for i in range(0, len(path_hex), 2)]
return nodes
def path_bytes_to_nodes(path_bytes: bytes, prefix_hex_chars: int) -> tuple[str, list[str]]:
"""Path bytes as ``(hex, uppercase node IDs)``, ``prefix_hex_chars`` per node.
A non-positive width means one byte per node; see :func:`split_path_hex`
for the fallback when the path does not divide evenly.
"""
n = prefix_hex_chars if prefix_hex_chars > 0 else 2
path_hex = path_bytes.hex()
return path_hex, [node.upper() for node in split_path_hex(path_hex, n)]
def decode_meshcore_packet(
raw_hex: str, payload_hex: str | None = None, *, prefix_hex_chars: int, logger: Any
) -> dict | None:
"""
Decode a MeshCore packet from raw hex data - matches Packet.cpp exactly
Args:
raw_hex: Raw packet data as hex string (may be RF data or direct MeshCore packet)
payload_hex: Optional extracted payload hex string (preferred over raw_hex)
Returns:
Decoded packet information or None if parsing fails
"""
# Ensure these are always defined for error logging (BUG-028)
byte_data: bytes = b""
hex_data: str = ""
try:
# Use payload_hex if provided (this is the actual MeshCore packet)
if payload_hex:
logger.debug("Using provided payload_hex for decoding")
hex_data = payload_hex
elif raw_hex:
logger.debug("Using raw_hex for decoding")
hex_data = raw_hex
else:
logger.debug("No packet data provided for decoding")
return None
# Remove 0x prefix if present (like in your other project)
if hex_data.startswith("0x"):
hex_data = hex_data[2:]
byte_data = bytes.fromhex(hex_data)
# Validate minimum packet size
if len(byte_data) < 2:
logger.error(f"Packet too short: {len(byte_data)} bytes")
return None
header = byte_data[0]
# Extract route type
route_type = RouteType(header & 0x03)
has_transport = route_type in [RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT]
# Calculate path length offset based on presence of transport codes
offset = 1
if has_transport:
offset += 4
# Check if we have enough data for path_len
if len(byte_data) <= offset:
logger.error(f"Packet too short for path_len at offset {offset}: {len(byte_data)} bytes")
return None
path_len_byte = byte_data[offset]
offset += 1
# Decode per firmware: low 6 bits = hop count, high 2 bits = size code (bytes_per_hop = code+1)
path_parts = decode_path_len_byte(path_len_byte)
if path_parts is None:
logger.debug("decode_meshcore_packet: invalid path_len byte (firmware would reject)")
return None
path_byte_length, bytes_per_hop = path_parts
# Check if we have enough data for the full path
if len(byte_data) < offset + path_byte_length:
logger.error(
f"Packet too short for path (need {offset + path_byte_length}, have {len(byte_data)})"
)
return None
# Extract path
path_bytes = byte_data[offset : offset + path_byte_length]
offset += path_byte_length
# Remaining data is payload
payload = byte_data[offset:]
# Extract payload version (bits 6-7)
payload_version = PayloadVersion((header >> 6) & 0x03)
# Only accept VER_1 (version 0)
if payload_version != PayloadVersion.VER_1:
logger.warning(
f"Encountered an unknown packet version. Version: {payload_version.value} RAW: {hex_data}"
)
return None
# Extract payload type (bits 2-5)
payload_type = PayloadType((header >> 2) & 0x0F)
# Chunk path by bytes_per_hop from packet (1, 2, or 3)
path_hex, path_values = path_bytes_to_nodes(path_bytes, bytes_per_hop * 2)
# Process path based on packet type
path_info = process_packet_path(
path_bytes, payload, route_type, payload_type, prefix_hex_chars=prefix_hex_chars, logger=logger
)
# Extract transport codes if present (only for TRANSPORT_FLOOD and TRANSPORT_DIRECT)
transport_codes = None
if has_transport and len(byte_data) >= 5: # header(1) + transport(4)
transport_bytes = byte_data[1:5]
transport_codes = {
"code1": int.from_bytes(transport_bytes[0:2], byteorder="little"),
"code2": int.from_bytes(transport_bytes[2:4], byteorder="little"),
"hex": transport_bytes.hex(),
}
packet_info = {
"header": f"0x{header:02x}",
# Raw values for backward compatibility
"route_type": route_type.value,
"route_type_name": route_type.name,
"payload_type": payload_type.value,
"payload_type_name": payload_type.name,
"payload_version": payload_version.value,
# Enum objects for improved type safety
"route_type_enum": route_type,
"payload_type_enum": payload_type,
"payload_version_enum": payload_version,
# Transport and path information
"has_transport_codes": has_transport,
"transport_codes": transport_codes,
"transport_size": 4 if has_transport else 0,
"path_len": len(path_values), # Hop count for display / routing_info
"path_len_byte": path_len_byte, # Raw wire byte (same as firmware Packet path_len)
"path_byte_length": path_byte_length, # Path bytes (for logs showing "X bytes")
"bytes_per_hop": bytes_per_hop, # For multi-byte path storage/retrieval
"path_info": path_info,
"path": path_values, # For backward compatibility
"path_hex": path_hex,
"payload_hex": payload.hex(),
"payload_bytes": len(payload),
}
logger.debug(
f"Successfully decoded: route={packet_info.get('route_type_name')}, type={packet_info.get('payload_type_name')}"
)
return packet_info
except Exception as e:
# Log as ERROR not DEBUG so we can see what's failing
logger.error(f"Error decoding packet (len={len(byte_data)}): {e}", exc_info=True)
logger.error(f"Failed packet hex: {hex_data}")
return None
def process_packet_path(
path_bytes: bytes,
payload: bytes,
route_type: RouteType,
payload_type: PayloadType,
*,
prefix_hex_chars: int,
logger: Any,
) -> dict:
"""
Process the path field based on packet and route type
Args:
path_bytes: Raw path bytes
payload: Payload bytes (needed for TRACE packets)
route_type: Route type from header
payload_type: Payload type from header
Returns:
dict: Processed path information
"""
try:
# Chunk path bytes into node IDs using configured prefix length (with legacy fallback)
_, path_nodes = path_bytes_to_nodes(path_bytes, prefix_hex_chars)
# Special handling for TRACE packets
if payload_type == PayloadType.TRACE:
# RF path bytes are per-hop SNR×4 (int8), not node hashes. The commanded route is
# in the payload after tag(4)+auth(4)+flags(1); use path_info / parse_trace_payload_route_hashes for display.
# In TRACE packets, path field contains SNR data
# Real routing path is in the payload as pathHashes (after tag(4) + auth(4) + flags(1))
snr_values = []
for b in path_bytes:
# Convert SNR byte to dB (signed value)
snr_db = (b - 256) / 4 if b > 127 else b / 4
snr_values.append(snr_db)
# Decode trace payload to extract pathHashes (routing path)
# path_hash_len from flags (bits 0-1): 1 << (flags & 3) = 1, 2, 4, or 8 bytes per hop
path_hashes = []
if len(payload) >= 9: # Minimum: tag(4) + auth(4) + flags(1)
try:
path_hashes_bytes = payload[9:]
flags = payload[8]
path_hash_len = 1 << (flags & 3) # 1, 2, 4, or 8 bytes per hop
if path_hash_len <= 0:
path_hash_len = 1
if len(path_hashes_bytes) % path_hash_len == 0:
path_hashes = [
path_hashes_bytes[i : i + path_hash_len].hex().upper()
for i in range(0, len(path_hashes_bytes), path_hash_len)
]
else:
# Fallback: 1 byte per hop (legacy)
path_hashes = [f"{b:02x}".upper() for b in path_hashes_bytes]
except Exception as e:
logger.debug(f"Error extracting pathHashes from trace payload: {e}")
path_hashes = [f"{b:02x}".upper() for b in payload[9:]]
return {
"type": "trace",
"snr_data": snr_values,
"snr_path": path_nodes, # SNR data as hex for reference
"path": path_hashes, # Actual routing path from payload pathHashes
"path_hashes": path_hashes, # Explicit field for pathHashes
"description": f"TRACE packet with {len(snr_values)} SNR readings and {len(path_hashes)} path nodes",
}
# Regular packets - determine path type based on route type
is_direct = route_type in [RouteType.DIRECT, RouteType.TRANSPORT_DIRECT]
if is_direct:
# Direct routing: path contains routing instructions
# Bytes are stripped at each hop
return {
"type": "routing_instructions",
"path": path_nodes,
"meaning": "bytes_stripped_at_each_hop",
"description": f"Direct route via {','.join(path_nodes)} ({len(path_nodes)} hops)",
}
else:
# Flood routing: path contains historical route
# Bytes are added as packet floods through network
return {
"type": "historical_route",
"path": path_nodes,
"meaning": "bytes_added_as_packet_floods",
"description": f"Flooded through {','.join(path_nodes)} ({len(path_nodes)} hops)",
}
except Exception as e:
logger.error(f"Error processing packet path: {e}")
# Return basic path info as fallback (legacy 1-byte-per-hop)
_, path_nodes = path_bytes_to_nodes(path_bytes, 2)
return {"type": "unknown", "path": path_nodes, "description": f"Path: {','.join(path_nodes)}"}
def format_path_string(hex_path: str, bytes_per_hop: int | None = None, *, logger: Any) -> str:
"""
Convert a hex path string to node prefix format.
Args:
hex_path: Hex string representing the path (e.g., "01025f7e" or "01025fab" for 2-byte hops).
bytes_per_hop: Optional bytes per hop (1, 2, or 3) for multi-byte paths; None = legacy 1 byte per node.
Returns:
str: Formatted path string (e.g., "01,02,5f,7e" or "0102,5fab")
"""
try:
if not hex_path:
return "Direct"
if bytes_per_hop is not None and bytes_per_hop > 0:
hex_chars = bytes_per_hop * 2
path_nodes = split_path_hex(hex_path, hex_chars)
if path_nodes:
return ",".join(path_nodes)
return "Direct"
# Legacy: one byte per node (two hex chars)
path_bytes = bytes.fromhex(hex_path)
path_nodes = []
for i in range(len(path_bytes)):
node_id = path_bytes[i]
path_nodes.append(f"{node_id:02x}")
if path_nodes:
return ",".join(path_nodes)
else:
return "Direct"
except Exception as e:
logger.debug(f"Error formatting path string: {e}")
truncated = hex_path[:16] if len(hex_path) > 16 else hex_path
return f"Raw: {truncated}{'...' if len(hex_path) > 16 else ''}"
def route_type_name(route_type: int) -> str:
"""Get human-readable name for route type"""
route_types = {
0x00: "ROUTE_TYPE_TRANSPORT_FLOOD",
0x01: "ROUTE_TYPE_FLOOD",
0x02: "ROUTE_TYPE_DIRECT",
0x03: "ROUTE_TYPE_TRANSPORT_DIRECT",
}
return route_types.get(route_type, f"UNKNOWN_ROUTE_{route_type:02x}")
def payload_type_name(payload_type: int) -> str:
"""Get human-readable name for payload type"""
payload_types = {
0x00: "REQ",
0x01: "RESPONSE",
0x02: "TXT_MSG",
0x03: "ACK",
0x04: "ADVERT",
0x05: "GRP_TXT",
0x06: "GRP_DATA",
0x07: "ANON_REQ",
0x08: "PATH",
0x09: "TRACE",
0x0A: "MULTIPART",
# Additional payload types found in meshcore library (may not be in official spec)
0x0B: "UNKNOWN_0b", # Not defined in official spec
0x0C: "UNKNOWN_0c", # Not defined in official spec
0x0D: "UNKNOWN_0d", # Not defined in official spec
0x0E: "UNKNOWN_0e", # Not defined in official spec
0x0F: "RAW_CUSTOM",
}
return payload_types.get(payload_type, f"UNKNOWN_{payload_type:02x}")