13 KiB
RemoteTerm for MeshCore
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over serial and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
For detailed component documentation, see:
app/CLAUDE.md- Backend (FastAPI, database, radio connection, packet decryption)frontend/CLAUDE.md- Frontend (React, state management, WebSocket, components)
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ CrackerPanel (global collapsible, WebGPU cracking) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ useWebSocket ←──── Real-time updates │
│ │ │
│ api.ts ←──── REST API calls │
└───────────────────────────┼──────────────────────────────────────┘
│ HTTP + WebSocket (/api/*)
┌───────────────────────────┼──────────────────────────────────────┐
│ Backend (FastAPI) │
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
│ ↓ └───────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ RadioManager + Event Handlers │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────┼──────────────────────────────────────┘
│ Serial
┌──────┴──────┐
│ MeshCore │
│ Radio │
└─────────────┘
Key Design Principles
- Store-and-serve: Backend stores all packets even when no client is connected
- Parallel storage: Messages stored both decrypted (when possible) and as raw packets
- Extended capacity: Server stores contacts/channels beyond radio limits (~350 contacts, ~40 channels)
- Real-time updates: WebSocket pushes events; REST for actions
- Offline-capable: Radio operates independently; server syncs when connected
- Auto-reconnect: Background monitor detects disconnection and attempts reconnection
Data Flow
Incoming Messages
- Radio receives message → MeshCore library emits event
event_handlers.pycatches event → stores in databasews_managerbroadcasts to connected clients- Frontend
useWebSocketreceives → updates React state
Outgoing Messages
- User types message → clicks send
api.sendChannelMessage()→ POST to backend- Backend calls
radio_manager.meshcore.commands.send_chan_msg() - Message stored in database with
outgoing=true - For direct messages: ACK tracked; for channel: repeat detection
ACK and Repeat Detection
Direct messages: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
Channel messages: Flood messages echo back. The decoder identifies repeats by matching (channel_idx, text_hash, timestamp ±5s) and marks the original as "acked".
Directory Structure
.
├── app/ # FastAPI backend
│ ├── CLAUDE.md # Backend documentation
│ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints
│ ├── repository.py # Database CRUD
│ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption
│ └── websocket.py # Real-time broadcasts
├── frontend/ # React frontend
│ ├── CLAUDE.md # Frontend documentation
│ ├── src/
│ │ ├── App.tsx # Main component
│ │ ├── api.ts # REST client
│ │ ├── useWebSocket.ts # WebSocket hook
│ │ └── components/
│ │ ├── CrackerPanel.tsx # WebGPU key cracking
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ...
│ └── vite.config.ts
├── references/meshcore_py/ # MeshCore Python library
├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime)
├── integration_test.html # Browser-based API tests
└── pyproject.toml # Python dependencies
Development Setup
Backend
# Install dependencies
uv sync
# Run server (auto-detects radio)
uv run uvicorn app.main:app --reload
# Or specify port
MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
Frontend
cd frontend
npm install
npm run dev # http://localhost:5173, proxies /api to :8000
Both Together (Development)
Terminal 1: uv run uvicorn app.main:app --reload
Terminal 2: cd frontend && npm run dev
Production
In production, the FastAPI backend serves the compiled frontend:
cd frontend && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
Access at http://localhost:8000. All API routes are prefixed with /api.
Testing
Backend (pytest)
PYTHONPATH=. uv run pytest tests/ -v
Key test files:
tests/test_decoder.py- Channel + direct message decryption, key exchangetests/test_keystore.py- Ephemeral key storetests/test_event_handlers.py- ACK tracking, repeat detectiontests/test_api.py- API endpoints, read state trackingtests/test_migrations.py- Database migration system
Frontend (Vitest)
cd frontend
npm run test:run
Integration Tests
Open integration_test.html in a browser with the backend running.
Before Completing Changes
Always run both backend and frontend validation before finishing any changes:
# From project root - run backend tests
PYTHONPATH=. uv run pytest tests/ -v
# From project root - run frontend tests and build
cd frontend && npm run test:run && npm run build
This catches:
- Type mismatches between frontend and backend (e.g., missing fields in TypeScript interfaces)
- Breaking changes to shared types or API contracts
- Runtime errors that only surface during compilation
API Summary
All endpoints are prefixed with /api (e.g., /api/health).
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/health |
Connection status |
| GET | /api/radio/config |
Radio configuration |
| PATCH | /api/radio/config |
Update name, location, radio params |
| POST | /api/radio/advertise |
Send advertisement |
| POST | /api/radio/reconnect |
Manual radio reconnection |
| POST | /api/radio/enable-server-decryption |
Export private key, enable decryption |
| GET | /api/radio/decryption-status |
Check if decryption enabled |
| GET | /api/contacts |
List contacts |
| POST | /api/contacts/sync |
Pull from radio |
| POST | /api/contacts/{key}/telemetry |
Request telemetry from repeater |
| POST | /api/contacts/{key}/command |
Send CLI command to repeater |
| GET | /api/channels |
List channels |
| POST | /api/channels |
Create channel |
| GET | /api/messages |
List with filters |
| POST | /api/messages/direct |
Send direct message |
| POST | /api/messages/channel |
Send channel message |
| POST | /api/packets/decrypt/historical |
Decrypt stored packets |
| GET | /api/packets/decrypt/progress |
Get historical decryption progress |
| POST | /api/packets/maintenance |
Delete old packets (cleanup) |
| POST | /api/packets/dedup |
Remove duplicate raw packets |
| GET | /api/packets/dedup/progress |
Get deduplication progress |
| POST | /api/contacts/{key}/mark-read |
Mark contact conversation as read |
| POST | /api/channels/{key}/mark-read |
Mark channel as read |
| POST | /api/read-state/mark-all-read |
Mark all conversations as read |
| GET | /api/settings |
Get app settings |
| PATCH | /api/settings |
Update app settings |
| WS | /api/ws |
Real-time updates |
Key Concepts
Contact Public Keys
- Full key: 64-character hex string
- Prefix: 12-character hex (used for matching)
- Lookups use
LIKE 'prefix%'for matching
Contact Types
0- Unknown1- Client (regular node)2- Repeater3- Room
Channel Keys
- Stored as 32-character hex string (TEXT PRIMARY KEY)
- Hashtag channels:
SHA256("#name")[:16]converted to hex - Custom channels: User-provided or generated
Message Types
PRIV- Direct messagesCHAN- Channel messages- Both use
conversation_key(user pubkey for PRIV, channel key for CHAN)
Read State Tracking
Read state (last_read_at) is tracked server-side for consistency across devices:
- Stored as Unix timestamp in
contacts.last_read_atandchannels.last_read_at - Updated via
POST /api/contacts/{key}/mark-readandPOST /api/channels/{key}/mark-read - Bulk update via
POST /api/read-state/mark-all-read - Frontend compares
last_read_atwith messagereceived_atto count unreads
State Tracking Keys (Frontend): Generated by getStateKey() for message times (sidebar sorting):
- Channels:
channel-{channel_key} - Contacts:
contact-{12-char-pubkey-prefix}
Note: These are NOT the same as Message.conversation_key (the database field).
Server-Side Decryption
The server can decrypt historical packets if given the necessary keys:
Channel messages: Decrypted automatically using stored channel keys.
Direct messages: Requires the node's private key, which must be:
- Exported from radio via
POST /radio/enable-server-decryption - Stored only in memory (never persisted to disk)
- Re-exported after every server restart
This allows decrypting messages from contacts whose public keys were learned after the message was received.
MeshCore Library
The meshcore_py library provides radio communication. Key patterns:
# Connection
mc = await MeshCore.create_serial(port="/dev/ttyUSB0")
# Commands
await mc.commands.send_msg(dst, msg)
await mc.commands.send_chan_msg(channel_idx, msg)
await mc.commands.get_contacts()
await mc.commands.set_channel(idx, name, key)
# Events
mc.subscribe(EventType.CONTACT_MSG_RECV, handler)
mc.subscribe(EventType.CHANNEL_MSG_RECV, handler)
mc.subscribe(EventType.ACK, handler)
Environment Variables
| Variable | Default | Description |
|---|---|---|
MESHCORE_SERIAL_PORT |
auto-detect | Serial port for radio |
MESHCORE_DATABASE_PATH |
data/meshcore.db |
SQLite database location |
MESHCORE_MAX_RADIO_CONTACTS |
200 |
Max recent contacts to keep on radio for DM ACKs |