feat(trace_command): enhance path parsing for multibyte nodes and update documentation

- Updated the TraceCommand class to support parsing of 1-byte, 2-byte, and 3-byte hex node paths.
- Improved the _extract_path_from_message and _parse_path_arg methods to handle multibyte node formats and enforce consistent length checks.
- Enhanced help text and usage examples to reflect new path formats.
- Added unit tests for multibyte path parsing and reciprocal path building to ensure correct functionality.
This commit is contained in:
agessaman
2026-04-25 20:28:41 -07:00
parent 2e7c56207a
commit 6d80ad03da
2 changed files with 143 additions and 16 deletions
+37 -14
View File
@@ -26,7 +26,7 @@ class TraceCommand(BaseCommand):
short_description = "Run link trace (manual or reciprocal path)"
usage = "trace [path] or tracer [path]"
examples = ["trace 01,7a,55", "tracer", "tracer 01,7a,55"]
examples = ["trace 01,7a,55", "trace feed,6ddf,feed", "tracer", "tracer 01,7a,55"]
def __init__(self, bot):
super().__init__(bot)
@@ -55,7 +55,8 @@ class TraceCommand(BaseCommand):
def get_help_text(self) -> str:
return (
"trace [path] — run trace along path (return may not be heard). No path = round-trip like tracer. "
"tracer [path] — round-trip so bot hears return. Path: comma 2-char hex (e.g. 01,7a,55). "
"tracer [path] — round-trip so bot hears return. Path: comma-separated hex nodes "
"(2-char 1-byte e.g. 01,7a,55; 4-char 2-byte e.g. feed,6ddf,feed). "
"No path = use your message path (round-trip)."
)
@@ -66,7 +67,7 @@ class TraceCommand(BaseCommand):
return bool(content_lower.startswith("trace ") or content_lower.startswith("tracer "))
def _extract_path_from_message(self, message: MeshMessage) -> list[str]:
"""Extract path node IDs from message.path (supports 1-hop and multi-hop)."""
"""Extract path node IDs from message.path (supports 1-byte, 2-byte, and 3-byte hashes)."""
if not message.path:
return []
if "Direct" in message.path or "0 hops" in message.path:
@@ -79,21 +80,32 @@ class TraceCommand(BaseCommand):
path_string = path_string.strip()
# Single node (e.g. "01 (1 hop)") has no comma
if "," not in path_string:
if len(path_string) == 2 and all(c in "0123456789abcdefABCDEF" for c in path_string):
if len(path_string) in (2, 4, 6) and all(c in "0123456789abcdefABCDEF" for c in path_string):
return [path_string.lower()]
return []
parts = path_string.split(",")
valid = []
expected_len = None
for part in parts:
part = part.strip()
if len(part) == 2 and all(c in "0123456789abcdefABCDEF" for c in part):
valid.append(part.lower())
if not part:
continue
if len(part) not in (2, 4, 6) or not all(c in "0123456789abcdefABCDEF" for c in part):
continue
if expected_len is None:
expected_len = len(part)
if len(part) != expected_len:
continue
valid.append(part.lower())
return valid
def _parse_path_arg(self, content: str) -> Optional[list[str]]:
"""Parse path from command content after 'trace ' or 'tracer '.
Accepts: comma-separated 2-char hex (01,7a,55), contiguous hex (01e07a), or mixed (01,e001).
Returns list of 2-char hex bytes, or None if no path args / invalid.
Accepts comma-separated hex nodes where each segment is the same length:
2-char = 1-byte (e.g. 01,7a,55), 4-char = 2-byte (e.g. feed,6ddf),
6-char = 3-byte (e.g. feedca,6ddf01).
Without commas, treats contiguous hex as 2-char (1-byte) nodes.
Returns list of hex node IDs, or None if no path args / invalid.
"""
content = content.strip()
if content.startswith("!"):
@@ -105,13 +117,24 @@ class TraceCommand(BaseCommand):
break
if not rest:
return None
# Normalize: drop commas and spaces, single string of hex chars
hex_chars = re.sub(r"[\s,]+", "", rest).lower()
# Comma-separated: each segment is one node; preserves multibyte groupings
if "," in rest:
parts = [p.strip().lower() for p in rest.split(",") if p.strip()]
if not parts:
return None
first_len = len(parts[0])
if first_len not in (2, 4, 6) or not all(
len(p) == first_len and all(c in "0123456789abcdef" for c in p)
for p in parts
):
return None
return parts
# No commas: treat as contiguous hex, split into 2-char (1-byte) nodes
hex_chars = re.sub(r"\s+", "", rest).lower()
if not hex_chars or len(hex_chars) % 2 != 0:
return None
if not all(c in "0123456789abcdef" for c in hex_chars):
return None
# Split into 2-char (1-byte) nodes
return [hex_chars[i : i + 2] for i in range(0, len(hex_chars), 2)]
def _build_reciprocal_path(self, nodes: list[str]) -> list[str]:
@@ -203,9 +226,9 @@ class TraceCommand(BaseCommand):
await self.send_response(message, "Trace not available (firmware/connection).")
return True
flags = 0
if self.trace_mode == "two_byte":
flags = 1 # Reserve flag for 2-byte when supported
# Auto-detect flags from path element length: 2-char=1-byte→0, 4-char=2-byte→1
node_len = len(path_nodes[0]) if path_nodes else 2
flags = {2: 0, 4: 1}.get(node_len, 0)
result = await run_trace(
self.bot,
+106 -2
View File
@@ -1,9 +1,12 @@
"""Tests for modules.commands.trace_command — pure logic functions."""
import configparser
from unittest.mock import MagicMock, Mock
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from modules.commands.trace_command import TraceCommand
from modules.trace_runner import RunTraceResult
from tests.conftest import mock_message
@@ -367,7 +370,6 @@ class TestTraceExecute:
def test_execute_no_meshcore_commands(self):
import asyncio
from unittest.mock import AsyncMock
bot = _make_bot()
bot.connected = True
bot.meshcore = MagicMock()
@@ -377,3 +379,105 @@ class TestTraceExecute:
msg = mock_message(content="trace 01,7a")
result = asyncio.run(cmd.execute(msg))
assert result is True
class TestMultibyteParsePathArg:
"""Multibyte path parsing: 2-byte (4-char) and 3-byte (6-char) nodes."""
def setup_method(self):
self.cmd = TraceCommand(_make_bot())
def test_two_byte_comma_separated(self):
result = self.cmd._parse_path_arg("trace feed,6ddf,feed")
assert result == ["feed", "6ddf", "feed"]
def test_two_byte_single_pair(self):
result = self.cmd._parse_path_arg("trace feed,6ddf")
assert result == ["feed", "6ddf"]
def test_two_byte_tracer_prefix(self):
result = self.cmd._parse_path_arg("tracer feed,6ddf")
assert result == ["feed", "6ddf"]
def test_mixed_length_returns_none(self):
result = self.cmd._parse_path_arg("trace feed,01")
assert result is None
def test_three_byte_comma_separated(self):
result = self.cmd._parse_path_arg("trace feedca,6ddf01")
assert result == ["feedca", "6ddf01"]
def test_contiguous_hex_still_splits_by_two(self):
result = self.cmd._parse_path_arg("trace feed6ddf")
assert result == ["fe", "ed", "6d", "df"]
def test_two_byte_bang_prefix(self):
result = self.cmd._parse_path_arg("!trace feed,6ddf")
assert result == ["feed", "6ddf"]
class TestMultibyteExtractPathFromMessage:
"""_extract_path_from_message handles 1-byte, 2-byte, and 3-byte path hashes."""
def setup_method(self):
self.cmd = TraceCommand(_make_bot())
def test_two_byte_multi_hop(self):
msg = mock_message(content="trace", path="feed,6ddf,feed (3 hops via FLOOD)")
assert self.cmd._extract_path_from_message(msg) == ["feed", "6ddf", "feed"]
def test_two_byte_single_node(self):
msg = mock_message(content="trace", path="feed (1 hop)")
assert self.cmd._extract_path_from_message(msg) == ["feed"]
def test_three_byte_multi_hop(self):
msg = mock_message(content="trace", path="feedca,6ddf01 (2 hops via FLOOD)")
assert self.cmd._extract_path_from_message(msg) == ["feedca", "6ddf01"]
def test_three_byte_single_node(self):
msg = mock_message(content="trace", path="feedca (1 hop)")
assert self.cmd._extract_path_from_message(msg) == ["feedca"]
def test_mixed_length_parts_skipped(self):
msg = mock_message(content="trace", path="feed,01,ab")
result = self.cmd._extract_path_from_message(msg)
assert len(set(len(p) for p in result)) <= 1
class TestMultibyteReciprocalPath:
"""_build_reciprocal_path works with multibyte nodes."""
def setup_method(self):
self.cmd = TraceCommand(_make_bot())
def test_two_byte_three_node_reciprocal(self):
result = self.cmd._build_reciprocal_path(["feed", "6ddf", "feed"])
assert result == ["feed", "6ddf", "feed", "6ddf", "feed"]
class TestFlagsAutoDetection:
"""execute() passes flags derived from path element length, not from trace_mode config."""
def setup_method(self):
self.bot = _make_bot()
self.bot.connected = True
self.bot.meshcore = MagicMock()
self.bot.meshcore.commands = MagicMock()
self.cmd = TraceCommand(self.bot)
self.cmd.send_response = AsyncMock(return_value=True)
@pytest.mark.asyncio
async def test_one_byte_path_uses_flags_zero(self):
with patch("modules.commands.trace_command.run_trace") as mock_run:
mock_run.return_value = RunTraceResult(success=False, tag=0, error_message="x")
await self.cmd.execute(mock_message(content="trace 01,7a,55"))
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["flags"] == 0
@pytest.mark.asyncio
async def test_two_byte_path_uses_flags_one(self):
with patch("modules.commands.trace_command.run_trace") as mock_run:
mock_run.return_value = RunTraceResult(success=False, tag=0, error_message="x")
await self.cmd.execute(mock_message(content="trace feed,6ddf,feed"))
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["flags"] == 1