diff --git a/docs/specs/2026-05-10-meshcore-design.md b/docs/specs/2026-05-10-meshcore-design.md new file mode 100644 index 0000000..8e713e6 --- /dev/null +++ b/docs/specs/2026-05-10-meshcore-design.md @@ -0,0 +1,238 @@ +# 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 + +```python +@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/`` | Remove contact | +| GET | /meshcore/telemetry/`` | Telemetry history for node | +| POST | /meshcore/traceroute | Request traceroute to node | +| GET | /meshcore/repeaters | List repeater nodes | + +## SSE Event Format + +```json +{"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 consumer** — `EventSource('/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 + +- Meshcore Python library: https://github.com/meshcore-dev/meshcore_py +- Companion protocol: https://docs.meshcore.io/companion_protocol/ +- meshcore-proxy (BLE/serial → TCP bridge): https://github.com/rgregg/meshcore-proxy +- Existing Meshtastic implementation (reference): `utils/meshtastic.py`, `routes/meshtastic.py`