--- 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: ` / `mc: ` / `both: ` (or `mt mc: `) to the agent -> agent runs `meshgw reply {mc|mt|both} ""` -> 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())