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