Bridge: push TRACE_DATA for over-the-air trace replies

pymc_core never forwards a received trace to the companion client: its
frame server registers every push callback except on_trace_received, and
nothing in the library ever fires the trace_received event, so the whole
path is dead code — the only push_trace_data call is the self-hop
shortcut in _cmd_send_trace_path. A trace sent from MeshTerm therefore
returns RESP_CODE_SENT and then times out, because the node parses the
reply and drops it.

Shim it at the dispatcher's packet-received callback (where CompanionRadio
already watches every RX packet with its measured SNR): wrap it, and for a
TRACE payload re-slice the on-air bytes into the hash/SNR blocks
push_trace_data wants, emitting the standard 0x89 frame the meshcore client
decodes. Verified byte-exact end to end. Handles both the SNR-per-hop
layout (final SNR from our reception) and firmware that trails the final
SNR in the payload.

Upstream fix (no local pymc_core checkout to carry it): mirror every other
push — fire trace_received from _on_packet_received and register
on_trace_received in frame_server._setup_push_callbacks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-08-12 23:18:14 -04:00
1 parent 366719771f
commit 744e021fb0
1 file changed
+91
+91
View File
@@ -661,6 +661,96 @@ def _wire_contact_persistence(companion, log) -> None:
log.info("contact persistence at %s", path)
def _wire_trace_push(companion, server, log) -> None:
"""Forward over-the-air trace replies to the client as PUSH_CODE_TRACE_DATA.
pymc_core parses received TRACE packets but never hands them to the
companion client: its frame server registers every push callback *except*
``on_trace_received``, and nothing in the library ever fires the
``trace_received`` event — so the whole path is dead code. The only
``push_trace_data`` call is the self-hop shortcut in ``_cmd_send_trace_path``.
The upshot is that MeshTerm's ``run_trace`` sends the trace, gets
``RESP_CODE_SENT``, then waits on ``TRACE_DATA`` and times out while the node
quietly drops the reply.
Bridge it at the one seam that sees every received packet with its measured
SNR — the dispatcher's packet-received callback (``CompanionRadio`` already
installs ``_on_packet_received`` there and only acts on group data). Wrap it:
run the original, then, for a TRACE payload, re-slice the on-air bytes into
the hash/SNR blocks ``push_trace_data`` wants and push the standard 0x89
frame — exactly what the ``meshcore`` client on MeshTerm's side decodes.
Upstream, the clean fix is the mirror of every other push: have
``_on_packet_received`` fire ``trace_received`` for a TRACE packet and have
``frame_server._setup_push_callbacks`` register ``on_trace_received`` →
``push_trace_data``. Until a pymc_core release carries that, this shim stands
(same spirit as :func:`_patch_pymc_ack_compat`).
"""
import struct
try:
from pymc_core.protocol.constants import PAYLOAD_TYPE_TRACE
except Exception as exc: # package layout changed — leave it alone
log.warning("trace push not wired (%s)", exc)
return
dispatcher = getattr(getattr(companion, "node", None), "dispatcher", None)
if dispatcher is None or not hasattr(dispatcher, "set_packet_received_callback"):
log.warning("trace push not wired (no dispatcher)")
return
prev = getattr(companion, "_on_packet_received", None)
def push_reply(pkt) -> None:
payload = bytes(pkt.payload or b"")
if len(payload) < 9:
log.debug("trace reply too short (%d bytes)", len(payload))
return
tag, auth_code, flags = struct.unpack_from("<IIB", payload, 0)
hash_width = 1 << (flags & 0x03)
tail = payload[9:]
stride = hash_width + 1 # each hop: its hash bytes + one appended SNR byte
remainder = len(tail) % stride
if remainder == 0:
# Per-hop SNRs are in the payload; our own reception SNR is the final byte.
hops = len(tail) // stride
snr = pkt.get_snr() if hasattr(pkt, "get_snr") else getattr(pkt, "_snr", 0.0)
final_snr_byte = max(-128, min(127, int(round((snr or 0.0) * 4)))) & 0xFF
elif remainder == 1:
# Firmware that also carries the final device SNR as a trailing byte.
hops = (len(tail) - 1) // stride
final_snr_byte = tail[-1]
else:
log.info(
"trace tail %d not %d*(width %d + 1)[+1] (flags=0x%02x); raw=%s",
len(tail), len(tail) // stride, hash_width, flags, payload.hex().upper(),
)
return
path_bytes = hops * hash_width
path_hashes = tail[:path_bytes]
path_snrs = tail[path_bytes : path_bytes + hops]
server.push_trace_data(
path_bytes, flags, tag, auth_code, path_hashes, path_snrs, final_snr_byte
)
log.info("pushed TRACE_DATA tag=0x%08X hops=%d width=%d", tag, hops, hash_width)
async def on_packet(pkt) -> None:
if prev is not None:
await prev(pkt)
try:
if pkt.get_payload_type() != PAYLOAD_TYPE_TRACE:
return
except Exception:
return
try:
push_reply(pkt)
except Exception as exc:
log.warning("trace push failed: %s", exc)
dispatcher.set_packet_received_callback(on_packet)
log.info("compat: TRACE replies now push PUSH_CODE_TRACE_DATA to the client")
def run_bridge(host: str, port: int, node_name: str) -> int:
import asyncio
import logging
@@ -781,6 +871,7 @@ def run_bridge(host: str, port: int, node_name: str) -> int:
device_model="pyMC-spi-bridge",
)
await server.start()
_wire_trace_push(companion, server, log)
log.info("READY — connect with: meshterm --tcp %s:%d", host, port)
import signal