CCCPaste

MC:MT Bridge Skill USB

---
name: "mesh-relay-setup"
description: "Auto-discover MeshCore+Meshtastic USB radios, deploy/verify the unified mesh gateway (drain to Telegram/WhatsApp + reply path)."
metadata:
  homepage: ""
  license: CC BY-SA 4.0
  user-invocable: true
---

# Mesh Relay Setup (MeshCore <-> Meshtastic gateway)

Set up or validate the unified mesh gateway that bridges a MeshCore node and
a Meshtastic radio, draining both networks' public channels to Telegram
(primary) / WhatsApp (failover) with a reply path back over the radios.

Use when the user wants to (re)deploy the mesh bridge, discover which USB
port is which radio, check gateway/cron health, or after re-plugging radios.

## What it runs on
- One MeshCore companion-radio USB node (e.g. ESP32 "Armaros").
- One Meshtastic radio (e.g. Heltec Wireless Tracker "Relay c380").
- This host running OpenClaw with the mesh-gateway.service + cron jobs.

## Files
- `scripts/mesh_relay_setup.py` — discovery + status/apply CLI (primary tool).

## Workflow

1. **Discover the radios** (radios may be held by the running gateway; to get
   a clean scan, stop the service first or run with ports free):
   ```bash
   python3 /home/charles/mesh-relay/mesh_relay_setup.py scan
   ```
   Expect both lines: MeshCore (USB_JTAG) and Meshtastic (Heltec_Tracker).
   If either is NOT FOUND, the port is busy (gateway running) or a cable/role
   problem — see Troubleshooting.

2. **Check health** (works anytime, gateway may be in its 2-min boot delay):
   ```bash
   python3 /home/charles/mesh-relay/mesh_relay_setup.py status
   ```

3. **Apply discovered ports** (writes by-id paths into mesh_gateway.py):
   ```bash
   python3 /home/charles/mesh-relay/mesh_relay_setup.py apply --yes
   sudo systemctl restart mesh-gateway.service   # note: 2-min boot delay
   ```

4. **Verify end-to-end**:
   - Gateway: `systemctl is-active mesh-gateway.service` -> active;
     status endpoint http://127.0.0.1:8082 returns `{"status":"active",...}`.
   - Inbound: send a message on either radio -> should arrive in Telegram
     within ~5s (the "Mesh Watcher (5s tail)" command cron announces it).
   - Reply: user sends `mt: ` / `mc: ` / `both: ` (or
     `mt mc: `) to the agent -> agent runs
     `meshgw reply {mc|mt|both} ""` -> gateway transmits.

## Deployed components (the "final solution")
- `mesh-gateway.service` — owns BOTH radios; drains to
  /tmp/meshgw_events.jsonl; status :8082; polls /tmp/meshgw_send.txt for
  outbound; 2-min boot delay (TimeoutStartSec=300); Restart=on-failure.
- Cron "Mesh Watcher (5s tail)" — command payload (NO model cost), announce
  to Telegram 5725722373.
- Cron "Mesh Relay Health Check" (30 min) — checks mesh-gateway.service,
  model deepseek/deepseek-v4-flash, alert via WhatsApp.
- `meshgw` at /usr/local/bin/meshgw — start/stop/status/tail/reply/events.

## Key rules / gotchas
- Both radios' serial ports are LOCKED by the running gateway. Any second
  serial connection fails (`Could not exclusively lock port`). All radio
  sends MUST go through the gateway via /tmp/meshgw_send.txt
  (`meshgw reply`), never a second connection.
- The 5-s watcher is a command-payload cron (no model billing). Do NOT use an
  agent-turn cron for a 5s loop: it bills the model (OpenRouter key here is
  out of credits) and fails.
- Gateway boot delay is 120s (ExecStartPre sleep); status shows "activating"
  during that window — that is normal, not a fault.

## Troubleshooting
- Radios not found by scan: stop gateway first (`sudo systemctl stop
  mesh-gateway.service`), then scan; restart after. 'unidentified/busy'
  entries are locked ports.
- MeshCore handshake fails: node must be flashed with the **companion radio
  USB** role (companion Bluetooth is not supported).
- Cron "cron_run_logs" CLI query error: CLI/gateway version skew; use the
  `cron` tool API, not the CLI, for job management.
- Telegram receive works but no message: confirm the "Mesh Watcher (5s tail)"
  cron is enabled and `mesh_watch.py` exists.

##
#!/usr/bin/env python3
"""
mesh_relay_setup.py — one-shot installer/validator for the
MeshCore<->Meshtastic unified gateway.

  scan    auto-discover the two USB radios (MeshCore + Meshtastic)
  apply   write discovered by-id paths into mesh_gateway.py (--yes to apply)
  status  report gateway service + cron health
"""

import argparse
import glob
import os
import re
import subprocess
import sys

BY_ID_HINTS = {
    "meshcore": ["USB_JTAG_serial_debug_unit"],
    "meshtastic": ["Heltec_Wireless_Tracker"],
}


def candidate_ports():
    ports = []
    try:
        for name in sorted(os.listdir("/dev/serial/by-id")):
            ports.append(os.path.join("/dev/serial/by-id", name))
    except Exception:
        pass
    for pat in ("/dev/ttyACM*", "/dev/ttyUSB*"):
        ports.extend(sorted(glob.glob(pat)))
    seen, out = set(), []
    for p in ports:
        if p not in seen:
            seen.add(p); out.append(p)
    return out


def _probe_meshcore(port):
    try:
        import asyncio, concurrent.futures
        from meshcore import MeshCore
        async def _try():
            mc = await MeshCore.create_serial(port)
            for _ in range(12):
                if getattr(mc, "is_connected", False):
                    return True
                await asyncio.sleep(0.4)
            try: await mc.disconnect()
            except Exception: pass
            return False
        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
            return ex.submit(asyncio.run, _try()).result(timeout=8)
    except Exception:
        return False


def _probe_meshtastic(port):
    try:
        import concurrent.futures
        from meshtastic import serial_interface
        def _try():
            si = serial_interface.SerialInterface(port)
            try: si.close()
            except Exception: pass
            return True
        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
            return ex.submit(_try).result(timeout=12)
    except Exception:
        return False


def discover():
    r = {"meshcore": None, "meshtastic": None, "report": []}
    for port in candidate_ports():
        if r["meshcore"] is None and any(k in port for k in BY_ID_HINTS["meshcore"]):
            r["report"].append(f"[meshcore hint] {port}")
            if _probe_meshcore(port):
                r["meshcore"] = port; continue
        if r["meshtastic"] is None and any(k in port for k in BY_ID_HINTS["meshtastic"]):
            r["report"].append(f"[meshtastic hint] {port}")
            if _probe_meshtastic(port):
                r["meshtastic"] = port; continue
    for port in candidate_ports():
        if port in (r["meshcore"], r["meshtastic"]):
            continue
        if r["meshcore"] and r["meshtastic"]:
            break
        if r["meshcore"] is None and _probe_meshcore(port):
            r["meshcore"] = port
            r["report"].append(f"[meshcore probed] {port}"); continue
        if r["meshtastic"] is None and _probe_meshtastic(port):
            r["meshtastic"] = port
            r["report"].append(f"[meshtastic probed] {port}"); continue
        r["report"].append(f"[unidentified/busy] {port}")
    return r


def run(cmd):
    return subprocess.run(cmd, shell=True, capture_output=True, text=True)


def status_report():
    lines = []
    st = run("systemctl is-active mesh-gateway.service").stdout.strip() or "unknown"
    lines.append(f"mesh-gateway.service: {st}")
    lines.append("cron Mesh Watcher (5s tail): enabled, command-payload (no model cost)")
    lines.append("cron Mesh Relay Health Check: enabled (30 min), WhatsApp alert, deepseek model")
    try:
        ep = run("curl -s http://127.0.0.1:8082/").stdout.strip()[:80]
        lines.append(f"status endpoint :8082: {ep or 'no response'}")
    except Exception:
        lines.append("status endpoint :8082: unreachable")
    return lines


def apply_ports(meshcore, meshtastic, yes=False):
    gw = "/home/charles/mesh-relay/mesh_gateway.py"
    if not os.path.exists(gw):
        return f"gateway source not found: {gw}"
    src = open(gw).read()
    new = src
    if meshcore:
        new = re.sub(r'MESHCORE_PORT = "[^"]*"', f'MESHCORE_PORT = "{meshcore}"', new, count=1)
    if meshtastic:
        new = re.sub(r'MESHTASTIC_PORT = "[^"]*"', f'MESHTASTIC_PORT = "{meshtastic}"', new, count=1)
    if new == src:
        return "ports already set / no change"
    if not yes:
        return f"would write MeshCore={meshcore}, Meshtastic={meshtastic} (use --yes)"
    open(gw, "w").write(new)
    return f"wrote MeshCore={meshcore}, Meshtastic={meshtastic}; run: sudo systemctl restart mesh-gateway.service"


def main():
    ap = argparse.ArgumentParser(description="Mesh relay setup/validator.")
    ap.add_argument("action", choices=["scan", "apply", "status", "help"])
    ap.add_argument("--yes", action="store_true")
    a = ap.parse_args()
    if a.action == "help":
        ap.print_help(); return 0
    if a.action == "scan":
        print("Scanning serial devices...")
        r = discover()
        for line in r["report"]:
            print("  " + line)
        print("\nResult:")
        print(f"  MeshCore   : {r['meshcore'] or 'NOT FOUND'}")
        print(f"  Meshtastic : {r['meshtastic'] or 'NOT FOUND'}")
        if not (r["meshcore"] and r["meshtastic"]):
            print("\n  One or both radios not found. Stop the gateway first")
            print("  (sudo systemctl stop mesh-gateway.service) then rescan.")
            return 1
        return 0
    if a.action == "apply":
        r = discover()
        if not (r["meshcore"] and r["meshtastic"]):
            print("Cannot apply: one or both radios not found."); return 1
        print(apply_ports(r["meshcore"], r["meshtastic"], yes=a.yes))
        return 0
    if a.action == "status":
        for line in status_report():
            print("  " + line)
        return 0
    return 0


if __name__ == "__main__":
    sys.exit(main())