diff --git a/modules/commands/trace_command.py b/modules/commands/trace_command.py index bc803cf..3d016b3 100644 --- a/modules/commands/trace_command.py +++ b/modules/commands/trace_command.py @@ -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, diff --git a/tests/test_trace_command.py b/tests/test_trace_command.py index af98ce2..b6997b8 100644 --- a/tests/test_trace_command.py +++ b/tests/test_trace_command.py @@ -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