Files
MeshTerm/scripts/uconsole/meshterm-spi-bridge
T
Jean-Pierre MartineauandClaude Opus 5.5 dee4674942 The SPI radio listens for the preamble the mesh actually sends
MeshCore transmits a 32-symbol preamble up to SF8 (16 above), but the
node and the bridge configured the SX1262 for the library's fixed 12.
The chip waits for the sync word only about as long as the preamble it
expects, so it locked onto each packet, gave up, and locked on again
further along the same preamble - decoding a packet only when it
happened to catch the tail. On the uConsole that meant 0 of 5 detected
packets decoded after a send; with 32 symbols, 4 of 4.

The node now derives the preamble from the spreading factor as MeshCore
does, and re-sends it to the listening chip when Device config changes
the SF. preamble_length leaves the SPI wiring table (it was protocol,
never wiring) and is retired; the bridge defaults to the same rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:12:52 -04:00

1257 lines
49 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
# SPDX-License-Identifier: Apache-2.0
"""
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
`openhop_core` library (`pymc_core` until its rename in mid-2026; both are
supported, the newer preferred). 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 software 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 node 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"
HOME = Path.home()
# The node runtimes this bridge can drive, newest first. `pymc_core` was renamed
# `openhop_core` in 2026 (PyPI `pymc-core` stops at 1.0.12; `openhop-core` carries on
# from 1.1.x) and the newer one has taken two of this file's three compat shims upstream,
# so when both are installed the newer wins.
RUNTIMES = ("openhop_core", "pymc_core")
# Interpreters likely to have a runtime, in preference order. First the bridge's own
# venv — the answer on a host whose distro package cannot be upgraded (the
# meshcore-uconsole .deb for trixie is built against Python 3.13 and will not run on a
# bookworm uConsole; `python3 -m venv <this path> && pip install "openhop-core[hardware]"`
# gives the bridge the current runtime while the GUI keeps its own). Then the
# hackergadgets uConsole .deb's private virtualenv.
VENV_CANDIDATES = [
str(HOME / ".local" / "share" / APP_NAME / "venv" / "bin" / "python"),
"/opt/venvs/meshcore-uconsole/bin/python",
]
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_runtime(python: str) -> str | None:
"""The node runtime `python` can import — a name from RUNTIMES — or None.
One subprocess per interpreter: it reports the first runtime it finds in
RUNTIMES order, so an interpreter carrying both answers `openhop_core`.
"""
if not python or not os.path.exists(python):
return None
probe = (
"import importlib.util as u, sys\n"
f"for m in {RUNTIMES!r}:\n"
" if u.find_spec(m):\n"
" print(m); break\n"
)
try:
r = subprocess.run(
[python, "-c", probe],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=20,
)
except Exception:
return None
found = r.stdout.strip()
return found if r.returncode == 0 and found in RUNTIMES else None
def python_has_pymc(python: str) -> bool:
"""Return whether `python` can import a node runtime (kept for the older name)."""
return python_runtime(python) is not None
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_runtime_python() -> tuple[str, str] | None:
"""Find an interpreter that has a node runtime: `(python, runtime)`, or None.
Candidates in order: the explicit override, this interpreter, the known venv
paths, then the interpreter behind an installed `meshcore-console`. The newest
runtime wins across all of them — an interpreter with `openhop_core` beats an
earlier candidate that only has `pymc_core` — and the override is the one
exception: MESHTERM_PYMC_PYTHON names a python, and whatever it has is used.
"""
seen: set[str] = set()
override = os.environ.get("MESHTERM_PYMC_PYTHON", "")
if override:
rt = python_runtime(override)
if rt:
return override, rt
candidates = [sys.executable, *VENV_CANDIDATES]
mcc = _meshcore_console_python()
if mcc:
candidates.append(mcc)
found: list[tuple[str, str]] = []
for c in candidates:
if c and c not in seen:
seen.add(c)
rt = python_runtime(c)
if rt:
found.append((c, rt))
for wanted in RUNTIMES:
for python, rt in found:
if rt == wanted:
return python, rt
return None
def find_pymc_python() -> str | None:
"""The interpreter `find_runtime_python` picks, or None (kept for the older name)."""
hit = find_runtime_python()
return hit[0] if hit else 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:
hit = find_runtime_python()
self.python, self.runtime = hit if hit else (None, None)
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)} node runtime {self.python} ({self.runtime})")
else:
print(f" {tag(False)} node runtime not found (openhop_core or pymc_core)")
print(" fix: give the bridge its own runtime, on any Debian-family host:")
print(f" python3 -m venv {VENV_CANDIDATES[0][: -len('/bin/python')]}")
print(f" {VENV_CANDIDATES[0][: -len('python')]}pip install 'openhop-core[hardware]'")
print(" (or set MESHTERM_PYMC_PYTHON to a python that has one)")
# 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 (software node -> 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 — no node runtime was 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 _import_runtime():
"""Import the node runtime this interpreter has: `(module_name, module)`.
RUNTIMES order, so `openhop_core` wins over a `pymc_core` sitting beside it.
Raises ImportError when neither is importable.
"""
import importlib
for name in RUNTIMES:
try:
return name, importlib.import_module(name)
except ImportError:
continue
raise ImportError("no node runtime: " + " or ".join(RUNTIMES))
def _rt_import(rt: str, dotted: str, attr: str | None = None):
"""`from {rt}.{dotted} import {attr}` for whichever runtime is loaded."""
import importlib
module = importlib.import_module(f"{rt}.{dotted}")
return getattr(module, attr) if attr else module
def _patch_pymc_ack_compat(rt: str, 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.
openhop_core carries this upstream (its `process_discrete_ack` takes the
first four bytes of anything four bytes or longer), so under that runtime
the shim stands down rather than re-stating what the library already does.
"""
if rt != "pymc_core":
log.info("compat: %s accepts >4-byte ACK payloads itself; ack shim not needed", rt)
return
try:
AckHandler = _rt_import(rt, "node.handlers.ack", "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_channel_persistence(companion, log) -> None:
"""Keep the node's channel table across bridge restarts.
The runtime's ChannelStore is memory-only, like its contacts: every restart
came up with no channels, and MeshTerm's Channels page was empty until each
one was added again. Reload the last snapshot at startup and re-snapshot
after every set or clear, which the frame server makes through
``set_channel`` / ``remove_channel`` on the companion.
"""
import json
store = getattr(companion, "channels", None)
if store is None or not hasattr(companion, "set_channel"):
log.warning("channel persistence not wired (no channel store)")
return
path = HOME / ".local" / "share" / APP_NAME / "channels.json"
def snapshot() -> list:
out = []
for idx in range(getattr(store, "max_channels", 40)):
ch = store.get(idx)
if ch is not None and ch.name:
out.append({"idx": idx, "name": ch.name, "secret": bytes(ch.secret).hex()})
return out
def save() -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(snapshot()))
tmp.replace(path)
except Exception as exc:
log.warning("could not save channels (%s)", exc)
if path.exists():
try:
restored = 0
for entry in json.loads(path.read_text()):
if entry.get("name"):
companion.set_channel(
int(entry["idx"]), entry["name"], bytes.fromhex(entry["secret"])
)
restored += 1
log.info("restored %d channel(s) from %s", restored, path)
except Exception as exc:
log.warning("could not restore channels (%s)", exc)
for name in ("set_channel", "remove_channel"):
orig = getattr(companion, name, None)
if orig is None:
continue
def wrapped(*args, _orig=orig, **kwargs):
result = _orig(*args, **kwargs)
save()
return result
setattr(companion, name, wrapped)
log.info("channel persistence at %s", path)
def _wire_trace_push(rt: str, 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
# openhop_core carries this upstream: its CompanionRadio fires
# ``trace_received`` for a completed trace and its frame server registers
# ``_on_trace_received`` → ``push_trace_data``. Bridging it a second time
# would push every reply twice, so where the server has the handler, the
# shim stands down.
if hasattr(server, "_on_trace_received"):
log.info("compat: %s pushes completed TRACE replies itself; trace shim not needed", rt)
return
try:
PAYLOAD_TYPE_TRACE = _rt_import(rt, "protocol.constants", "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:
rt, runtime = _import_runtime()
except ImportError:
sys.stderr.write(
"This must run under a Python that has openhop_core or pymc_core. Run the "
"menu instead (it finds the right one), or set MESHTERM_PYMC_PYTHON.\n"
)
return 2
try:
import importlib.metadata as _md
rt_version = _md.version(rt.replace("_", "-"))
except Exception:
rt_version = getattr(runtime, "__version__", "?")
log.info("node runtime %s %s (%s)", rt, rt_version, sys.executable)
_patch_pymc_ack_compat(rt, log)
CompanionFrameServer = _rt_import(rt, "companion", "CompanionFrameServer")
CompanionRadio = _rt_import(rt, "companion", "CompanionRadio")
# Prefer the hackergadgets meshcore_console helpers (correct board pins +
# the SAME node identity the GUI uses). Fall back to a direct setup for a
# generic install (a Waveshare HAT with a pip'd runtime), or for the
# bridge's own venv on a host whose GUI package is behind the runtime.
identity = None
radio = None
try:
from meshcore_console.meshcore import runtime as _mc_runtime
from meshcore_console.meshcore.config import load_hardware_config_from_env
from meshcore_console.meshcore.paths import identity_key_path
# The helper is named for the runtime the GUI was built against; either
# spelling hands back the same five-tuple.
_import_stack = getattr(_mc_runtime, "import_openhop_core", None) or getattr(
_mc_runtime, "import_pymc_core"
)
create_radio = _mc_runtime.create_radio
SX1262Radio, _ES, _ESub, _MN, LocalIdentity = _import_stack()
hw = load_hardware_config_from_env()
log.info("radio config %s", hw.to_log_string())
radio = create_radio(SX1262Radio, hw, log.info)
# The same preamble rule as the direct path below: unless the environment
# names one, follow MeshCore (32 symbols up to SF8, 16 above) so the radio
# hears the mesh; begin() reads it, so this is in time.
if "MESHCORE_PREAMBLE_LENGTH" not in os.environ and hasattr(radio, "preamble_length"):
radio.preamble_length = 32 if hw.spreading_factor <= 8 else 16
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 %s defaults", rt)
import inspect
SX1262Radio = _rt_import(rt, "hardware.sx1262_wrapper", "SX1262Radio")
LocalIdentity = _rt_import(rt, "protocol.identity", "LocalIdentity")
# The uConsole AIO preset, knob for knob what meshcore_console's own
# HardwareRadioConfig defaults to — because the runtime's *own* defaults
# are a Waveshare HAT's (TX-enable on pin 6, no DIO2 RF switch, no DIO3
# TCXO, 868 MHz), and on the AIO those leave the radio deaf. Every knob is
# env-overridable under the same MESHCORE_* names the GUI reads.
env = os.environ.get
def _int(name: str, default: int) -> int:
return int(env(name, default))
def _bool(name: str, default: bool) -> bool:
raw = env(name)
return default if raw is None else raw.strip().lower() in ("1", "true", "yes", "on")
def _pins(name: str) -> list[int] | None:
raw = env(name, "").strip()
return [int(p) for p in raw.split(",") if p.strip()] or None
wanted = {
"bus_id": _int("MESHCORE_BUS_ID", 1),
"cs_id": _int("MESHCORE_CS_ID", 0),
"cs_pin": _int("MESHCORE_CS_PIN", -1),
"gpio_chip": _int("MESHCORE_GPIO_CHIP", 0),
"use_gpiod_backend": _bool("MESHCORE_USE_GPIOD_BACKEND", False),
"reset_pin": _int("MESHCORE_RESET_PIN", 25),
"busy_pin": _int("MESHCORE_BUSY_PIN", 24),
"irq_pin": _int("MESHCORE_IRQ_PIN", 26),
"txen_pin": _int("MESHCORE_TXEN_PIN", -1),
"rxen_pin": _int("MESHCORE_RXEN_PIN", -1),
"en_pins": _pins("MESHCORE_EN_PINS"),
"frequency": _int("MESHCORE_FREQUENCY", 910_525_000),
"tx_power": _int("MESHCORE_TX_POWER", 22),
"spreading_factor": _int("MESHCORE_SPREADING_FACTOR", 7),
"bandwidth": _int("MESHCORE_BANDWIDTH", 62_500),
"coding_rate": _int("MESHCORE_CODING_RATE", 5),
# MeshCore's own rule (RadioLibWrapper::preambleLengthForSF): 32 symbols up
# to SF8, 16 above. The receiver must match it, not just the sender — the
# SX1262 waits for the sync word only about as long as the preamble it expects,
# so the old fixed 12 against a mesh sending 32 decoded almost nothing.
"preamble_length": _int(
"MESHCORE_PREAMBLE_LENGTH",
32 if _int("MESHCORE_SPREADING_FACTOR", 7) <= 8 else 16,
),
"is_waveshare": _bool("MESHCORE_IS_WAVESHARE", False),
"use_dio2_rf": _bool("MESHCORE_USE_DIO2_RF", True),
"use_dio3_tcxo": _bool("MESHCORE_USE_DIO3_TCXO", True),
}
# Pass only what this runtime's constructor knows: pymc_core 1.0.x lacks
# some of these (en_pins, the gpiod backend) and would refuse the call.
accepted = inspect.signature(SX1262Radio.__init__).parameters
kwargs = {k: v for k, v in wanted.items() if k in accepted and v is not None}
log.info("radio config %s", " ".join(f"{k}={v}" for k, v in kwargs.items()))
radio = SX1262Radio(**kwargs)
radio_config = {
"frequency": kwargs["frequency"], "bandwidth": kwargs["bandwidth"],
"spreading_factor": kwargs["spreading_factor"],
"coding_rate": kwargs["coding_rate"], "tx_power": kwargs["tx_power"],
}
# The GUI's identity first, where the meshcore-uconsole stack keeps one, so
# a bridge running from its own venv is still the same node the GUI is; then
# the bridge's own.
xdg = Path(env("XDG_DATA_HOME") or (HOME / ".local" / "share"))
for key_path in (
xdg / "meshcore-uconsole" / "identity.key",
HOME / ".local" / "share" / APP_NAME / "identity.key",
):
if key_path.exists():
identity = LocalIdentity(key_path.read_bytes())
log.info("using existing node identity at %s", key_path)
break
if identity is None:
# No persisted identity anywhere — mint one and save it so the node
# keeps a stable identity across restarts.
_LI = _rt_import(rt, "protocol.identity", "LocalIdentity")
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)
_wire_channel_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(rt, 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 node runtime's 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())