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]
---
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