A pure BLE-to-JSON bridge for Meshtastic radios. Connects to N radios simultaneously over BLE and exposes a unified JSON REST API, WebSocket event stream, MCP tool server, and Meshtastic TCP gateway. Consumers (dashboard servers, logging tools, automation, AI agents) never see protobuf.
Multi-device BLE bridging is the core feature — most existing Meshtastic bridges connect to one device at a time.
The BLE connection handling (core/ble_handler.py, core/stats.py) is adapted from Yeraze/meshtastic-ble-bridge.
This bridge:
- Connects to Meshtastic radios over BLE (multi-device)
- Streams decoded
FromRadiopackets as JSON over WebSocket - Accepts outbound packets (send text, admin messages)
- Proxies MQTT traffic on behalf of radios configured for
proxy_to_client_enabled - Publishes mesh events to an external MQTT broker (optional)
- Provides filtered node queries
- Exposes all methods as MCP tools over streamable HTTP transport
- Caches recent text messages and replays them to new WebSocket clients (optional)
- Bridges each BLE device to a Meshtastic TCP gateway port (optional)
- Runs a background Claude AI daemon that responds to
@claudetrigger words over the mesh
It does not contain: dashboard UI, rotator logic, radar, node history, or any consuming-application logic. Those belong in a separate dashboard server.
| Endpoint | Description |
|---|---|
GET /help |
API reference |
GET /status |
Server status and device list |
GET /devices |
Connected device list |
POST /devices |
Connect a new BLE device {address, pin?, tcp_port?} |
DELETE /devices/{node_id} |
Disconnect a device |
PATCH /ble_devices/{address} |
Update per-device config fields (auto_connect, tcp_port) |
POST /reload |
Reload bridge_config.yaml without restarting (also triggered by SIGHUP) |
GET /nodes |
Merged node list across all bridges (query params below) |
GET /ble/scan |
Scan for nearby Meshtastic BLE devices |
POST /ble/pair |
Start dynamic-PIN pairing |
POST /ble/passkey |
Supply PIN for dynamic-PIN pairing |
GET /mqtt_publish |
Get MQTT publisher config |
PUT /mqtt_publish |
Update MQTT publisher config |
GET /mqtt_publish/status |
MQTT publisher connection status |
WS /events |
Unified event stream from all devices (tagged with device). Replays cached text messages on connect if message_cache.enabled. |
GET /sections |
Available config section names |
GET /schema/{section} |
JSON schema for a config section |
| Endpoint | Description |
|---|---|
GET /status |
BLE state, node count, MQTT status |
GET /info |
my_info + device metadata |
GET /nodes |
NodeDB with optional filters |
GET /nodes/{num} |
Single node |
GET /channels |
Channel list |
GET /channels/{index} |
Single channel (live admin read) |
PUT /channels/{index} |
Update a channel |
GET /config |
Cached config + module_config |
GET /config/{section} |
Live admin read of a config section |
PUT /config/{section} |
Write a config section |
GET /owner |
Device owner (live) |
PUT /owner |
Set device owner |
GET /fixed_position |
Fixed position |
PUT /fixed_position |
Set fixed position |
DELETE /fixed_position |
Remove fixed position |
POST /messages |
Send text {text, to?, channel?} |
GET /messages |
Recent cached text messages |
POST /admin |
Generic AdminMessage passthrough |
POST /rpc |
JSON-RPC 2.0 method call |
GET /range_test |
Range test log |
DELETE /range_test |
Clear range test log |
WS /events |
Per-device event stream. Replays cached text messages on connect if message_cache.enabled. |
| Endpoint | Description |
|---|---|
POST /ota |
Trigger BLE OTA firmware update for an nRF52 device |
Body: { "ble_addr": "AA:BB:CC:DD:EE:FF", "firmware": "/path/to/firmware.zip", "node_id": "!aabbccdd" }
ble_addrandfirmwareare required;node_idis optional (used only to label WS events)- Returns
{"started": true}immediately — the update runs as a background task - Does not require the bridge to already be connected to the target device over BLE; it opens its own direct BLE connection for DFU
- Implemented via recrof/nrf_dfu_py (Nordic Secure DFU over BLE/bleak)
- Progress is streamed to all
/eventsWebSocket subscribers asota_start→ota_progress→ota_completeorota_error
{"type": "ota_start", "ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "firmware": "firmware.zip"}
{"type": "ota_progress", "ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "data": {"pct": 42}}
{"type": "ota_complete", "ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "data": {...}}
{"type": "ota_error", "ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "data": {"error": "..."}}All /nodes endpoints accept:
| Param | Default | Description |
|---|---|---|
max_age |
0 (off) | Max seconds since last heard |
max_hops |
99 | Max hop count |
named_only |
false | Only nodes with a long_name |
has_position |
false | Only nodes with position |
hide_mqtt |
false | Exclude MQTT-sourced nodes |
has_signal |
false | Only nodes with SNR/RSSI |
has_telemetry |
false | Only nodes with device_metrics |
node_roles |
[] (all) | Filter by role strings e.g. ROUTER, CLIENT |
The bridge exposes all methods as MCP tools over streamable HTTP transport (stateless per-request, no dropped connections):
| Endpoint | Description |
|---|---|
POST /mcp |
Streamable HTTP MCP endpoint |
Tools available: list_devices, connect_device, disconnect_device, get_info, get_nodes, get_status, get_channels, get_config, get_config_live, get_owner_live, get_channel_live, get_fixed_position, set_fixed_position, remove_fixed_position, send_text, set_config, set_owner, set_channel, get_messages, wait_for_message
wait_for_message long-polls the bridge event queue and returns the next TEXT_MESSAGE_APP packet — useful for interactive chat loops and event-driven agents.
Configure in Claude Code (/etc/claude-code/managed-mcp.json or ~/.claude/managed-mcp.json):
{
"mcpServers": {
"mesh-gw": {
"type": "http",
"url": "http://<host>:8001/mcp"
}
}
}The /mt-chat Claude Code skill enables an interactive chat loop over the mesh using MCP tools:
- Invoke
/mt-chatin Claude Code - Claude uses
wait_for_messageto receive incoming texts andsend_textto reply — all directly from the Claude Code session, no extra API account needed. - Replies go only to the message sender (never broadcast to the mesh).
core/claude_daemon.py runs as a background task inside the bridge server. It watches all incoming mesh messages for a configurable trigger word (default: @claude) from trusted node IDs, calls claude -p (Claude Code CLI non-interactively), and sends the reply back as a direct message.
- No separate Anthropic API account or API key required — uses the local Claude Code CLI credentials.
- Replies are per-sender, with conversation history kept per sender.
- Trigger word, system prompt, trusted nodes, and reply length are all configurable in
bridge_config.yaml.
claude_chat:
enabled: true
trigger_word: "@claude"
system_prompt: "You are Claude, accessible via Meshtastic radio. Keep replies concise — this is a low-bandwidth radio link."
max_history: 20
max_reply_length: 200
whitelist: "" # comma-separated !hex node IDs; empty = my_nodes only
my_nodes: "!aabbccdd" # your own node IDs (always allowed to trigger)Each device entry in ble_devices can have a tcp_port. The bridge opens a TCP server on that port implementing the standard Meshtastic StreamAPI framing (0x94 0xc3 magic + 2-byte length). This makes each radio accessible to:
- Meshtastic CLI:
meshtastic --host <host> - Meshtastic Android/iOS app: add TCP connection in app settings
- Any other Meshtastic TCP-capable client
The TCP gateway and REST/WebSocket API operate concurrently on the same radio — a Meshtastic app can be connected on the TCP port while the dashboard, MCP tools, and Claude daemon all continue to operate via the REST/WS API. Packets received over BLE are forwarded to all TCP clients and all WS subscribers simultaneously.
Different radios get different ports (e.g., 4403, 4404). The TCP port is configurable per-device in the dashboard or directly in bridge_config.yaml.
All settings live in core/bridge_config.yaml:
ble_devices:
- address: AA:BB:CC:DD:EE:FF
pin: ""
auto_connect: true # connect automatically on startup
tcp_port: 4403 # optional: Meshtastic TCP gateway port
- address: 11:22:33:44:55:66
pin: "123456"
auto_connect: false
tcp_port: 4404
message_cache:
enabled: false # replay recent text messages to new WS clients
max_messages: 100 # ring buffer size
max_age_seconds: 86400 # discard messages older than this on replay
mqtt_publish:
enabled: false # publish mesh events to an external MQTT broker
broker: localhost
port: 1883
username: ""
password: ""
use_tls: false
topic_prefix: mesh
ha_discovery: false # publish Home Assistant discovery payloads
ha_discovery_prefix: homeassistant
claude_chat:
enabled: false # background Claude AI daemon
trigger_word: "@claude"
system_prompt: "..."
max_history: 20
max_reply_length: 200
whitelist: "" # comma-separated !hex IDs; empty = my_nodes only
my_nodes: "" # your own node IDsConfig changes can be applied without restarting:
systemctl reload mesh-gw # sends SIGHUP
# or
curl -X POST http://localhost:8001/reloadBLE connections are preserved across a reload.
pip install -r requirements.txt
python -m module.main AA:BB:CC:DD:EE:FF 11:22:33:44:55:66 --http-port 8001If a connected radio has moduleConfig.mqtt.enabled and proxy_to_client_enabled set, the bridge automatically connects to the radio's configured broker and relays MQTT traffic (mqttClientProxyMessage) bidirectionally. No bridge configuration needed — broker address, credentials, and root topic all come from the radio's own config.
Separately from the MQTT proxy, the bridge can publish decoded mesh events to any MQTT broker. Configure under mqtt_publish in bridge_config.yaml. Events are published per-device and per-portnum. Set ha_discovery: true to publish Home Assistant discovery payloads for automatic entity creation.
Events on /events (and /{node_id}/events) are JSON objects:
| Type | Description |
|---|---|
packet |
Raw decoded FromRadio packet |
node_info |
Node added or updated in NodeDB |
node_update |
Emitted after every mesh packet — use instead of REST polling for live node state |
status |
BLE connection state change (ble_state, mqtt_proxy, etc.) |
tilt_update |
LIS3DH tilt telemetry decoded from PRIVATE_APP (portnum 256) packets |
ota_start |
OTA flash started — includes ble_addr, device, firmware filename |
ota_progress |
OTA progress — data.pct is 0–100 |
ota_complete |
OTA finished successfully |
ota_error |
OTA failed — data.error contains the reason |
{"type": "packet", "data": {...}, "device": "!aabbccdd"}
{"type": "node_info", "data": {...}, "device": "!aabbccdd"}
{"type": "node_update", "data": {...}, "device": "!aabbccdd"}
{"type": "status", "data": {"ble_state": "ready", ...}, "device": "!aabbccdd"}
{"type": "tilt_update", "data": {"pitch": 1.2, "roll": -0.4, "x": 0.02, "y": -0.01, "z": 0.98}, "device": "!aabbccdd"}
{"type": "ota_start", "ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "firmware": "firmware.zip"}
{"type": "ota_progress","ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "data": {"pct": 42}}
{"type": "ota_complete","ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "data": {...}}
{"type": "ota_error", "ble_addr": "AA:BB:CC:DD:EE:FF", "device": "!aabbccdd", "data": {"error": "..."}}If message_cache.enabled, replayed messages include "_replay": true so clients can distinguish them from live events.
[Meshtastic Radio] <--BLE--> [core/ble_handler.py]
|
[core/bridge.py]
[core/state.py]
|
+-----------+--------------+-----------+-----------+
| | | | |
[module/server.py] | [core/mcp_server.py] | [core/mqtt_publisher.py]
FastAPI, port 8001 | MCP streamable HTTP | external MQTT broker
| | |
REST/WS clients | [core/mqtt_proxy.py]
(dashboard, apps) | radio MQTT proxy
|
[core/tcp_gateway.py] [core/claude_daemon.py]
Meshtastic TCP bridge @claude AI daemon
(per-device port) (claude -p via WS events)
[nRF52 Device] <--BLE (DFU)--> [core/ota.py]
POST /ota → background task
streams ota_progress via /events WS