Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- ---
- 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())
Advertisement