#!/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(" 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())