Cerebus83 icon

MC:MT Bridge Skill USB

Cerebus83 | PRO | 08/11/26 03:51:39 PM UTC (Edited) | 0 ⭐ | 16120 👁️ | Never ⏰ | [python, LoRA, meshtastic, Meshcore, Skill.md, setup.py]
Bash |

15.14 KB

|

None

|

0 👍

/

0 👎

---
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).
  (This update replaces the previous 170-line copy with the full 269-line
  live version: fuller docstring, busy-skip safety notes, apply refactor.)
- Gateway source: `/home/charles/mesh-relay/mesh_gateway.py` (owns radios,
  drains events, MessageDedup loop prevention).
 
## 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
   ```
   The status endpoint http://127.0.0.1:8082 also returns `dedup` stats
   (tracked / window_s / hold_s / max_tracked).
 
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: <text>` / `mc: <text>` / `both: <text>` (or
     `mt mc: <text>`) to the agent -> agent runs
     `meshgw reply {mc|mt|both} "<text>"` -> gateway transmits.
   - Dedup: send the same sender+text on the other network within 3 min ->
     the duplicate is suppressed (log shows `[dedup] suppressed repeat`).
 
## 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.
 
## Relay-loop prevention (MessageDedup)
The gateway rejects inbound messages that repeat a recent (sender, text) to
stop MT<->MC relay loops:
- window_s = 180 (3 min): a repeat inside this window is dropped.
- hold_s = 120 (2 min): entries are retained (evicted) on this age.
- max_tracked = 30: only the last 30 messages are tracked (bounded).
- PERSISTENT: state in /tmp/meshgw_dedup.json, so a gateway restart does NOT
  forget recent history (avoids loops across restarts).
- Keyed on sender+text (mesh-wide), not just text or node — different
  messages with the same text are NOT over-dropped.
Config knobs are the LOOP_WINDOW_S / HOLD_S / MAX_TRACKED constants at the
top of mesh_gateway.py; the skill's `status`/endpoint surfaces the live
values.
 
## 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.
- After editing mesh_gateway.py, `systemctl restart` re-runs the 120s delay.
 
## 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 (built 2026-08-11 for Charles Anaman,
Waali Wireless Wombats).
 
What it does:
  1. AUTO-DISCOVER the two USB radios:
       - MeshCore node (must answer the meshcore serial companion handshake)
       - Meshtastic radio (must answer meshtastic-python over serial)
     It probes every /dev/ttyACM* and /dev/ttyUSB* (and /dev/serial/by-id/*)
     with a bounded read-only handshake, never holding a port that another
     service already owns (skips locked ports instead of failing).
  2. Prints a clear report of what was found (device, protocol, stable by-id path).
  3. (Optional) --apply writes the discovered port paths into the gateway
     source / systemd unit as a patch, and reports the systemd units + cron
     jobs that make up the "final solution" we built today.
 
The final solution it documents/installs:
  - mesh-gateway.service  : owns BOTH radios; drains MeshCore+Meshtastic to
                            /tmp/meshgw_events.jsonl; status endpoint :8082;
                            polls /tmp/meshgw_send.txt for outbound sends;
                            2-min boot delay; auto-restart.
  - "Mesh Watcher (5s tail)" cron : command payload (NO model cost) that
                            announces new mesh messages to Telegram.
  - "Mesh Relay Health Check" cron : 30-min, checks gateway, WhatsApp alert.
  - Reply path: `meshgw reply {mc|mt|both} "text"` (admin/agent relays).
 
Usage:
  python3 mesh_relay_setup.py scan             # discover + report (read-only)
  python3 mesh_relay_setup.py apply [--yes]    # write discovered paths into gateway
  python3 mesh_relay_setup.py status           # report current service/cron health
  python3 mesh_relay_setup.py --help
"""
 
import argparse
import json
import os
import re
import subprocess
import sys
import time
 
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
 
BY_ID_PATTERNS = {
    "meshcore": [
        "USB_JTAG_serial_debug_unit",   # ESP32 companion (our Armaros)
    ],
    "meshtastic": [
        "Heltec_Wireless_Tracker",      # our Meshtastic node
    ],
}
 
 
def candidate_ports():
    """Yield all plausible serial device paths, stable by-id first."""
    ports = []
    try:
        byid = sorted(os.listdir("/dev/serial/by-id"))
        for name in byid:
            ports.append(os.path.join("/dev/serial/by-id", name))
    except Exception:
        pass
    for dev in ("/dev/ttyACM*", "/dev/ttyUSB*"):
        import glob
        ports.extend(sorted(glob.glob(dev)))
    # de-dupe preserving order
    seen = set()
    out = []
    for p in ports:
        if p not in seen:
            seen.add(p)
            out.append(p)
    return out
 
 
def _probe_meshcore(port):
    """Return True if `port` answers the MeshCore companion handshake."""
    # We must NOT open a port that another process holds (mesh-gateway has the
    # serial lock). Try a non-blocking open; if it raises, it's busy -> skip.
    try:
        import asyncio
        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
 
        # run with a hard wall-clock bound so a hung port can't stall discovery
        import concurrent.futures
        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
            fut = ex.submit(asyncio.run, _try())
            return fut.result(timeout=8)
    except Exception:
        return False
 
 
def _probe_meshtastic(port):
    """Return True if `port` answers meshtastic-python's serial info request."""
    try:
        import concurrent.futures
        import asyncio
        from meshtastic import serial_interface
 
        def _try():
            si = serial_interface.SerialInterface(port)
            ok = bool(getattr(si, "nodes", None)) or True
            try:
                si.close()
            except Exception:
                pass
            return ok
 
        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
            fut = ex.submit(_try)
            return fut.result(timeout=12)
    except Exception:
        return False
 
 
def discover():
    """Return {meshcore: port|None, meshtastic: port|None, report_lines: []}."""
    result = {"meshcore": None, "meshtastic": None, "report": []}
 
    for port in candidate_ports():
        # decide by stable by-id hint first (fast, no probe)
        if result["meshcore"] is None:
            if any(k in port for k in BY_ID_PATTERNS["meshcore"]):
                result["report"].append(f"[meshcore hint] {port}")
                if _probe_meshcore(port):
                    result["meshcore"] = port
                    continue
        if result["meshtastic"] is None:
            if any(k in port for k in BY_ID_PATTERNS["meshtastic"]):
                result["report"].append(f"[meshtastic hint] {port}")
                if _probe_meshtastic(port):
                    result["meshtastic"] = port
                    continue
 
    # full probe pass for anything still unidentified
    for port in candidate_ports():
        if port in (result["meshcore"], result["meshtastic"]):
            continue
        # skip ports we already decided as hints
        if result["meshcore"] and result["meshtastic"]:
            break
        if result["meshcore"] is None and _probe_meshcore(port):
            result["meshcore"] = port
            result["report"].append(f"[meshcore probed] {port}")
            continue
        if result["meshtastic"] is None and _probe_meshtastic(port):
            result["meshtastic"] = port
            result["report"].append(f"[meshtastic probed] {port}")
            continue
        result["report"].append(f"[unidentified/busy] {port}")
 
    return result
 
 
# ---------------------------------------------------------------------------
# Status / apply
# ---------------------------------------------------------------------------
 
def run(cmd):
    return subprocess.run(cmd, shell=True, capture_output=True, text=True)
 
 
def status_report():
    lines = []
    lines.append(f"mesh-gateway.service: {run('systemctl is-active mesh-gateway.service').stdout.strip() or 'unknown'}")
    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:
        lines.append(f"status endpoint:200 : {run('curl -s http://127.0.0.1:8082/').stdout.strip()[:80]}")
    except Exception:
        lines.append("status endpoint: unreachable")
    return lines
 
 
def apply_ports(meshcore, meshtastic, yes=False):
    """Patch the discovered ports into mesh_gateway.py's config constants."""
    gw_src = "/home/charles/mesh-relay/mesh_gateway.py"
    if not os.path.exists(gw_src):
        return f"gateway source not found: {gw_src}"
    with open(gw_src) as f:
        src = f.read()
    new_src = src
    if meshcore:
        new_src = re.sub(
            r'MESHCORE_PORT = "[^"]*"',
            f'MESHCORE_PORT = "{meshcore}"',
            new_src, count=1,
        )
    if meshtastic:
        new_src = re.sub(
            r'MESHTASTIC_PORT = "[^"]*"',
            f'MESHTASTIC_PORT = "{meshtastic}"',
            new_src, count=1,
        )
    if new_src == src:
        return "ports already set / no change needed"
    if not yes:
        return f"would write:\n  MESHCORE -> {meshcore}\n  MESHTASTIC -> {meshtastic}\n(use --yes to apply)"
    with open(gw_src, "w") as f:
        f.write(new_src)
    return f"wrote gateway ports: MESHCORE={meshcore}, MESHTASTIC={meshtastic}\n(re-run deploy: sudo systemctl restart mesh-gateway.service)"
 
 
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
 
def main():
    ap = argparse.ArgumentParser(description="Mesh Relay setup/validator (MeshCore+Meshtastic).")
    ap.add_argument("action", choices=["scan", "apply", "status", "help"])
    ap.add_argument("--yes", action="store_true", help="apply without prompting")
    args = ap.parse_args()
 
    if args.action == "help":
        ap.print_help()
        return 0
 
    if args.action == "scan":
        print("Scanning serial devices...")
        r = discover()
        print("\nDiscovery report:")
        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"] or not r["meshtastic"]:
            print("\n  One or both radios not found. Check cables/ports and that")
            print("  the mesh-gateway.service is STOPPED (it holds the serial lock).")
            return 1
        return 0
 
    if args.action == "apply":
        r = discover()
        if not r["meshcore"] or not r["meshtastic"]:
            print("Cannot apply: one or both radios not found.")
            return 1
        print(apply_ports(r["meshcore"], r["meshtastic"], yes=args.yes))
        return 0
 
    if args.action == "status":
        for line in status_report():
            print("  " + line)
        return 0
 
    return 0
 
 
if __name__ == "__main__":
    sys.exit(main())

Comments