Files
MeshTerm/scripts/meshterm-spi-bridge
T
Jean-Pierre MartineauandClaude Opus 4.8 16532ea14b Bridge: forward the RX packet log so the live feed isn't advert-only
MeshTerm's live feed is built from RX_LOG_DATA events — every overheard
frame with its SNR/RSSI. pymc_core's frame server has push_rx_raw (the
0x88 push) but its _setup_push_callbacks never registers on_rx_log_data,
so it is never called: the promiscuous log is dropped and only adverts —
which carry their own wired advert_received push — reach the feed. The
radio was plainly hearing requests and channel text too; none of it showed.

Subscribe to the dispatcher's raw-packet feed directly and push each frame.
Routing through the dispatcher rather than on_rx_log_data keeps it clear of
the frame server's per-connection clear_push_callbacks(), and that feed
fires before dedup, so the client sees every repeat — what a live feed wants.

Same missing-wiring shape as the trace push; upstream fix is likewise to
register on_rx_log_data -> push_rx_raw in _setup_push_callbacks. Confirmed
against a live mesh: the feed now shows the full packet mix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 15:22:27 -04:00

1028 lines
39 KiB
Python
Executable File
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.
#!/usr/bin/env python3
"""
meshterm-spi-bridge — expose an SPI-attached MeshCore radio to MeshTerm.
Some boards (the ClockworkPi uConsole hackergadgets AIO, Waveshare LoRa HATs)
wire the LoRa chip straight to the host's SPI bus — there is no companion MCU
and no firmware to flash. The node runs *in software* on the host via the
`pymc_core` library. MeshTerm, though, speaks the MeshCore companion protocol
over serial/BLE/TCP and expects to talk to a node, not to drive a bare radio.
This tool bridges the two: it runs the pymc_core node itself and puts the
standard companion protocol in front of it on a local TCP port, so that
meshterm --tcp 127.0.0.1:5000
connects exactly as if a real companion device were plugged in.
It is BOTH a friendly setup menu and the bridge process. Run it with any
Python 3 — it finds the pymc_core runtime for you and re-execs the bridge half
under that interpreter automatically. You never need to know about venvs.
./meshterm-spi-bridge # interactive menu
./meshterm-spi-bridge --check # run prerequisite checks and exit
./meshterm-spi-bridge --run # run the bridge now in this terminal
"""
from __future__ import annotations
import argparse
import os
import shutil
import socket
import subprocess
import sys
from pathlib import Path
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
APP_NAME = "meshterm-spi-bridge"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 5000
DEFAULT_NODE_NAME = "uConsole"
# Interpreters likely to have pymc_core, in preference order. The hackergadgets
# uConsole .deb installs everything into this private virtualenv.
VENV_CANDIDATES = ["/opt/venvs/meshcore-uconsole/bin/python"]
HOME = Path.home()
INSTALL_PATH = HOME / ".local" / "bin" / APP_NAME
UNIT_NAME = f"{APP_NAME}.service"
UNIT_PATH = HOME / ".config" / "systemd" / "user" / UNIT_NAME
MESHTERM_CONFIG = HOME / ".meshterm" / "config.toml"
RULE = "=" * 62
THIN = "-" * 62
# --------------------------------------------------------------------------- #
# Colour (restrained — off when piped, dumb terminal, or NO_COLOR is set)
# --------------------------------------------------------------------------- #
_USE_COLOR = (
sys.stdout.isatty()
and not os.environ.get("NO_COLOR")
and not os.environ.get("MESHTERM_NO_COLOR")
and os.environ.get("TERM", "") != "dumb"
)
def _c(text: str, code: str) -> str:
return f"\033[{code}m{text}\033[0m" if _USE_COLOR else text
def bold(t: str) -> str:
return _c(t, "1")
def dim(t: str) -> str:
return _c(t, "2")
def green(t: str) -> str:
return _c(t, "32")
def red(t: str) -> str:
return _c(t, "1;31")
def yellow(t: str) -> str:
return _c(t, "33")
def cyan(t: str) -> str:
return _c(t, "36")
def cmd(t: str) -> str:
"""Highlight a command the user should type/copy."""
return bold(cyan(t))
# --------------------------------------------------------------------------- #
# Small text helpers (deliberately plain — a little colour, no flourish)
# --------------------------------------------------------------------------- #
def tag(ok: bool, warn: bool = False) -> str:
if warn:
return yellow("[warn]")
return green("[ ok ]") if ok else red("[FAIL]")
def ask(prompt: str, default: str = "") -> str:
try:
return input(prompt).strip()
except (EOFError, KeyboardInterrupt):
print()
return default
def confirm(prompt: str, default_yes: bool = False) -> bool:
suffix = " [Y/n] " if default_yes else " [y/N] "
ans = ask(prompt + suffix).lower()
if not ans:
return default_yes
return ans in ("y", "yes")
# --------------------------------------------------------------------------- #
# Runtime discovery
# --------------------------------------------------------------------------- #
def python_has_pymc(python: str) -> bool:
"""Return whether `python` can import pymc_core."""
if not python or not os.path.exists(python):
return False
try:
r = subprocess.run(
[python, "-c", "import pymc_core"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=20,
)
return r.returncode == 0
except Exception:
return False
def _meshcore_console_python() -> str | None:
"""Resolve the interpreter behind the `meshcore-console` command, if present."""
exe = shutil.which("meshcore-console")
if not exe:
return None
try:
first = Path(exe).resolve().read_text(errors="replace").splitlines()[0]
except Exception:
return None
if first.startswith("#!"):
cand = first[2:].strip().split()[0]
if cand.endswith("python") or "python" in os.path.basename(cand):
return cand
return None
def find_pymc_python() -> str | None:
"""Find an interpreter that has pymc_core, or None.
Order: explicit override, this interpreter, known venv paths, then the
interpreter behind an installed `meshcore-console`.
"""
seen: set[str] = set()
candidates = [os.environ.get("MESHTERM_PYMC_PYTHON", ""), sys.executable]
candidates += VENV_CANDIDATES
mcc = _meshcore_console_python()
if mcc:
candidates.append(mcc)
for c in candidates:
if c and c not in seen:
seen.add(c)
if python_has_pymc(c):
return c
return None
# --------------------------------------------------------------------------- #
# Preflight checks
# --------------------------------------------------------------------------- #
def _dev_ok(path: str) -> tuple[bool, str]:
if not os.path.exists(path):
return False, "missing"
if not os.access(path, os.R_OK | os.W_OK):
return False, "present but no read/write permission"
return True, "present, read/write ok"
def _first_spidev() -> str | None:
import glob
devs = sorted(glob.glob("/dev/spidev*"))
if "/dev/spidev1.0" in devs:
return "/dev/spidev1.0"
return devs[0] if devs else None
def port_listening(host: str, port: int) -> bool:
"""Return whether something is already accepting connections on host:port."""
try:
with socket.create_connection((host, port), timeout=1.5):
return True
except OSError:
return False
def meshcore_console_running() -> list[str]:
"""Best-effort list of running meshcore-console (GUI/CLI) command lines."""
try:
out = subprocess.check_output(
["pgrep", "-af", "meshcore-console"], text=True, stderr=subprocess.DEVNULL
)
except Exception:
return []
lines = []
for line in out.splitlines():
# our own bridge invokes the meshcore-uconsole venv python, not the
# meshcore-console binary, so it never matches — but guard anyway.
if "--run-bridge" in line or APP_NAME in line:
continue
lines.append(line.strip())
return lines
def systemctl_user(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["systemctl", "--user", *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def service_installed() -> bool:
return UNIT_PATH.exists()
def service_active() -> bool:
return systemctl_user("is-active", UNIT_NAME).stdout.strip() == "active"
class Preflight:
def __init__(self) -> None:
self.python = find_pymc_python()
self.spidev = _first_spidev()
self.spidev_ok, self.spidev_detail = (
_dev_ok(self.spidev) if self.spidev else (False, "no /dev/spidev* found")
)
self.gpio_ok, self.gpio_detail = _dev_ok("/dev/gpiochip0")
self.svc_active = service_active()
self.port_busy = port_listening(DEFAULT_HOST, DEFAULT_PORT)
self.gui = meshcore_console_running()
@property
def can_run(self) -> bool:
return bool(self.python) and self.spidev_ok and self.gpio_ok
def print(self) -> None:
print(bold("Prerequisite check:"))
# 1. runtime
if self.python:
print(f" {tag(True)} pymc_core runtime {self.python}")
else:
print(f" {tag(False)} pymc_core runtime not found")
print(" fix: install the radio stack, e.g. on the uConsole:")
print(" sudo apt install meshcore-uconsole")
print(" (or set MESHTERM_PYMC_PYTHON to a python that has it)")
# 2. spi
print(f" {tag(self.spidev_ok)} SPI device {self.spidev or '-'} — {self.spidev_detail}")
if not self.spidev_ok and self.spidev is None:
print(" fix: enable SPI in /boot/firmware/config.txt")
print(" (dtoverlay=spi1-1cs on the uConsole) and reboot")
elif not self.spidev_ok:
print(" fix: add your user to the 'spi' group, then log out/in:")
print(" sudo usermod -aG spi $USER")
# 3. gpio
print(f" {tag(self.gpio_ok)} GPIO chip /dev/gpiochip0 — {self.gpio_detail}")
if not self.gpio_ok and "permission" in self.gpio_detail:
print(" fix: sudo usermod -aG gpio $USER (then log out/in)")
# 4. radio availability (informational)
if self.svc_active:
print(f" {tag(True)} bridge service running (radio in use by the boot service)")
elif self.gui:
print(f" {tag(False, warn=True)} radio in use meshcore-console is running:")
for line in self.gui[:2]:
print(f" {line[:70]}")
print(" note: only one program can use the radio at a time —")
print(" close meshcore-console before running the bridge")
elif self.port_busy:
print(f" {tag(False, warn=True)} port {DEFAULT_PORT} busy something is already on {DEFAULT_HOST}:{DEFAULT_PORT}")
else:
print(f" {tag(True)} radio is free nothing else is using it")
# --------------------------------------------------------------------------- #
# Caveats banner
# --------------------------------------------------------------------------- #
CAVEATS = """\
How this works, briefly:
* Your LoRa radio is wired to the host's SPI bus — there is no separate
MCU and no firmware to flash. The mesh node runs as software here.
* This bridge runs that node and exposes it on a local network port so
MeshTerm can connect with: meshterm --tcp {host}:{port}
* Two processes cooperate: this bridge (owns the radio) and MeshTerm
(the terminal you use). Keep the bridge running while you use MeshTerm.
Caveat — only ONE program may use the radio at a time:
* Run EITHER the meshcore-console GUI, OR this bridge — never both.
* If the bridge won't start, close meshcore-console first."""
def print_caveats() -> None:
text = CAVEATS.format(host=DEFAULT_HOST, port=DEFAULT_PORT)
for line in text.splitlines():
if line.endswith(":") and not line.startswith(" "):
print(bold(line))
elif "meshterm --tcp" in line:
print(line.replace(
f"meshterm --tcp {DEFAULT_HOST}:{DEFAULT_PORT}",
cmd(f"meshterm --tcp {DEFAULT_HOST}:{DEFAULT_PORT}"),
))
else:
print(line)
# --------------------------------------------------------------------------- #
# Actions
# --------------------------------------------------------------------------- #
def action_run_now(pre: Preflight) -> None:
if pre.svc_active:
print()
print("The bridge is already running as a boot service, so the radio is")
print("in use and MeshTerm can connect right now:")
print(" " + cmd(f"meshterm --tcp {DEFAULT_HOST}:{DEFAULT_PORT}"))
print("To run it in this terminal instead, stop the service first (menu")
print("option 3, or: systemctl --user stop " + UNIT_NAME + ").")
return
if pre.gui:
print()
print("meshcore-console is running and holding the radio. Close it first,")
print("then try again.")
return
if not pre.can_run:
print("\nCan't run yet — resolve the [FAIL] items above first.")
return
print()
print(dim(THIN))
print("Starting the bridge. Leave this running, open another terminal, and:")
print(" " + cmd(f"meshterm --tcp {DEFAULT_HOST}:{DEFAULT_PORT}"))
print("Press Ctrl+C here to stop the bridge.")
print(dim(THIN))
offer_meshterm_profile()
print()
run_cmd = [pre.python, os.path.abspath(__file__), "--run-bridge",
"--host", DEFAULT_HOST, "--port", str(DEFAULT_PORT), "--name", DEFAULT_NODE_NAME]
try:
subprocess.run(run_cmd)
except KeyboardInterrupt:
pass
print("\nBridge stopped. Back to the menu.")
def _write_unit(python: str) -> None:
UNIT_PATH.parent.mkdir(parents=True, exist_ok=True)
exec_start = (
f"{python} {INSTALL_PATH} --run-bridge "
f"--host {DEFAULT_HOST} --port {DEFAULT_PORT} --name {DEFAULT_NODE_NAME}"
)
UNIT_PATH.write_text(
"[Unit]\n"
"Description=MeshTerm SPI-radio companion bridge (pymc_core -> TCP)\n"
"After=network.target\n\n"
"[Service]\n"
"Type=simple\n"
f"ExecStart={exec_start}\n"
"Restart=on-failure\n"
"RestartSec=3\n\n"
"[Install]\n"
"WantedBy=default.target\n"
)
def action_install_service(pre: Preflight) -> None:
if not pre.python:
print("\nCan't install — the pymc_core runtime wasn't found (see above).")
return
if pre.gui:
print("\nmeshcore-console is running. The service would fight it for the")
print("radio. Close meshcore-console first, then install the service.")
if not confirm("Install anyway?"):
return
print()
print("This will install a background service that:")
print(f" * runs the bridge on {DEFAULT_HOST}:{DEFAULT_PORT}")
print(" * starts automatically on boot and restarts if it crashes")
print(" * runs as YOU, using your node identity (same as the GUI)")
print("Reminder: while it's running, don't also open the meshcore-console GUI.")
if not confirm("\nInstall and start it now?", default_yes=True):
print("Cancelled.")
return
# Install a stable copy of this script for the unit to point at.
INSTALL_PATH.parent.mkdir(parents=True, exist_ok=True)
if os.path.abspath(__file__) != str(INSTALL_PATH):
shutil.copyfile(os.path.abspath(__file__), INSTALL_PATH)
INSTALL_PATH.chmod(0o755)
_write_unit(pre.python)
systemctl_user("daemon-reload")
r = systemctl_user("enable", "--now", UNIT_NAME)
if r.returncode != 0:
print("\nFailed to start the service:")
print(" " + (r.stderr.strip() or "unknown error"))
return
# Try to make it start at boot even before you log in.
linger = subprocess.run(
["loginctl", "enable-linger", os.environ.get("USER", "")],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
)
print(green("\nInstalled and started."))
if service_active():
print(f" {tag(True)} service is running")
print(f" unit: {UNIT_PATH}")
print(f" logs: journalctl --user -u {UNIT_NAME} -f")
if linger.returncode != 0:
print("\nOne extra step for start-on-boot without logging in first:")
print(f" sudo loginctl enable-linger {os.environ.get('USER','')}")
offer_meshterm_profile()
print("\nConnect from MeshTerm with: " + cmd(f"meshterm --tcp {DEFAULT_HOST}:{DEFAULT_PORT}"))
def action_uninstall_service() -> None:
if not service_installed():
print("\nNo bridge service is installed. Nothing to remove.")
print("(This never touches the hackergadgets meshcore-console package.)")
return
print("\nThis removes ONLY the bridge service and its helper script.")
print("It does NOT remove meshcore-console or the radio stack.")
if not confirm("Remove the bridge service now?", default_yes=True):
print("Cancelled.")
return
systemctl_user("disable", "--now", UNIT_NAME)
try:
UNIT_PATH.unlink()
except FileNotFoundError:
pass
systemctl_user("daemon-reload")
# Remove the installed helper copy (but not a repo checkout you ran from).
if INSTALL_PATH.exists() and os.path.abspath(__file__) != str(INSTALL_PATH):
try:
INSTALL_PATH.unlink()
except OSError:
pass
print(green("\nRemoved.") + " The radio is free again for the meshcore-console GUI.")
if MESHTERM_CONFIG.exists() and "[profiles.bridge]" in MESHTERM_CONFIG.read_text(errors="replace"):
print("Note: a [profiles.bridge] block remains in your MeshTerm config;")
print(f" edit {MESHTERM_CONFIG} to remove it if you like.")
def action_status(pre: Preflight) -> None:
print()
print(bold("Bridge service:"))
if not service_installed():
print(" not installed")
else:
print(f" installed {UNIT_PATH}")
print(f" active: {'yes' if service_active() else 'no'}")
print(f" logs: journalctl --user -u {UNIT_NAME} -f")
listening = port_listening(DEFAULT_HOST, DEFAULT_PORT)
print(f"\nEndpoint {DEFAULT_HOST}:{DEFAULT_PORT}: {'listening' if listening else 'not listening'}")
if listening:
print(" MeshTerm can connect now: " + cmd(f"meshterm --tcp {DEFAULT_HOST}:{DEFAULT_PORT}"))
def offer_meshterm_profile() -> None:
"""Optionally add a MeshTerm TCP profile so bare `meshterm` connects here."""
if MESHTERM_CONFIG.exists() and "[profiles.bridge]" in MESHTERM_CONFIG.read_text(errors="replace"):
return # already there
if not confirm(
f"\nAdd a 'bridge' profile to {MESHTERM_CONFIG} so you can just run "
"`meshterm`?",
default_yes=False,
):
return
block = (
"\n# Added by meshterm-spi-bridge — the local SPI-radio bridge.\n"
"[profiles.bridge]\n"
f'host = "{DEFAULT_HOST}"\n'
f"tcp_port = {DEFAULT_PORT}\n"
'description = "Local SPI radio via meshterm-spi-bridge"\n'
)
try:
MESHTERM_CONFIG.parent.mkdir(parents=True, exist_ok=True)
with MESHTERM_CONFIG.open("a", encoding="utf-8") as fh:
fh.write(block)
print("Added [profiles.bridge]. It'll show up in MeshTerm's device")
print("picker as 'bridge' — just select it there, or run: meshterm -p bridge")
except OSError as exc:
print(f"Couldn't write the profile ({exc}); add it by hand if you want it.")
# --------------------------------------------------------------------------- #
# Interactive menu
# --------------------------------------------------------------------------- #
def menu() -> int:
while True:
pre = Preflight()
print()
print(cyan(RULE))
print(bold(cyan(" MeshTerm SPI-radio bridge — setup")))
print(cyan(RULE))
print_caveats()
print()
pre.print()
print()
print(bold("What would you like to do?"))
print(f" {bold('1')}) Run the bridge now (stays in this terminal; Ctrl+C to stop)")
print(f" {bold('2')}) Install as a service (starts on boot, restarts on failure)")
print(f" {bold('3')}) Uninstall the service")
print(f" {bold('4')}) Show status")
print(f" {bold('5')}) Quit")
choice = ask("\n> ")
if choice == "1":
action_run_now(pre)
elif choice == "2":
action_install_service(pre)
elif choice == "3":
action_uninstall_service()
elif choice == "4":
action_status(pre)
elif choice in ("5", "q", "quit", "exit", ""):
print("Bye.")
return 0
else:
print("Please choose 1-5.")
ask("\nPress Enter to continue…")
# --------------------------------------------------------------------------- #
# The bridge itself (runs under the pymc_core interpreter via --run-bridge)
# --------------------------------------------------------------------------- #
def _patch_pymc_ack_compat(log) -> None:
"""Accept modern MeshCore ACKs that carry extra bytes after the CRC.
Newer firmware sends discrete ACK payloads longer than 4 bytes (the 4-byte
CRC first, then extras). pymc_core's AckHandler requires exactly 4 bytes
and drops the rest, so delivery confirmations never reach MeshTerm.
Override it to take the leading 4 bytes as the CRC — the same tolerant
parse pymc_core already uses for path-bundled ACKs.
"""
try:
from pymc_core.node.handlers.ack import AckHandler
except Exception as exc: # package layout changed — leave it alone
log.warning("ack compat shim not applied (%s)", exc)
return
async def process_discrete_ack(self, packet):
payload = packet.payload
self.log(f"Processing discrete ACK: payload_len={len(payload)}")
self.log(f"ACK payload (hex): {payload.hex().upper()}")
if len(payload) < 4:
self.log(f"Invalid ACK length: {len(payload)} bytes (expected >= 4)")
return None
crc = int.from_bytes(payload[:4], "little")
if len(payload) > 4:
self.log(f"ACK carries {len(payload) - 4} extra byte(s): {payload[4:].hex().upper()}")
self.log(f"Discrete ACK received: CRC={crc:08X}")
return crc
AckHandler.process_discrete_ack = process_discrete_ack
log.info("compat: AckHandler patched to accept >4-byte ACK payloads")
def _wire_contact_persistence(companion, log) -> None:
"""Keep the node's contact list across bridge restarts.
pymc_core's ContactStore is memory-only, so a restart forgot every contact
and DMs failed with ERR_CODE_NOT_FOUND until the peer was heard again.
Reload the last snapshot at startup and re-snapshot after every mutation.
"""
import json
store = getattr(companion, "contacts", None)
if store is None or not hasattr(store, "load_from_dicts"):
log.warning("contact persistence not wired (no contact store)")
return
path = HOME / ".local" / "share" / APP_NAME / "contacts.json"
def to_rec(c) -> dict:
rec = {
"public_key": c.public_key.hex(),
"name": c.name,
"adv_type": c.adv_type,
"flags": c.flags,
"out_path_len": c.out_path_len,
"out_path": c.out_path.hex() if c.out_path else "",
"last_advert_timestamp": c.last_advert_timestamp,
"lastmod": c.lastmod,
"gps_lat": c.gps_lat,
"gps_lon": c.gps_lon,
"sync_since": c.sync_since,
}
if c.last_advert_packet:
rec["last_advert_packet"] = c.last_advert_packet.hex()
return rec
if path.exists():
try:
store.load_from_dicts(json.loads(path.read_text()))
log.info("restored %d contact(s) from %s", store.get_count(), path)
except Exception as exc:
log.warning("could not restore contacts (%s)", exc)
def save() -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps([to_rec(c) for c in store.get_all()]))
tmp.replace(path)
except Exception as exc:
log.warning("could not save contacts (%s)", exc)
def wrap(name: str) -> None:
orig = getattr(store, name, None)
if orig is None:
return
def wrapped(*args, _orig=orig, **kwargs):
result = _orig(*args, **kwargs)
save()
return result
setattr(store, name, wrapped)
for name in ("add", "add_or_overwrite", "update", "remove", "clear"):
wrap(name)
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.
Reading the SNRs is the subtle part. A TRACE packet keeps the *route* (the
hop hashes) in its payload, unchanged as it travels; the per-hop SNRs ride in
the packet **header's path field** instead — one signed byte (SNR × 4) appended
by each hop, which is where ``push_trace_data``'s SNR block and MeshTerm's
decoder both source them. So the payload never grows, and an earlier attempt
that waited for SNRs to appear *in the payload* never fired.
A received TRACE packet is also not a reply just because it exists. The
dispatcher is promiscuous — it runs the trace handler and this callback for
every non-duplicate TRACE frame it overhears, including third parties' traces
and our own outbound echoed back by the first repeater (same tag, header path
still filling). Pushing on those makes MeshTerm's tag-filtered wait resolve
instantly against a half-walked trace, so *every* trace reads as success. So
we gate hard: remember the tag and route of every trace we originate
(``send_trace_path_raw`` is the one send seam the frame server calls), and push
only a received TRACE whose tag we sent *and* whose header has accumulated an
SNR for every hop of that route — the completed reply.
Upstream, the clean fix is the mirror of every other push: have
``_on_packet_received`` fire ``trace_received`` for a completed trace 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
import time
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
orig_send = getattr(companion, "send_trace_path_raw", None)
if orig_send is None:
log.warning("trace push not wired (no send_trace_path_raw)")
return
# tag -> (flags, path_bytes, deadline). Only a trace WE originated, walked in
# full, becomes a push. Entries expire so a never-answered trace can't later
# match a recycled tag.
SENT_TTL_S = 90.0
sent: dict = {}
def _prune(now: float) -> None:
for tag in [t for t, (_f, _p, dl) in sent.items() if dl < now]:
sent.pop(tag, None)
async def send_trace_path_raw(tag, auth_code, flags, path_bytes, _orig=orig_send):
now = time.monotonic()
_prune(now)
key = tag & 0xFFFFFFFF
sent[key] = (flags, bytes(path_bytes), now + SENT_TTL_S)
log.info(
"trace sent tag=0x%08X flags=0x%02x path=%s",
key, flags, bytes(path_bytes).hex().upper(),
)
return await _orig(tag, auth_code, flags, path_bytes)
companion.send_trace_path_raw = send_trace_path_raw
prev = getattr(companion, "_on_packet_received", None)
def push_reply(pkt) -> None:
payload = bytes(pkt.payload or b"")
if len(payload) < 9:
return
tag, auth_code, flags = struct.unpack_from("<IIB", payload, 0)
rec = sent.get(tag & 0xFFFFFFFF)
if rec is None:
return # a trace we didn't send — overheard third party, ignore
_sent_flags, _sent_path, _dl = rec
hw = 1 << (flags & 0x03)
route = payload[9:] # the hop hashes, unchanged in flight
hops = len(route) // hw if hw else 0
# Per-hop SNRs live in the header path: one signed byte (SNR*4) per hop.
header_path = bytes(getattr(pkt, "path", b"") or b"")
snr_count = getattr(pkt, "path_len", 0) & 0x3F
header_snrs = header_path[:snr_count]
log.info(
"trace rx tag=0x%08X hops=%d hdr_snrs=%d payload=%s hdrpath=%s",
tag, hops, len(header_snrs), payload.hex().upper(), header_path.hex().upper(),
)
if hops == 0 or len(header_snrs) < hops:
return # still in flight: not every hop has reported an SNR yet
path_snrs = header_snrs[:hops]
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
sent.pop(tag & 0xFFFFFFFF, None) # one reply per trace
server.push_trace_data(
hops * hw, flags, tag, auth_code, route, path_snrs, final_snr_byte
)
log.info("pushed TRACE_DATA tag=0x%08X hops=%d width=%d", tag, hops, hw)
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: completed TRACE replies push PUSH_CODE_TRACE_DATA to the client")
def _wire_rx_log_push(companion, server, log) -> None:
"""Forward the promiscuous RX packet log to the client (PUSH_CODE_LOG_RX_DATA).
The same missing-wiring gap as the trace push, one seam over. MeshTerm's live
feed is built from RX_LOG_DATA events — every overheard frame with its SNR/RSSI.
pymc_core's frame server has ``push_rx_raw`` (which builds the 0x88 push) but its
``_setup_push_callbacks`` never registers ``on_rx_log_data``, so it is never
called. Adverts still show because they carry their *own* decoded push
(``advert_received``, which is wired); everything else we overhear — requests,
channel text we can't decrypt, acks — travels only via this raw log and is
dropped. So the feed looks advert-only.
Subscribe to the dispatcher's raw-packet feed directly and push each frame.
Going through the dispatcher (not ``on_rx_log_data``) keeps it clear of the
frame server's per-connection ``clear_push_callbacks()``, and that feed fires
before dedup, so the client sees every repeat — exactly what a live feed wants.
Upstream, the fix is again the mirror of the wired pushes: register
``on_rx_log_data`` → ``push_rx_raw`` in ``frame_server._setup_push_callbacks``.
"""
dispatcher = getattr(getattr(companion, "node", None), "dispatcher", None)
if dispatcher is None or not hasattr(dispatcher, "add_raw_packet_subscriber"):
log.warning("rx-log push not wired (no dispatcher)")
return
if not hasattr(server, "push_rx_raw"):
log.warning("rx-log push not wired (no push_rx_raw)")
return
async def on_raw(pkt, data, _analysis=None) -> None:
try:
snr = getattr(pkt, "snr", getattr(pkt, "_snr", 0.0))
rssi = getattr(pkt, "rssi", getattr(pkt, "_rssi", 0))
server.push_rx_raw(snr or 0.0, rssi or 0, bytes(data))
except Exception as exc:
log.warning("rx-log push failed: %s", exc)
dispatcher.add_raw_packet_subscriber(on_raw)
log.info("compat: raw RX packets now push PUSH_CODE_LOG_RX_DATA to the client")
def run_bridge(host: str, port: int, node_name: str) -> int:
import asyncio
import logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
)
log = logging.getLogger(APP_NAME)
try:
import pymc_core # noqa: F401
except ImportError:
sys.stderr.write(
"This must run under a Python that has pymc_core. Run the menu instead "
"(it finds the right one), or set MESHTERM_PYMC_PYTHON.\n"
)
return 2
_patch_pymc_ack_compat(log)
from pymc_core.companion import CompanionFrameServer, CompanionRadio
# Prefer the hackergadgets meshcore_console helpers (correct board pins +
# the SAME node identity the GUI uses). Fall back to a direct pymc_core
# setup for a generic install (e.g. a Waveshare HAT with pip'd pymc_core).
identity = None
radio = None
try:
from meshcore_console.meshcore.config import load_hardware_config_from_env
from meshcore_console.meshcore.paths import identity_key_path
from meshcore_console.meshcore.runtime import create_radio, import_pymc_core
SX1262Radio, _ES, _ESub, _MN, LocalIdentity = import_pymc_core()
hw = load_hardware_config_from_env()
log.info("radio config %s", hw.to_log_string())
radio = create_radio(SX1262Radio, hw, log.info)
key_path = identity_key_path()
if key_path.exists():
identity = LocalIdentity(key_path.read_bytes())
log.info("using existing node identity at %s", key_path)
radio_config = {
"frequency": hw.frequency, "bandwidth": hw.bandwidth,
"spreading_factor": hw.spreading_factor, "coding_rate": hw.coding_rate,
"tx_power": hw.tx_power,
}
except ImportError:
log.info("meshcore_console not present — using direct pymc_core defaults")
from pymc_core.hardware.sx1262_wrapper import SX1262Radio
from pymc_core.protocol.identity import LocalIdentity
radio = SX1262Radio(
bus_id=int(os.environ.get("MESHCORE_BUS_ID", 1)),
cs_id=int(os.environ.get("MESHCORE_CS_ID", 0)),
cs_pin=int(os.environ.get("MESHCORE_CS_PIN", -1)),
reset_pin=int(os.environ.get("MESHCORE_RESET_PIN", 25)),
busy_pin=int(os.environ.get("MESHCORE_BUSY_PIN", 24)),
irq_pin=int(os.environ.get("MESHCORE_IRQ_PIN", 26)),
frequency=int(os.environ.get("MESHCORE_FREQUENCY", 910_525_000)),
tx_power=int(os.environ.get("MESHCORE_TX_POWER", 22)),
spreading_factor=int(os.environ.get("MESHCORE_SPREADING_FACTOR", 7)),
bandwidth=int(os.environ.get("MESHCORE_BANDWIDTH", 62_500)),
coding_rate=int(os.environ.get("MESHCORE_CODING_RATE", 5)),
)
radio_config = {
"frequency": 910_525_000, "bandwidth": 62_500,
"spreading_factor": 7, "coding_rate": 5, "tx_power": 22,
}
key_path = HOME / ".local" / "share" / APP_NAME / "identity.key"
if key_path.exists():
identity = LocalIdentity(key_path.read_bytes())
if identity is None:
# No persisted identity anywhere — mint one and save it so the node
# keeps a stable identity across restarts.
from pymc_core.protocol.identity import LocalIdentity as _LI
tmp = _LI()
seed = tmp.get_signing_key_bytes()
save_path = HOME / ".local" / "share" / APP_NAME / "identity.key"
save_path.parent.mkdir(parents=True, exist_ok=True)
save_path.write_bytes(seed)
identity = _LI(seed)
log.info("generated a new node identity at %s", save_path)
async def _main() -> int:
# radio.begin() with backoff — GPIO lines may still be held briefly by
# a just-closed session.
import time
for attempt in range(4):
log.info("radio.begin() attempt %d/4", attempt + 1)
try:
if not radio.begin():
raise RuntimeError("radio.begin() returned False")
break
except SystemExit:
if attempt == 3:
raise RuntimeError("GPIO pins still busy after retries")
try:
radio.cleanup()
except Exception:
pass
time.sleep(2.0 * (attempt + 1))
log.info("radio ready")
companion = CompanionRadio(radio, identity, node_name=node_name, radio_config=radio_config)
await companion.start()
_wire_contact_persistence(companion, log)
pub = companion.get_public_key()
log.info("node identity %s… (hash 0x%02x, name %r)", pub.hex()[:16], pub[0], node_name)
server = CompanionFrameServer(
bridge=companion,
companion_hash=f"{pub[0]:02x}",
port=port,
bind_address=host,
local_hash=pub[0],
device_model="pyMC-spi-bridge",
)
await server.start()
_wire_trace_push(companion, server, log)
_wire_rx_log_push(companion, server, log)
log.info("READY — connect with: meshterm --tcp %s:%d", host, port)
import signal
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop.set)
except NotImplementedError:
pass
try:
await stop.wait()
finally:
log.info("shutting down…")
try:
await server.stop()
await companion.stop()
except Exception:
pass
return 0
try:
return asyncio.run(_main())
except KeyboardInterrupt:
return 0
except Exception as exc: # surface a clean message, not a traceback wall
log.error("bridge failed: %s", exc)
return 1
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
def main() -> int:
ap = argparse.ArgumentParser(
description="Expose an SPI-attached MeshCore radio to MeshTerm over TCP."
)
ap.add_argument("--check", action="store_true", help="run prerequisite checks and exit")
ap.add_argument("--run", action="store_true", help="run the bridge now (finds the runtime)")
ap.add_argument("--run-bridge", action="store_true", help=argparse.SUPPRESS)
ap.add_argument("--host", default=DEFAULT_HOST)
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--name", default=DEFAULT_NODE_NAME)
args = ap.parse_args()
# Internal: we've already been re-execed under the pymc_core interpreter.
if args.run_bridge:
return run_bridge(args.host, args.port, args.name)
if args.check:
Preflight().print()
return 0
if args.run:
pre = Preflight()
pre.print()
action_run_now(pre)
return 0
return menu()
if __name__ == "__main__":
raise SystemExit(main())