Files
intercept/docs/specs/2026-05-10-meshcore-design.md
T
James Smith 2b9665c723 docs: add Meshcore integration design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:22:07 +01:00

9.3 KiB

Meshcore Support — Design Spec

Date: 2026-05-10
Status: Approved

Overview

Add a Meshcore mode to Intercept, providing full feature parity with the existing Meshtastic module. Meshcore is a LoRa mesh radio platform using a repeater-based routing model (dedicated infrastructure nodes relay; clients do not). It has an official Python library (meshcore, PyPI) and a published companion protocol.

Decisions

Decision Choice Rationale
Connection methods USB serial + TCP + BLE Maximum hardware flexibility
Feature scope Full parity with Meshtastic Messages, node map, telemetry, traceroute, repeater management
Async integration Background asyncio thread meshcore library is asyncio-based; this isolates it cleanly from Flask/gevent
UI layout Messages-first (mirror Meshtastic) Sidebar: contacts/nodes. Center: message feed. Tabs: map, telemetry, repeaters
BLE in Docker Document limitation + proxy workaround BLE unavailable in containers; meshcore-proxy bridges BLE → TCP

Architecture

New Files

utils/meshcore.py              # MeshcoreClient singleton + dataclasses
utils/meshcore_client.py       # Thin async wrapper around meshcore library (lives in asyncio thread)
routes/meshcore.py             # Flask blueprint (/meshcore)
static/js/modes/meshcore.js    # Frontend IIFE module
static/css/modes/meshcore.css  # Scoped styles
templates/partials/modes/meshcore.html  # Sidebar partial
tests/test_meshcore_client.py
tests/test_meshcore_routes.py
tests/test_meshcore_integration.py

Modified Files

  • routes/__init__.py — import + register_blueprint(meshcore_bp)
  • templates/index.html — ~12 insertion points (CSS, partial, JS, validModes, modeGroups, etc.)
  • requirements.txt — add meshcore>=1.0.0 (optional dep, graceful fallback if absent)
  • .gitignore — already has .superpowers/

Async Bridge Pattern

meshcore library (asyncio event loop in daemon OS thread)
  → event callbacks (_on_message, _on_node_update, _on_telemetry)
  → asyncio.run_coroutine_threadsafe() → queue.Queue (thread-safe, max 500)
  → /meshcore/stream SSE generator drains queue (30s keepalive timeout)
  → Frontend EventSource routes by event type

This is the same conceptual pattern as all other decoder integrations in Intercept (ADS-B socket reader, AIS-catcher output thread, rtl_433 stdout thread), just with an explicit asyncio loop instead of a subprocess thread.

Data Model

@dataclass
class MeshcoreMessage:
    id: str
    sender_id: str
    recipient_id: str       # node ID or broadcast address
    text: str
    timestamp: datetime
    hop_count: int
    snr: float | None
    is_direct: bool         # DM vs broadcast
    pending: bool = False   # optimistic send state

@dataclass
class MeshcoreNode:
    node_id: str
    name: str
    is_repeater: bool       # key Meshcore distinction — rendered differently on map
    lat: float | None
    lon: float | None
    battery_pct: int | None
    last_seen: datetime
    snr: float | None
    hops_away: int | None

@dataclass
class MeshcoreContact:
    node_id: str
    name: str
    public_key: str         # Meshcore uses key-based addressing
    last_msg: datetime | None

@dataclass
class MeshcoreTelemetry:
    node_id: str
    timestamp: datetime
    battery_pct: int | None
    voltage: float | None
    temperature: float | None
    humidity: float | None
    uptime_secs: int | None

@dataclass
class MeshcoreTraceroute:
    origin_id: str
    destination_id: str
    hops: list[str]
    snr_per_hop: list[float]
    timestamp: datetime

@dataclass
class SerialConfig:
    port: str | None = None   # None = auto-discover
    baud: int = 115200

@dataclass
class TCPConfig:
    host: str = "localhost"
    port: int = 5000          # meshcore-proxy default

@dataclass
class BLEConfig:
    device_address: str | None = None  # None = scan for first Meshcore device

ConnectionConfig = SerialConfig | TCPConfig | BLEConfig

Connection state enum: DISCONNECTED | CONNECTING | CONNECTED | ERROR

Connection Handling

Serial

Auto-discover: scan /dev/ttyUSB*, /dev/ttyACM*, /dev/cu.usbserial* and return list to frontend via GET /meshcore/ports. User can also specify path directly.

TCP

Direct connection to host:port. Primary use case: meshcore-proxy running on the host, exposing a local USB or BLE device over TCP for Docker deployments.

BLE

  • Linux/RPi: meshcore library uses BlueZ (requires bluetoothctl accessible)
  • macOS: meshcore library uses CoreBluetooth
  • Docker: detect via presence of /.dockerenv or INTERCEPT_DOCKER=1 env var; connect attempt fails fast with clear error directing user to meshcore-proxy

GET /meshcore/ble/scan returns: [{"address": "AA:BB:CC:DD:EE:FF", "name": "MeshCore-Node1", "rssi": -72}]

Reconnect

Exponential backoff: 3 retries at 5s, 15s, 45s (cap 60s). On final failure, pushes status SSE event with state: "error". User can manually retry via POST /meshcore/connect.

API Endpoints

Method Path Description
GET /meshcore/status Connection state + transport info
POST /meshcore/connect Connect with SerialConfig, TCPConfig, or BLEConfig
POST /meshcore/disconnect Disconnect and stop background thread
GET /meshcore/ports List available serial ports
GET /meshcore/ble/scan Scan for nearby Meshcore BLE devices
GET /meshcore/stream SSE stream (messages, nodes, telemetry, status)
GET /meshcore/messages Recent messages (last 500)
POST /meshcore/send Send text message
GET /meshcore/nodes All known nodes
GET /meshcore/contacts Contact list
POST /meshcore/contacts Add contact
DELETE /meshcore/contacts/<id> Remove contact
GET /meshcore/telemetry/<node_id> Telemetry history for node
POST /meshcore/traceroute Request traceroute to node
GET /meshcore/repeaters List repeater nodes

SSE Event Format

{"type": "message",    "data": { ...MeshcoreMessage }}
{"type": "node",       "data": { ...MeshcoreNode }}
{"type": "telemetry",  "data": { ...MeshcoreTelemetry }}
{"type": "traceroute", "data": { ...MeshcoreTraceroute }}
{"type": "status",     "data": {"state": "connected", "transport": "serial", "device": "/dev/ttyUSB0"}}

Keepalive comment (: keepalive) sent every 30 seconds on idle.

Frontend (meshcore.js)

IIFE pattern, same as all other Intercept JS modules. Key responsibilities:

  • SSE consumerEventSource('/meshcore/stream'), routes events by type
  • Message feed — append to scrolling list, optimistic pending state on send
  • Sidebar — contact list + node list; repeaters shown separately with triangle icon (vs circle for client nodes), matching Meshcore UI conventions
  • Tabs — Map (Leaflet, reuse existing map setup pattern), Telemetry (Chart.js, reuse existing chart helpers), Repeaters (dedicated table view)
  • Connection panel — transport selector (Serial / TCP / BLE), port/IP/address input, connect/disconnect button
  • Traceroute modal — hop diagram with SNR annotations, same visual style as Meshtastic traceroute

Repeater Management

Meshcore repeaters are a first-class concept (unlike Meshtastic where all nodes relay). Design:

  • Repeaters identified by is_repeater: true on MeshcoreNode
  • Rendered on map as orange triangles (client nodes = blue circles)
  • Dedicated "Repeaters" tab in the main panel showing: name, location, uptime, last seen, hop count
  • Repeater stats surfaced in telemetry if available (uptime_secs from MeshcoreTelemetry)

Error Handling

  • meshcore library not installed → mode loads but shows "meshcore package required: pip install meshcore"
  • BLE in Docker → clear error: "BLE unavailable in Docker. Run meshcore-proxy on the host and connect via TCP."
  • Serial port not found → return available ports list in error response
  • Connection lost mid-session → automatic reconnect with backoff; SSE status event updates UI indicator
  • Send failure → SSE event clears pending state, shows error in message feed

Testing

tests/test_meshcore_client.py

  • Connection state machine transitions
  • Reconnect backoff timing (mock asyncio loop)
  • Message parsing and queue feeding
  • Node/contact TTL expiry
  • BLE unavailability error (Docker scenario)

tests/test_meshcore_routes.py

  • All REST endpoints: correct JSON shape, status codes
  • /meshcore/connect with each connection config type
  • /meshcore/send with missing/invalid params → 400
  • SSE stream yields keepalive on empty queue
  • Input validation via utils/validation.py

tests/test_meshcore_integration.py

  • Mock meshcore library at boundary (same approach as mocking meshtastic SDK)
  • Full round-trip: connect → receive message event → appears in SSE stream
  • Traceroute request → hop structure correctly parsed

Dependencies

meshcore>=1.0.0   # optional — graceful degradation if absent

No new frontend dependencies — Leaflet and Chart.js already present.

Reference