From ec21d03c03ce7fe34ffc03344b35d074261b85f1 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sun, 13 Sep 2026 01:29:28 -0400 Subject: [PATCH] The SPI bridge drives openhop_core, and stands its shims down where the library caught up pymc_core was renamed openhop_core in 2026 and the current meshcore-uconsole package depends on the new name, so a bridge that only knew the old one failed its own preflight on a fresh install. It now finds either runtime, newest first (an interpreter with openhop_core beats an earlier candidate with pymc_core; MESHTERM_PYMC_PYTHON still overrides), imports through the name it found, and lets two of its three compat shims stand down under openhop_core, which accepts long ACK payloads and pushes completed trace replies itself; pushing a trace twice was the alternative. The raw RX-log push is still the bridge's. What the port found on the device shaped the rest. The 1.12.0 .deb is built for trixie against Python 3.13 and cannot run on a bookworm uConsole, so the bridge looks first in a venv of its own (~/.local/share/meshterm-spi-bridge/venv, made with openhop-core[hardware]) and, without the GUI's helpers there, configures the radio itself with the uConsole AIO preset rather than the runtime's Waveshare defaults (TX-enable on pin 6, no DIO2 switch, no TCXO -- deaf on the AIO), every MESHCORE_* knob the GUI reads honoured and passed only where the constructor accepts it. It also loads the GUI's identity key first, so a bridge in its own venv is still the same node. Verified on the uConsole: openhop-core 1.1.3, radio up, 159 contacts restored, info and contacts answered over TCP, and the boot service now runs from that venv. The manual and the index say all of this instead of the rename warning. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 ++ docs/hardware.md | 81 +++++++--- scripts/README.md | 2 +- scripts/meshterm-spi-bridge | 291 +++++++++++++++++++++++++++--------- 4 files changed, 290 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a464fe..7665478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ allowed to change behaviour, not just add to it. ### Added +- **The SPI radio bridge runs on `openhop_core`.** The library the uConsole's software + node runs on was renamed from `pymc_core` in 2026, and the current `meshcore-uconsole` + package depends on the new name. `meshterm-spi-bridge` now drives either runtime, + preferring the newer, and two of its three compatibility shims stand down under it — + openhop_core accepts long ACK payloads and pushes completed trace replies itself; only + the raw RX-log push is still the bridge's. On a bookworm uConsole, where the trixie + package cannot run, the bridge takes a venv of its own with `openhop-core[hardware]` + and still uses the GUI's identity, so it stays the same node. Verified on the device: + radio up, 159 contacts restored, `info` and `contacts` answered over TCP. + - **The PicoCalc picks its console font, and MeshTerm hands the console back.** A *Console font* row under Display on the Preferences page offers `6x12 (53x26)` — the font the device boots in — and `6x8 (53x40)`, fourteen more rows of the same glyph inventory in a diff --git a/docs/hardware.md b/docs/hardware.md index b15e330..635e513 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -456,9 +456,9 @@ confirming against it rather than taking from here. On some boards the LoRa chip hangs straight off the host's SPI bus — the ClockworkPi uConsole with the hackergadgets AIO, Waveshare LoRa HATs, and similar. There is **no companion microcontroller and no firmware to flash**, so there is no node for MeshTerm to -talk to: the mesh node itself has to run in software on the host, through the `pymc_core` -library (which on the uConsole arrives with the `meshcore-uconsole` package, alongside its -own `meshcore-console` GUI). +talk to: the mesh node itself has to run in software on the host, through the `openhop_core` +library — `pymc_core` until its 2026 rename, and on the uConsole it arrives with the +`meshcore-uconsole` package, alongside its own `meshcore-console` GUI. [`scripts/meshterm-spi-bridge`](../scripts/meshterm-spi-bridge) fills that gap. It runs the software node and puts the **standard companion protocol in front of it on a local TCP @@ -476,22 +476,43 @@ a packaged virtual environment, and you should never need to know that. It runs on the radio host as **your normal user, never root**: the node identity and the service are per-user. -### Before you start: the runtime rename +### Before you start: which runtime you have -> **On a current uConsole install this bridge does not start, and the fix is a port nobody -> has done yet.** It was written against **`pymc_core` 1.0.12** and every one of its -> interfaces was checked against that release. Since then the library was renamed: the -> `pyMC_core` project became `openhop_core`, PyPI's `pymc-core` stopped at 1.0.12 in May -> 2026, and the current `meshcore-uconsole` package depends on `openhop-core` instead. That -> virtual environment has no `pymc_core` module at all, and the helper module the bridge -> imports from now exports a differently named entry point. What you will see is the -> bridge's own preflight reporting the runtime as **not found**, and nothing running. -> -> That is the whole of what has been verified: the rename, the package dependency, and that -> the preflight therefore fails. Whether the bridge's three compatibility shims are still -> needed under the new library, and what else moved inside it, has **not** been checked. If -> you are on an older, pre-rename install — `pymc-core` 1.0.x — everything below applies as -> written. +The library was renamed in 2026: the `pyMC_core` project became `openhop_core`, PyPI's +`pymc-core` stopped at 1.0.12, and `openhop-core` carries on from 1.1.x. The bridge drives +**either**, and prefers the newer one when both are installed. Its preflight names the +runtime it found beside the interpreter it will run under: + +``` +[ ok ] node runtime /home/you/.local/share/meshterm-spi-bridge/venv/bin/python (openhop_core) +``` + +Under `openhop_core` two of the bridge's three compatibility shims stand down, because the +library now does their work itself — it accepts the longer ACK payloads modern firmware +sends, and it pushes a completed trace reply to the client. The bridge logs each as +`not needed` at startup. The third, the raw RX-log push that feeds MeshTerm's live feed, is +still the bridge's, and still logs `compat:` when it is wired. + +**Which runtime you get depends on your Debian release.** The `meshcore-uconsole` package +from 1.12.0 is built for *trixie* against Python 3.13; on a *bookworm* uConsole (Python +3.11) that package will not run — its virtual environment points at an interpreter the +system does not have — and apt will offer it anyway. Do not take the upgrade. Give the +bridge a runtime of its own instead, in the directory the preflight looks in first: + +```bash +python3 -m venv ~/.local/share/meshterm-spi-bridge/venv +~/.local/share/meshterm-spi-bridge/venv/bin/pip install "openhop-core[hardware]" +``` + +The GUI keeps its own stack, and the bridge still loads the GUI's identity key from +`~/.local/share/meshcore-uconsole/identity.key`, so it is the same node either way. Without +the `meshcore_console` helpers in that venv the bridge configures the radio itself, with +the uConsole AIO preset (SPI bus 1, reset 25, busy 24, IRQ 26, no TX-enable pin, the DIO2 +RF switch and the DIO3 TCXO on, 910.525 MHz) and every `MESHCORE_*` override the GUI +honours — see [Hardware knobs](#hardware-knobs). Verified on a bookworm uConsole with +`openhop-core` 1.1.3: the radio initialises, the contacts come back, and MeshTerm reads +`info` and `contacts` over the TCP port. On a pre-rename install — `pymc-core` 1.0.x — +everything below applies as written, all three shims included. ### Setting it up @@ -554,8 +575,15 @@ board, export what differs before launching. | `MESHCORE_RESET_PIN`, `MESHCORE_BUSY_PIN`, `MESHCORE_IRQ_PIN` | the three control lines | | `MESHCORE_FREQUENCY`, `MESHCORE_TX_POWER` | frequency in Hz, power in dBm | | `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining | +| `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) | +| `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the uConsole), and the Waveshare HAT's own wiring | +| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble | | `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one | +The direct path passes a knob only when the runtime's radio constructor accepts it, so the +same environment works under `pymc_core` 1.0.x, which lacks the newer ones. Under the +`meshcore_console` helpers the GUI's own configuration reads the same names. + Where the `meshcore-console` stack is installed, the bridge builds the radio through *its* hardware configuration, which reads a longer list of variables — transmit/receive enable pins, the GPIO chip, the Waveshare and DIO options, and the enable pins an AIO v2 needs to @@ -576,9 +604,10 @@ Traces and the live feed reach MeshTerm through compatibility shims the bridge i the library: it subscribes to raw packets to feed the live feed, reassembles a completed trace reply and pushes it, and relaxes an acknowledgement length check that newer firmware trips by appending bytes after the CRC. None of that is MeshTerm's protocol being bent — -the library simply never registered those two pushes — and upstream wiring would make the -shims redundant. Their **on-air behaviour was validated on one bench**, against the radios -that were in range of it. +`pymc_core` simply never registered those two pushes. `openhop_core` has since taken the +ACK relaxation and the trace push upstream, and under it the bridge installs only the raw +RX-log shim, logging the other two as `not needed`. Their **on-air behaviour was validated +on one bench**, against the radios that were in range of it. --- @@ -602,7 +631,9 @@ expecting a finished tool. Most of what this manual explains is checked in code: the platform spec, the font and palette contracts (a test parses the font script itself and pins it to MeshTerm's glyph inventory), the profile keys the scripts write, the soldered-UART liveness fix, and every -interface the SPI bridge calls in `pymc_core` 1.0.12. The upstream firmware state is checked +interface the SPI bridge calls in `pymc_core` 1.0.12 and in `openhop_core` 1.1.3 (the +latter on a live uConsole: radio up, contacts restored, `info` and `contacts` answered over +TCP). The upstream firmware state is checked too: the pinned MeshCore commit exists, the patch's two hunks are still needed on `dev` today, and the nRF52840 UF2 family id and both XIAO bootloader ids match Adafruit's board files. @@ -633,8 +664,10 @@ following is confirmable from code in this repository, and a second device may d - **Everything on-air about the SPI bridge** — the per-hop trace semantics it reassembles and the acknowledgement bytes "newer firmware" appends. The code is internally consistent with the library's API; the wire behaviour was seen on one mesh. -- **Whether the renamed radio library still needs the bridge's three shims**, and where the - current package installs itself. +- **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity, + contact restore and those two reads were exercised on a uConsole; a trace, a message and + the live feed under the new runtime were not, though the library's own trace push is + what the bridge now relies on for the first. - **The framebuffer console's 512-glyph cap.** Well known, taken as given, not re-measured here. diff --git a/scripts/README.md b/scripts/README.md index 385db6d..376a141 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -17,7 +17,7 @@ been proven on one bench. Start there and pick your target; the table below is t | [`calculinux-console-font-6x8.sh`](calculinux-console-font-6x8.sh) | the Lyra (root) | The shorter-cell companion font, **meshterm8** (6×8 → 53×40), for anyone who wants more rows than the 6×12 default gives. Its base bitmap is the kernel's `font_6x8`, so this one file and the PSF it builds are **GPL-2.0-only** ([`LICENSE.GPL-2.0`](LICENSE.GPL-2.0)); it reads the donor/alias/keeper tables out of the script above rather than keeping a second copy. Pick between the two fonts from MeshTerm's Preferences page. | | [`calculinux-wifi-set.sh`](calculinux-wifi-set.sh) | the Lyra (sudo) | Interactive Wi-Fi join: prompts for an SSID and passphrase, writes iwd's credentials, connects now. This is how a network *becomes* known to the boot kick. | | [`calculinux-radio-bridge.sh`](calculinux-radio-bridge.sh) | the Lyra (root) | **Unfinished, unsupported.** An early UART-to-TCP pump, superseded by `xiao-radio/lyra-setup.sh`; its header describes routing it does not contain. See the manual before reading it. | -| [`meshterm-spi-bridge`](meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. **Written for `pymc_core` 1.0.x — see the manual's rename note before installing.** | +| [`meshterm-spi-bridge`](meshterm-spi-bridge) | the radio host (your user) | Runs a mesh node in software over an SPI-attached SX1262 (uConsole AIO, Waveshare HATs) and serves the companion protocol on `127.0.0.1:5000`. Setup menu, preflight, optional `systemd --user` service. Runs on `openhop_core` (preferred) or its predecessor `pymc_core`; see the manual for the venv it wants on a bookworm uConsole. | | [`xiao-radio/`](xiao-radio/) | see below | Everything for the XIAO nRF52840 + Wio-SX1262 radio wired to the Lyra's UART1. | | [`xiao-radio/build-firmware.sh`](xiao-radio/build-firmware.sh) | dev machine | Clones and patches MeshCore, builds the `Xiao_nrf52_companion_radio_serial` environment, emits the `.uf2`. | | [`xiao-radio/flash.py`](xiao-radio/flash.py) | dev machine | Flashes the `.uf2` to the XIAO over its UF2 bootloader, refusing the wrong board or SoftDevice; prints the serial-DFU command when the bootloader offers no drive. | diff --git a/scripts/meshterm-spi-bridge b/scripts/meshterm-spi-bridge index e860799..4dbb142 100755 --- a/scripts/meshterm-spi-bridge +++ b/scripts/meshterm-spi-bridge @@ -5,10 +5,12 @@ meshterm-spi-bridge — expose an SPI-attached MeshCore radio to MeshTerm. Some boards (the ClockworkPi uConsole hackergadgets AIO, Waveshare LoRa HATs) wire the LoRa chip straight to the host's SPI bus — there is no companion MCU and no firmware to flash. The node runs *in software* on the host via the -`pymc_core` library. MeshTerm, though, speaks the MeshCore companion protocol -over serial/BLE/TCP and expects to talk to a node, not to drive a bare radio. +`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 pymc_core node itself and puts the +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 @@ -16,7 +18,7 @@ standard companion protocol in front of it on a local TCP port, so that connects exactly as if a real companion device were plugged in. It is BOTH a friendly setup menu and the bridge process. Run it with any -Python 3 — it finds the pymc_core runtime for you and re-execs the bridge half +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 @@ -42,11 +44,24 @@ DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 5000 DEFAULT_NODE_NAME = "uConsole" -# Interpreters likely to have pymc_core, in preference order. The hackergadgets -# uConsole .deb installs everything into this private virtualenv. -VENV_CANDIDATES = ["/opt/venvs/meshcore-uconsole/bin/python"] - HOME = Path.home() + +# 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 && 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 @@ -133,20 +148,37 @@ def confirm(prompt: str, default_yes: bool = False) -> bool: # --------------------------------------------------------------------------- # -def python_has_pymc(python: str) -> bool: - """Return whether `python` can import pymc_core.""" +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 False + 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", "import pymc_core"], - stdout=subprocess.DEVNULL, + [python, "-c", probe], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, timeout=20, ) - return r.returncode == 0 except Exception: - return False + 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: @@ -165,26 +197,45 @@ def _meshcore_console_python() -> str | None: return None -def find_pymc_python() -> str | None: - """Find an interpreter that has pymc_core, or None. +def find_runtime_python() -> tuple[str, str] | None: + """Find an interpreter that has a node runtime: `(python, runtime)`, or None. - Order: explicit override, this interpreter, known venv paths, then the - interpreter behind an installed `meshcore-console`. + 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() - candidates = [os.environ.get("MESHTERM_PYMC_PYTHON", ""), sys.executable] - candidates += VENV_CANDIDATES + 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) - if python_has_pymc(c): - return 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 # --------------------------------------------------------------------------- # @@ -253,7 +304,8 @@ def service_active() -> bool: class Preflight: def __init__(self) -> None: - self.python = find_pymc_python() + 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") @@ -271,12 +323,13 @@ class Preflight: print(bold("Prerequisite check:")) # 1. runtime if self.python: - print(f" {tag(True)} pymc_core runtime {self.python}") + print(f" {tag(True)} node runtime {self.python} ({self.runtime})") else: - print(f" {tag(False)} pymc_core runtime not found") - print(" fix: install the radio stack, e.g. on the uConsole:") - print(" sudo apt install meshcore-uconsole") - print(" (or set MESHTERM_PYMC_PYTHON to a python that has it)") + 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: @@ -384,7 +437,7 @@ def _write_unit(python: str) -> None: ) UNIT_PATH.write_text( "[Unit]\n" - "Description=MeshTerm SPI-radio companion bridge (pymc_core -> TCP)\n" + "Description=MeshTerm SPI-radio companion bridge (software node -> TCP)\n" "After=network.target\n\n" "[Service]\n" "Type=simple\n" @@ -398,7 +451,7 @@ def _write_unit(python: str) -> None: def action_install_service(pre: Preflight) -> None: if not pre.python: - print("\nCan't install — the pymc_core runtime wasn't found (see above).") + 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") @@ -562,7 +615,31 @@ def menu() -> int: # --------------------------------------------------------------------------- # -def _patch_pymc_ack_compat(log) -> None: +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 @@ -570,9 +647,16 @@ def _patch_pymc_ack_compat(log) -> None: 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: - from pymc_core.node.handlers.ack import AckHandler + 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 @@ -661,7 +745,7 @@ def _wire_contact_persistence(companion, log) -> None: log.info("contact persistence at %s", path) -def _wire_trace_push(companion, server, log) -> None: +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 @@ -707,8 +791,17 @@ def _wire_trace_push(companion, server, log) -> None: 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: - from pymc_core.protocol.constants import PAYLOAD_TYPE_TRACE + 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 @@ -846,29 +939,44 @@ def run_bridge(host: str, port: int, node_name: str) -> int: log = logging.getLogger(APP_NAME) try: - import pymc_core # noqa: F401 + rt, runtime = _import_runtime() except ImportError: sys.stderr.write( - "This must run under a Python that has pymc_core. Run the menu instead " - "(it finds the right one), or set MESHTERM_PYMC_PYTHON.\n" + "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 - _patch_pymc_ack_compat(log) + 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) - from pymc_core.companion import CompanionFrameServer, CompanionRadio + _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 pymc_core - # setup for a generic install (e.g. a Waveshare HAT with pip'd pymc_core). + # 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 - from meshcore_console.meshcore.runtime import create_radio, import_pymc_core - SX1262Radio, _ES, _ESub, _MN, LocalIdentity = import_pymc_core() + # 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) @@ -882,35 +990,80 @@ def run_bridge(host: str, port: int, node_name: str) -> int: "tx_power": hw.tx_power, } except ImportError: - log.info("meshcore_console not present — using direct pymc_core defaults") - from pymc_core.hardware.sx1262_wrapper import SX1262Radio - from pymc_core.protocol.identity import LocalIdentity + log.info("meshcore_console not present — using direct %s defaults", rt) + import inspect - radio = SX1262Radio( - bus_id=int(os.environ.get("MESHCORE_BUS_ID", 1)), - cs_id=int(os.environ.get("MESHCORE_CS_ID", 0)), - cs_pin=int(os.environ.get("MESHCORE_CS_PIN", -1)), - reset_pin=int(os.environ.get("MESHCORE_RESET_PIN", 25)), - busy_pin=int(os.environ.get("MESHCORE_BUSY_PIN", 24)), - irq_pin=int(os.environ.get("MESHCORE_IRQ_PIN", 26)), - frequency=int(os.environ.get("MESHCORE_FREQUENCY", 910_525_000)), - tx_power=int(os.environ.get("MESHCORE_TX_POWER", 22)), - spreading_factor=int(os.environ.get("MESHCORE_SPREADING_FACTOR", 7)), - bandwidth=int(os.environ.get("MESHCORE_BANDWIDTH", 62_500)), - coding_rate=int(os.environ.get("MESHCORE_CODING_RATE", 5)), - ) - radio_config = { - "frequency": 910_525_000, "bandwidth": 62_500, - "spreading_factor": 7, "coding_rate": 5, "tx_power": 22, + 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), + "preamble_length": _int("MESHCORE_PREAMBLE_LENGTH", 12), + "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), } - key_path = HOME / ".local" / "share" / APP_NAME / "identity.key" - if key_path.exists(): - identity = LocalIdentity(key_path.read_bytes()) + # 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. - from pymc_core.protocol.identity import LocalIdentity as _LI + _LI = _rt_import(rt, "protocol.identity", "LocalIdentity") tmp = _LI() seed = tmp.get_signing_key_bytes() @@ -956,7 +1109,7 @@ def run_bridge(host: str, port: int, node_name: str) -> int: device_model="pyMC-spi-bridge", ) await server.start() - _wire_trace_push(companion, server, log) + _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) @@ -1006,7 +1159,7 @@ def main() -> int: ap.add_argument("--name", default=DEFAULT_NODE_NAME) args = ap.parse_args() - # Internal: we've already been re-execed under the pymc_core interpreter. + # 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)