From f9f65037a893d04d4974b788649f4f0c4d79d033 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 25 Aug 2026 09:39:29 -0700 Subject: [PATCH] fix(template): stop pathbytes_min reading a hopless packet as multibyte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {path_distance|pathbytes_min:2|prefix_if_nonempty: | Path Dist: } printed "| Path Dist: N/A" on a direct message. bytes_per_hop_from_routing_and_nodes returned routing_info's bytes_per_hop before it ever looked at whether there were any hops, so a 0-hop packet whose format happens to use 2-byte hops reported 2, the gate passed, and prefix_if_nonempty found "N/A" waiting for it and dutifully attached the label. bytes_per_hop describes how a path is encoded. A direct packet has no path for it to describe, so the format field is not evidence of a wide path and must not stand in for one. Both docstrings already promised 1 for the direct case—"Returns 1 when no nodes (direct / unknown)"—so this is the code catching up to its stated contract rather than a change of policy. Latent until now: channel messages never had routing_info attached, so the field was absent and the fallback returned 1 by accident. Direct DMs have been hitting it all along. --- CHANGELOG.md | 8 +++++ modules/utils.py | 20 +++++++++--- tests/unit/test_response_template.py | 47 ++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca4f0d..cf2e016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ semantic versioning. ### Fixed +- `pathbytes_min` no longer treats a direct message as a multi-byte path, so + `{path_distance|pathbytes_min:2|prefix_if_nonempty: | Path Dist: }` stops printing + `| Path Dist: N/A` on a hopless packet. `bytes_per_hop` describes how a path is + encoded, and a direct packet has no path for it to describe, so + `bytes_per_hop_from_routing_and_nodes` now returns 1 whenever the packet reports no + hops—which is what its docstring already claimed. The bug was latent until channel + messages started carrying routing info. + - Channel messages are now tied to their own RF packet, so `{packet_hash}`, `{path}` and `{connection_info}` resolve on a channel instead of coming back empty or `Unknown`. MeshCore's CHAN event carries neither `raw_hex` nor a pubkey prefix, so diff --git a/modules/utils.py b/modules/utils.py index 524dafc..ffd3264 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1892,10 +1892,20 @@ def bytes_per_hop_from_routing_and_nodes( ) -> int: """Bytes per hop from packet routing metadata, else inferred from hex node width. - When ``routing_info`` includes ``bytes_per_hop`` in 1..3, that value wins. - Otherwise uses minimum half-byte width among ``node_ids`` (comma or path_nodes). - Returns ``1`` when no nodes (direct / unknown). + When the packet carries a path and ``routing_info`` includes ``bytes_per_hop`` + in 1..3, that value wins. Otherwise uses minimum half-byte width among + ``node_ids`` (comma or path_nodes). Returns ``1`` when no nodes (direct / + unknown). + + A hopless packet always reports ``1``, whatever ``bytes_per_hop`` says. + ``bytes_per_hop`` describes how a path is *encoded*, and a direct packet has no + path for it to describe, so letting the format field through would make + ``pathbytes_min:2`` treat "Direct" as a multi-byte path and print a label for a + distance that does not exist. """ + path_length = (routing_info or {}).get('path_length') + if not node_ids and not path_length: + return 1 if routing_info: bph = routing_info.get('bytes_per_hop') if isinstance(bph, int) and 1 <= bph <= 3: @@ -1912,8 +1922,8 @@ def message_path_bytes_per_hop(message: Any, *, prefix_hex_chars: int = 2) -> in :func:`extract_path_node_ids_from_message`, then comma/continuous hex via :func:`node_ids_from_path_string` using ``prefix_hex_chars`` for legacy paths. - Returns ``1`` when no usable path (direct / unparseable) so conservative gates - (e.g. ``pathbytes_min:2``) do not treat unknown as multibyte. + Returns ``1`` when there is no usable path (direct / unparseable) so conservative + gates (e.g. ``pathbytes_min:2``) treat neither unknown nor hopless as multibyte. """ routing_info = getattr(message, 'routing_info', None) node_ids = extract_path_node_ids_from_message(message) diff --git a/tests/unit/test_response_template.py b/tests/unit/test_response_template.py index 61b705d..c362b84 100644 --- a/tests/unit/test_response_template.py +++ b/tests/unit/test_response_template.py @@ -22,6 +22,53 @@ def test_message_path_bytes_per_hop_from_routing(): assert message_path_bytes_per_hop(msg) == 2 +@pytest.mark.unit +def test_hopless_packet_is_never_multibyte(): + """bytes_per_hop describes how a path is encoded; a direct packet has no path for + it to describe, so pathbytes_min must not read the format field as a wide path.""" + msg = MeshMessage( + content="test", + channel="c", + path="Direct", + routing_info={"bytes_per_hop": 2, "path_length": 0, "path_nodes": []}, + ) + assert message_path_bytes_per_hop(msg) == 1 + + +@pytest.mark.unit +def test_pathbytes_min_hides_label_on_a_direct_message(): + """A direct message has no path distance, so the whole clause must disappear + rather than render "Path Dist: N/A".""" + msg = MeshMessage( + content="test", + channel="c", + path="Direct", + routing_info={"bytes_per_hop": 2, "path_length": 0, "path_nodes": []}, + ) + out = format_piped_template( + "ack{path_distance|pathbytes_min:2|prefix_if_nonempty: | Path Dist: }", + {"path_distance": "N/A"}, + message=msg, + ) + assert out == "ack" + + +@pytest.mark.unit +def test_pathbytes_min_still_passes_a_real_multibyte_path(): + msg = MeshMessage( + content="test", + channel="c", + path="7a2a,0102 (2 hops)", + routing_info={"bytes_per_hop": 2, "path_length": 2, "path_nodes": ["7A2A", "0102"]}, + ) + out = format_piped_template( + "ack{path_distance|pathbytes_min:2|prefix_if_nonempty: | Path Dist: }", + {"path_distance": "12.4km"}, + message=msg, + ) + assert out == "ack | Path Dist: 12.4km" + + @pytest.mark.unit def test_message_path_bytes_per_hop_infers_from_nodes(): msg = MeshMessage(