A final pass over the docstrings and comments: "the own X of Y" is now "Y's own X" (84 places), a navigation entry is a screen on the stack, our node is our node, a paint is a paint, the quoted UX labels are in code font, and a list of punctuation glyphs is in code font. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1268 lines
51 KiB
Python
Executable File
1268 lines
51 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# SPDX-License-Identifier: Apache-2.0
|
||
"""
|
||
meshterm-spi-bridge: make an SPI-attached MeshCore radio available to MeshTerm.
|
||
|
||
Some boards (the hackergadgets AIO of the ClockworkPi uConsole, and Waveshare LoRa HATs)
|
||
connect the LoRa chip directly to the SPI bus of the host. They have no companion MCU and
|
||
no firmware to flash. The node runs in software on the host, with the `openhop_core`
|
||
library. The name of the library was `pymc_core` until its rename in mid-2026. The bridge
|
||
supports both names, and it prefers the newer one. But MeshTerm uses the MeshCore
|
||
companion protocol over serial, BLE, or TCP. It expects to talk to a node. It does not
|
||
drive a bare radio.
|
||
|
||
This tool connects the two. It runs the software node itself, and it puts the standard
|
||
companion protocol in front of the node, on a local TCP port. Then
|
||
|
||
meshterm --tcp 127.0.0.1:5000
|
||
|
||
connects in the same way as if a real companion device is connected.
|
||
|
||
This file is two things: a setup menu and the bridge process. Run it with any Python 3.
|
||
It finds the node runtime for you, and it runs the bridge half again under that
|
||
interpreter automatically. You do not 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 that this bridge can drive, newest first. `pymc_core` became
|
||
# `openhop_core` in 2026. (PyPI `pymc-core` stops at 1.0.12, and `openhop-core` continues
|
||
# from 1.1.x.) The newer library has taken two of the three compat shims of this file
|
||
# upstream. Thus, when both are installed, the bridge uses the newer one.
|
||
RUNTIMES = ("openhop_core", "pymc_core")
|
||
|
||
# The interpreters that probably have a runtime, in order of preference. The first is the
|
||
# own venv of the bridge. This is the answer on a host where the distro package cannot be
|
||
# upgraded. (The meshcore-uconsole .deb for trixie is built against Python 3.13 and does
|
||
# not run on a bookworm uConsole. `python3 -m venv <this path> && pip install
|
||
# "openhop-core[hardware]"` gives the bridge the current runtime, and the GUI keeps its
|
||
# own.) The second is the private virtualenv of the hackergadgets uConsole .deb.
|
||
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, and off when piped, on a dumb terminal, or when 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 that the user must type or copy."""
|
||
return bold(cyan(t))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Small text helpers (plain on purpose: a little colour, and no decoration)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
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 that `python` can import (a name from RUNTIMES), or None.
|
||
|
||
The function starts one subprocess for each interpreter. The subprocess reports the
|
||
first runtime that it finds, in the order of RUNTIMES. Thus an interpreter that has both
|
||
runtimes 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. This function keeps 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 the command exists."""
|
||
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.
|
||
|
||
The candidates, in order, are: the explicit override, this interpreter, the known venv
|
||
paths, and the interpreter behind an installed `meshcore-console`. The newest runtime
|
||
wins across all of them. An interpreter with `openhop_core` is better than an earlier
|
||
candidate that has only `pymc_core`. The override is the one exception.
|
||
MESHTERM_PYMC_PYTHON names a python, and the function uses the runtime that it has.
|
||
"""
|
||
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 that `find_runtime_python` chooses, or None.
|
||
|
||
This function keeps 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 a process already accepts 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]:
|
||
"""The command lines of the meshcore-console (GUI or CLI) processes that run.
|
||
|
||
The list can be incomplete.
|
||
"""
|
||
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 calls the python of the meshcore-uconsole venv, not the
|
||
# meshcore-console binary. Thus it never matches, but the check is a guard.
|
||
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 (information only)
|
||
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. The unit points to this copy.
|
||
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 the service start at boot, also 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 copy of the helper. Do not remove a repository checkout that
|
||
# you ran the script 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:
|
||
"""Offer to add a MeshTerm TCP profile, so that a bare `meshterm` connects here."""
|
||
if MESHTERM_CONFIG.exists() and "[profiles.bridge]" in MESHTERM_CONFIG.read_text(errors="replace"):
|
||
return # the profile is 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, through --run-bridge)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _import_runtime():
|
||
"""Import the node runtime that this interpreter has: `(module_name, module)`.
|
||
|
||
The function uses the order of RUNTIMES. Thus `openhop_core` wins over a `pymc_core`
|
||
that is installed next to it. 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 the runtime that 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 have extra bytes after the CRC.
|
||
|
||
Newer firmware sends discrete ACK payloads that are longer than 4 bytes (the 4-byte CRC
|
||
first, then extra bytes). The AckHandler of pymc_core needs exactly 4 bytes and drops the
|
||
rest. Thus delivery confirmations never reach MeshTerm. This function overrides the
|
||
handler to use the first 4 bytes as the CRC. This is the same tolerant parse that
|
||
pymc_core already uses for ACKs that are bundled in a path.
|
||
|
||
openhop_core has this change upstream. Its `process_discrete_ack` uses the first four
|
||
bytes of any payload of four bytes or more. Thus, under that runtime, the shim does
|
||
nothing, because the library already does the same thing.
|
||
"""
|
||
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: # the package layout changed, so do not change the package
|
||
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 contact list of the node when the bridge restarts.
|
||
|
||
The ContactStore of pymc_core keeps data in memory only. Thus a restart forgot each
|
||
contact, and DMs failed with ERR_CODE_NOT_FOUND until the node heard the peer again.
|
||
This function loads the last snapshot at startup and saves a new snapshot after each
|
||
change.
|
||
"""
|
||
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 channel table of the node when the bridge restarts.
|
||
|
||
The ChannelStore of the runtime keeps data in memory only, as it does for contacts. Each
|
||
restart started with no channels, and the Channels page of MeshTerm was empty until the
|
||
user added each channel again. This function loads the last snapshot at startup and
|
||
saves a new snapshot after each set or clear. The frame server does a set or a clear
|
||
through ``set_channel`` and ``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 the TRACE packets that it receives, but it never gives them to the
|
||
companion client. Its frame server registers each push callback except
|
||
``on_trace_received``, and nothing in the library fires the ``trace_received`` event.
|
||
Thus the whole path is dead code. The only ``push_trace_data`` call is the self-hop
|
||
shortcut in ``_cmd_send_trace_path``. As a result, ``run_trace`` of MeshTerm sends the
|
||
trace and gets ``RESP_CODE_SENT``. Then it waits for ``TRACE_DATA`` and times out,
|
||
while the node drops the reply with no message.
|
||
|
||
This function bridges the gap at the one place that sees each received packet with its
|
||
measured SNR. This is the packet-received callback of the dispatcher (``CompanionRadio``
|
||
already installs ``_on_packet_received`` there, and it acts only on group data). The
|
||
function wraps the callback. It runs the original callback. Then, for a TRACE payload,
|
||
it cuts the on-air bytes again into the hash and SNR blocks that ``push_trace_data``
|
||
needs, and it pushes the standard 0x89 frame. The ``meshcore`` client on the side of
|
||
MeshTerm decodes exactly this frame.
|
||
|
||
The SNR values are the difficult part. A TRACE packet keeps the route (the hop hashes)
|
||
in its payload, and the payload does not change while the packet travels. The SNR of
|
||
each hop is in the **path field of the packet header** instead. Each hop appends one
|
||
signed byte (SNR × 4). The SNR block of ``push_trace_data`` and the decoder of MeshTerm
|
||
both take the SNR values from this field. Thus the payload never grows. An earlier
|
||
attempt waited for SNR values to appear in the payload, and it never fired.
|
||
|
||
A received TRACE packet is not a reply only because it exists. The dispatcher is
|
||
promiscuous. It runs the trace handler and this callback for each TRACE frame that it
|
||
overhears and that is not a duplicate. This includes the traces of third parties. It
|
||
also includes our own outbound trace that the first repeater echoes back (the same
|
||
tag, and the header path is still filling). If the function pushes these packets, the
|
||
wait of MeshTerm, which filters by tag, resolves at once against a trace that is half
|
||
complete. Then each trace reads as a success. Thus the function uses a strict gate. It
|
||
remembers the tag and the route of each trace that we originate (``send_trace_path_raw``
|
||
is the one send point that the frame server calls). It pushes only a received TRACE that
|
||
has a tag that we sent and a header that has an SNR for each hop of that route. This is
|
||
the completed reply.
|
||
|
||
Upstream, the clean repair is the same as for each other push. ``_on_packet_received``
|
||
fires ``trace_received`` for a completed trace, and
|
||
``frame_server._setup_push_callbacks`` registers ``on_trace_received`` for
|
||
``push_trace_data``. Until a pymc_core release has this, this shim stays (it has the
|
||
same purpose as :func:`_patch_pymc_ack_compat`).
|
||
"""
|
||
import struct
|
||
import time
|
||
|
||
# openhop_core has this upstream. Its CompanionRadio fires ``trace_received`` for a
|
||
# completed trace, and its frame server registers ``_on_trace_received`` for
|
||
# ``push_trace_data``. A second bridge pushes each reply two
|
||
# times. Thus, where the server has the handler, the shim does nothing.
|
||
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: # the package layout changed, so do not change the package
|
||
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 that we originated, and that walked
|
||
# the full route, becomes a push. The entries expire. Thus a trace that nobody answered
|
||
# cannot match a tag that the node uses again later.
|
||
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 that we did not send (a third party that we overheard): ignore it
|
||
_sent_flags, _sent_path, _dl = rec
|
||
hw = 1 << (flags & 0x03)
|
||
route = payload[9:] # the hop hashes, which do not change in flight
|
||
hops = len(route) // hw if hw else 0
|
||
# The SNR of each hop is in the header path: one signed byte (SNR*4) for each 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 # the trace is still in flight: not all hops have reported an SNR
|
||
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 for each 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:
|
||
"""Send the promiscuous RX packet log to the client (PUSH_CODE_LOG_RX_DATA).
|
||
|
||
This is the same gap in the wiring as the trace push, in a place next to it. The live
|
||
feed of MeshTerm comes from RX_LOG_DATA events. Each overheard frame has its SNR and
|
||
RSSI. The frame server of pymc_core has ``push_rx_raw`` (which builds the 0x88 push),
|
||
but its ``_setup_push_callbacks`` never registers ``on_rx_log_data``, so nothing calls
|
||
it. Adverts still show, because they have their own decoded push (``advert_received``,
|
||
which is wired). Everything else that we overhear travels only through this raw log, and
|
||
it is dropped. This includes requests, channel text that we cannot decrypt, and acks.
|
||
Thus the feed looks as if it has only adverts.
|
||
|
||
This function subscribes to the raw-packet feed of the dispatcher directly, and it
|
||
pushes each frame. The path through the dispatcher (not ``on_rx_log_data``) does not
|
||
meet the ``clear_push_callbacks()`` that the frame server calls for each connection.
|
||
That feed fires before the removal of duplicates, so the client sees each repeat. A
|
||
live feed needs this.
|
||
|
||
Upstream, the repair is again the same as for the wired pushes: register
|
||
``on_rx_log_data`` for ``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. They have the correct board pins and
|
||
# the same node identity that the GUI uses. Fall back to a direct setup for a generic
|
||
# install (a Waveshare HAT with a runtime from pip), or for the bridge's own venv on
|
||
# a host where the GUI package is older than 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 name of the helper comes from the runtime that the GUI was built against.
|
||
# Each spelling returns 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)
|
||
# This is the same preamble rule as the direct path below. Unless the environment
|
||
# names a preamble, follow MeshCore (32 symbols up to SF8, 16 above). Thus the radio
|
||
# hears the mesh. begin() reads the value, so the change 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. Each value is the same as the default of the
|
||
# HardwareRadioConfig of meshcore_console. The defaults of the runtime itself are
|
||
# the defaults of a Waveshare HAT (TX-enable on pin 6, no DIO2 RF switch, no DIO3
|
||
# TCXO, 868 MHz), and on the AIO they make the radio deaf. The environment can
|
||
# override each value, with the same MESHCORE_* names that 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),
|
||
# The rule of MeshCore (RadioLibWrapper::preambleLengthForSF): 32 symbols up
|
||
# to SF8, 16 above. The receiver must use the same value as the sender. The
|
||
# SX1262 waits for the sync word only for approximately the length of the
|
||
# preamble that it expects. Thus the old fixed value of 12, against a mesh that
|
||
# sent 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 the arguments that the constructor of this runtime knows. pymc_core 1.0.x
|
||
# does not have some of them (en_pins, the gpiod backend) and refuses 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"],
|
||
}
|
||
# Use the identity of the GUI first, in the place where the meshcore-uconsole stack
|
||
# keeps it. Thus a bridge that runs from its own venv is the same node as the GUI.
|
||
# Then use the bridge's own identity.
|
||
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:
|
||
# There is no stored identity anywhere. Make one and save it. Thus the node keeps a
|
||
# stable identity when it 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. A session that just closed can still hold the GPIO
|
||
# lines for a short time.
|
||
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: # show a clean message, not a long traceback
|
||
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: the script already ran again under the interpreter of the node runtime.
|
||
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())
|