ESP32MCPServer
Health Pass
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Community trust — 65 GitHub stars
Code Pass
- Code scan — Scanned 5 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Allow AI models connect to ESP32 and read connected sensors NMEA2k, ODB/ODBII, NMEA0183. AI generated MCP server.
ESP32 MCP Server
A Model Context Protocol (MCP) server running on ESP32 (and Nordic nRF52840 in sensor-only mode), exposing real-world sensor data and bus protocols to AI assistants and automation tooling over WebSocket / JSON-RPC 2.0.
Connect an AI agent directly to your hardware: query I2C sensors, parse NMEA 0183 GPS/marine data, decode NMEA 2000 and OBD-II CAN frames, replay bus history — all through a standard MCP interface.

Features
| Capability | Details |
|---|---|
| MCP Protocol | JSON-RPC 2.0 over WebSocket, resource discovery & subscriptions |
| I2C Sensors | Auto-scan + drivers for BME280, MPU6050, ADS1115, SHT31, BH1750, INA219 |
| NMEA 0183 | GGA, RMC, VTG, GSV, MWV, DBT, DPT, HDG, HDT with XOR checksum validation |
| NMEA 2000 / CAN | 29-bit PGNs: position, heading, speed, depth, wind, attitude, water temp |
| OBD-II / CAN | Standard 11-bit OBD-II service 01/09 PID decoding (60+ PIDs) |
| Bus History | Persistent ring buffers for CAN, NMEA, NMEA 2000, OBD-II — queryable via MCP |
| Bus Readers | Time-bounded serial and CAN reads, raw or parsed mode, JSON output |
| Bluetooth (BLE) | BLE central over MCP (ESP32-S3): non-blocking advertising scanner with raw payload history and capture filters, GATT client (connect to public and random addresses, enumerate services, read values, capture notifications) — see Bluetooth (BLE) |
| WiFi | AP setup UI + STA mode; credentials stored in NVS |
| Discovery | mDNS (_mcp._tcp) + UDP capability broadcast |
| Metrics | Heap, uptime, RSSI, histogram stats with boot persistence |
| Multi-board | ESP32-S3, ESP32-C3, ESP32, Adafruit HUZZAH32, M5Stack, Nordic nRF52840-DK, Adafruit Feather nRF52840 |
| Testing | Native unit tests (Unity), mock I2C / CAN / Serial / LittleFS |
Supported Boards
ESP32 Boards — Full MCP Server (WiFi + WebSocket + CAN)
| Board | PlatformIO env | MCU | Flash | RAM | WiFi | BLE | CAN | RGB LED |
|---|---|---|---|---|---|---|---|---|
| ESP32-S3-DevKitC-1 | esp32-s3-devkitc-1 |
Xtensa LX7 × 2 @ 240 MHz | 8 MB | 512 KB (PSRAM not used²) | ✓ | 5.0 | ✓ | GPIO48 |
| ESP32-S3-DevKitC-1 N16R8 / N8R8 | esp32-s3-devkitc-1-n16r8 |
Xtensa LX7 × 2 @ 240 MHz | 8–16 MB | 512 KB + 8 MB octal PSRAM | ✓ | 5.0 | ✓ | GPIO48 |
| ESP32-DevKitC V4 | esp32-devkitc |
Xtensa LX6 × 2 @ 240 MHz | 4 MB | 520 KB | ✓ | ✗¹ | ✓ | — |
| ESP32-C3-DevKitM-1 | esp32-c3-devkitm-1 |
RISC-V @ 160 MHz | 4 MB | 400 KB | ✓ | ✗¹ | ✓ | GPIO8 |
| Adafruit HUZZAH32 | adafruit-huzzah32 |
Xtensa LX6 × 2 @ 240 MHz | 4 MB | 520 KB | ✓ | ✗¹ | ✓ | — |
| M5Stack Core ESP32 | m5stack-core |
Xtensa LX6 × 2 @ 240 MHz | 16 MB | 520 KB | ✓ | ✗¹ | ✗ | — |
¹ 4 MB-flash boards: the BLE stack + the MCP WebSocket server + OTA cannot all fit the 1.25 MB OTA app slot, so BLE is compiled out (the hardware still supports it). BLE is available on the ESP32-S3 (8 MB flash). esp32-devkitc, adafruit-huzzah32 and m5stack-core also use a larger-app partition table (partitions_4mb_ota.csv): upgrading an existing board requires one USB flash and re-uploading the filesystem — see the board pages in docs/boards/ and the CHANGELOG.
² PSRAM mode (octal vs quad) is fixed at build time, so modules with 8 MB octal PSRAM (N8R8/N16R8; esptool.py chip_id reports "Embedded PSRAM 8MB") need the -n16r8 env. Do not flash that env on DevKitC-1 variants without PSRAM or with quad PSRAM (N8R2).
Boot-time memory plan. At startup the firmware checks for PSRAM and divides memory between bus history and BLE capture accordingly (include/MemoryPlan.h; logged as [mem] plan=… on the serial console):
| Detected at boot | Bus history | BLE capture |
|---|---|---|
| PSRAM | budgeted from PSRAM (min of 1 MB and ¼ of free PSRAM) | 64 devices / 48 KB |
| No PSRAM, BLE enabled | internal heap, capped at 96 KB | 24 devices / 16 KB |
| No PSRAM, BLE disabled | internal heap, board cap (BOARD_BUS_HISTORY_HEAP_CAP) |
— |
An explicit ramBudgetBytes in the bus-history config still takes precedence.
nRF52840 Boards — Sensor Only (I2C + Serial, no WiFi)
| Board | PlatformIO env | MCU | Flash | RAM | BLE |
|---|---|---|---|---|---|
| Nordic nRF52840-DK | nrf52840-dk |
Cortex-M4F @ 64 MHz | 1 MB | 256 KB | 5.0 + 802.15.4 |
| Adafruit Feather nRF52840 | nrf52840-feather |
Cortex-M4F @ 64 MHz | 1 MB | 256 KB | 5.0 |
nRF52840 targets run a separate
main_nrf.cppentry point: sensors + I2C + Serial output only. No WiFi, no MCP WebSocket, no LittleFS, no bus history. For full MCP-over-WiFi, bridge via an ESP32-C3 in AT-command mode connected to UART1.
Board Feature Matrix
| Feature | ESP32-S3 | ESP32 V4 | ESP32-C3 | HUZZAH32 | M5Stack | nRF52840-DK | nRF52840-Feather |
|---|---|---|---|---|---|---|---|
| WiFi | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
BLE via MCP (ble/*) |
✓ | ✗¹ | ✗¹ | ✗¹ | ✗¹ | ✗³ | ✗³ |
| CAN / TWAI | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✗ |
| I2C | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| UART (ext) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| LittleFS | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| MCP Server | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| Bus History | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
| mDNS / UDP discovery | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ |
³ The nRF52840 has a BLE radio, but the nRF52 firmware (main_nrf.cpp) does not use it; there is no MCP server on those targets.
Architecture
flowchart TD
Client[AI Agent / MCP Client] -->|WebSocket :9000 JSON-RPC 2.0| MCPServer
subgraph ESP32
MCPServer -->|resources/list| Resources
MCPServer -->|sensors/i2c/scan| SensorMgr
MCPServer -->|sensors/read| SensorMgr
MCPServer -->|buses/serial/read| BusReader
MCPServer -->|buses/can/read| BusReader
MCPServer -->|bus/history/read| BusHistory
MCPServer -->|bus/history/config/*| BusHistory
SensorMgr -->|Wire I2C| I2CSensors[BME280 · MPU6050 · ADS1115\nSHT31 · BH1750 · INA219]
BusReader -->|UART| NMEAParser[NMEA 0183 Parser]
BusReader -->|CAN TWAI| CANParser[NMEA 2000 / OBD-II Parser]
BusReader -->|feed| BusHistory[Bus History\nRing Buffers]
BusHistory --> LittleFS
NetworkMgr[Network Manager] --> WiFi
NetworkMgr --> WebSetup[Setup UI :80]
DiscoveryMgr[Discovery Manager] -->|mDNS + UDP| Network
Metrics[Metrics System] --> LittleFS
uLogger --> LittleFS
end
MCPServer --> Metrics
MCPServer --> uLogger
Hardware
Pin Assignments (ESP32-S3-DevKitC-1)
| GPIO | Function | Connected to |
|---|---|---|
| IO8 | I2C SDA | All I2C sensors |
| IO9 | I2C SCL | All I2C sensors |
| IO16 | UART1 RX | GPS / chart plotter TX |
| IO17 | UART1 TX | GPS / chart plotter RX (optional) |
| IO4 | CAN RX | TJA1050 / SN65HVD230 CRXD |
| IO5 | CAN TX | TJA1050 / SN65HVD230 CTXD |
nRF52840-DK Pin Assignments
| Pin | Function |
|---|---|
| P0.26 / D6 | I2C SDA |
| P0.27 / D7 | I2C SCL |
| P0.06 / TX | UART1 TX |
| P0.08 / RX | UART1 RX |
Bill of Materials
| # | Part | Purpose |
|---|---|---|
| 1 | ESP32-S3-DevKitC-1 | Main MCU |
| 1 | BME280 breakout | Temperature / Humidity / Pressure |
| 1 | MPU6050 breakout | 6-axis IMU (optional) |
| 1 | TJA1050 or SN65HVD230 | CAN bus transceiver (optional) |
| 1 | GPS module (3.3 V UART) | NMEA 0183 position/time (optional) |
| 2 | 4.7 kΩ resistors | I2C pull-ups |
| — | Breadboard + jumpers |
ESP32-S3-DevKitC-1 Wiring
┌──────────────────────────────────────┐
│ ESP32-S3-DevKitC-1 │
│ │
3V3 ───┤ 3V3 GND ├─── GND
GND ───┤ GND IO0 ├
│ │
I2C SDA ── IO8 ───┤ IO8 ┌──────────┐ IO17 ├─── UART1 TX → GPS
I2C SCL ── IO9 ───┤ IO9 │ ESP32 │ IO16 ├─── UART1 RX ← GPS
│ IO10 │ S3 │ IO15 ├
│ IO11 └──────────┘ IO14 ├
│ IO12 IO13 ├
│ IO3 IO4 ├─── CAN RX ← TJA1050
│ IO46 IO5 ├─── CAN TX → TJA1050
│ │
│ USB (flash / serial log) │
└───────────────────────────────┘
I2C Sensor Wiring
All sensors share one bus. Add 4.7 kΩ pull-ups from SDA and SCL to 3V3.
ESP32-S3 BME280 MPU6050 ADS1115 INA219
───────── ─────── ─────── ─────── ───────
IO8 (SDA)─────SDA──────────SDA──────────SDA──────────SDA
IO9 (SCL)─────SCL──────────SCL──────────SCL──────────SCL
3V3 ──────────VCC──────────VCC──────────VCC──────────VCC
GND ──────────GND──────────GND──────────GND──────────GND
SDO→GND AD0→GND ADDR→GND
(0x76) (0x68) (0x48) (0x40)
Other supported sensors (same bus wiring): SHT31 (0x44), BH1750 (0x23).
NMEA 0183 Wiring (GPS / Chart Plotter)
GPS / chart plotter ESP32-S3
─────────────────── ────────
TX (3.3 V TTL NMEA out) ─────── IO16 (UART1 RX)
RX (optional NMEA in) ─────── IO17 (UART1 TX)
GND ─────── GND
VCC (3.3 V) ─────── 3V3
If the device outputs RS-232 levels, use a MAX3232 level shifter. Never connect RS-232 directly to ESP32 GPIO.
CAN Bus Wiring (NMEA 2000 / OBD-II)
TJA1050 / SN65HVD230 ESP32-S3
──────────────────── ────────
CTXD ─────────── IO5 (CAN TX)
CRXD ─────────── IO4 (CAN RX)
VCC (5 V for TJA1050) ─────────── VIN / external 5 V
GND ─────────── GND
CANH ──────────────── NMEA 2000 backbone / OBD-II CANH
CANL ──────────────── NMEA 2000 backbone / OBD-II CANL
NMEA 2000 runs at 250 kbps. OBD-II runs at 500 kbps. Set the baud rate in your
CANBusReaderconfiguration accordingly.
Software Prerequisites
- PlatformIO Core CLI or the PlatformIO IDE plugin for VS Code
- Python 3.8+
- Git
pip install platformio
Build & Flash
# 1. Clone
git clone https://github.com/navado/ESP32MCPServer.git
cd ESP32MCPServer
# 2. Install library dependencies
pio pkg install
# 3. Upload web assets to LittleFS (optional but recommended)
pio run -t uploadfs -e esp32-s3-devkitc-1
# 4. Build firmware
pio run -e esp32-s3-devkitc-1
# 5. Flash
pio run -t upload -e esp32-s3-devkitc-1
# 6. Monitor
pio device monitor --baud 115200
nRF52840
pio run -e nrf52840-dk
pio run -t upload -e nrf52840-dk
First Run
- Power on the board. It broadcasts a WiFi AP:
ESP32_XXXXXX(last 6 hex digits of the MAC address). - Connect to that AP and open
http://192.168.4.1. - Enter your WiFi credentials and click Save.
- The board joins your network and prints its IP address on the serial console.
- Connect an MCP client to
ws://<IP>:9000.
Web Dashboard
A browser-based dashboard is served from flash at http://<IP>/app.
| Tab | What it does |
|---|---|
| Sensors | Scan I²C bus, display detected devices, read values, auto-refresh |
| Metrics | List all metrics, live values, sparkline history chart |
| Logs | Query and clear metric log entries |
| MCP Console | Interactive JSON-RPC console with preset requests and live message log |
HTTP Endpoints (port 80)
| Method | Path | Description |
|---|---|---|
| GET | / |
WiFi setup page |
| POST | /save |
Save WiFi credentials (ssid, password form params) |
| GET | /status |
Network status JSON |
| WS | /ws |
Real-time network status WebSocket |
| GET | /app |
Browser dashboard |
| GET | /bus-history |
Read bus history config JSON |
| POST | /bus-history |
Update bus history config JSON |
MCP API Reference (WebSocket port 9000)
All methods use JSON-RPC 2.0 over WebSocket.
Protocol
| Method | Params | Description |
|---|---|---|
initialize |
— | Server info, protocol version, capabilities |
resources/list |
— | List registered MCP resources |
resources/read |
uri |
Read a resource by URI |
resources/subscribe |
uri |
Subscribe to push updates for a resource |
resources/unsubscribe |
uri |
Cancel a subscription |
Sensors
| Method | Params | Description |
|---|---|---|
sensors/i2c/scan |
— | Scan I²C bus; auto-detect and initialise drivers |
sensors/read |
sensorId? |
Read all channels of one sensor (omit ID to read all) |
sensors/i2c/scan
{"jsonrpc":"2.0","method":"sensors/i2c/scan","id":1}
{
"jsonrpc":"2.0","id":1,
"result":{"devices":[
{"address":118,"name":"BME280","supported":true,"parameters":["temperature","pressure","humidity"]},
{"address":104,"name":"MPU6050","supported":true,"parameters":["accel_x","accel_y","accel_z","gyro_x","gyro_y","gyro_z","temperature"]}
]}
}
sensors/read
{"jsonrpc":"2.0","method":"sensors/read","id":2,"params":{"sensorId":"bme280_0x76"}}
{
"jsonrpc":"2.0","id":2,
"result":{"readings":[
{"sensorId":"bme280_0x76","parameter":"temperature","value":22.4,"unit":"°C","timestamp":1234567890,"valid":true},
{"sensorId":"bme280_0x76","parameter":"humidity","value":58.1,"unit":"%RH","timestamp":1234567890,"valid":true},
{"sensorId":"bme280_0x76","parameter":"pressure","value":101320.0,"unit":"Pa","timestamp":1234567890,"valid":true}
]}
}
Bus History
Bus history accumulates CAN frames, NMEA lines, decoded NMEA 2000 messages, and OBD-II records in circular RAM buffers. The buffers are sized from a configurable RAM budget and their configuration is persisted to LittleFS.
| Method | Params | Description |
|---|---|---|
bus/history/read |
type, limit? |
Read accumulated messages from history buffers |
bus/history/config/get |
— | Read current buffer sizing configuration |
bus/history/config/set |
config fields | Resize buffers and persist configuration |
bus/history/read
type must be one of: can, nmea, nmea2000, obdii. limit defaults to all stored entries.
{"jsonrpc":"2.0","method":"bus/history/read","id":3,"params":{"type":"can","limit":10}}
{
"jsonrpc":"2.0","id":3,
"result":{
"type":"can",
"frames":["7E8#0441050300000000","7E8#04410D0000000000"],
"count":2,
"capacity":500
}
}
{"jsonrpc":"2.0","method":"bus/history/read","id":4,"params":{"type":"nmea2000"}}
{
"jsonrpc":"2.0","id":4,
"result":{
"type":"nmea2000",
"messages":[
{"pgn":129025,"priority":2,"source":0,"name":"Position Rapid Update",
"valid":true,"hasPosition":true,"latitude":47.6097,"longitude":-122.3331}
],
"count":1,"capacity":200
}
}
{"jsonrpc":"2.0","method":"bus/history/read","id":5,"params":{"type":"obdii"}}
{
"jsonrpc":"2.0","id":5,
"result":{
"type":"obdii",
"records":[
{"service":1,"pid":12,"name":"Engine RPM","value":1500,"unit":"rpm","valid":true},
{"service":1,"pid":13,"name":"Vehicle Speed","value":65,"unit":"km/h","valid":true}
],
"count":2,"capacity":300
}
}
bus/history/config/get
{"jsonrpc":"2.0","method":"bus/history/config/get","id":6}
{
"jsonrpc":"2.0","id":6,
"result":{
"canFrameCount":500,
"nmeaLineCount":1000,
"nmea2000Count":200,
"obdiiCount":300,
"ramBudgetBytes":1048576,
"safetyMarginBytes":65536,
"freeHeapBytes":2097152
}
}
bus/history/config/set
All fields are optional; omitted fields retain their current values.
{
"jsonrpc":"2.0","method":"bus/history/config/set","id":7,
"params":{"canFrameCount":1000,"ramBudgetBytes":2097152}
}
{"jsonrpc":"2.0","id":7,"result":{"ok":true,"canFrameCount":1000,"nmeaLineCount":1000,"nmea2000Count":200,"obdiiCount":300,"ramBudgetBytes":2097152,"safetyMarginBytes":65536}}
Set
canFrameCount,nmeaLineCount,nmea2000Count,obdiiCountall to0to enable auto-sizing: the server splitsramBudgetBytesequally among the four buffer types based on the size of each record type.
Metrics
| Method | Params | Description |
|---|---|---|
metrics/list |
category? |
List all registered metrics (name, type, unit, category) |
metrics/get |
name |
Current value of one metric |
metrics/history |
name, seconds? |
Historical samples for one metric (default: last 300 s) |
metrics/list
{"jsonrpc":"2.0","method":"metrics/list","id":8,"params":{"category":"system"}}
{
"jsonrpc":"2.0","id":8,
"result":{"metrics":[
{"name":"system.heap.free","type":"gauge","description":"Free heap","unit":"bytes","category":"system"},
{"name":"system.uptime","type":"counter","description":"Uptime","unit":"s","category":"system"},
{"name":"system.wifi.rssi","type":"gauge","description":"WiFi signal strength","unit":"dBm","category":"system"}
]}
}
Metric types: counter (cumulative integer), gauge (current value), histogram (min/max/mean/count object).
metrics/get
{"jsonrpc":"2.0","method":"metrics/get","id":9,"params":{"name":"system.heap.free"}}
{
"jsonrpc":"2.0","id":9,
"result":{"name":"system.heap.free","type":"gauge","value":214032,"unit":"bytes","category":"system","timestamp":1234567890}
}
For histogram metrics, value is an object: {"mean":…,"min":…,"max":…,"count":…}.
metrics/history
{"jsonrpc":"2.0","method":"metrics/history","id":10,"params":{"name":"system.heap.free","seconds":60}}
{
"jsonrpc":"2.0","id":10,
"result":{"name":"system.heap.free","entries":[
{"ts":1234567800,"val":214100},
{"ts":1234567830,"val":214032}
]}
}
Logs
| Method | Params | Description |
|---|---|---|
logs/query |
name?, seconds? |
Query binary metric log (default: last 3600 s) |
logs/clear |
— | Delete all log entries from LittleFS |
{"jsonrpc":"2.0","method":"logs/query","id":11,"params":{"name":"system.heap.free","seconds":120}}
{
"jsonrpc":"2.0","id":11,
"result":{"name":"system.heap.free","count":4,"entries":[
{"ts":1234567810,"val":214100},
{"ts":1234567840,"val":214032}
]}
}
{"jsonrpc":"2.0","method":"logs/clear","id":12}
{"jsonrpc":"2.0","id":12,"result":{"ok":true}}
Subscriptions
{"jsonrpc":"2.0","method":"resources/subscribe","id":13,"params":{"uri":"sensor://i2c/bme280_0x76"}}
After subscribing the server pushes unsolicited notifications/resources/updated on every value change:
{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"sensor://i2c/bme280_0x76"}}
Bluetooth (BLE)
The ESP32-S3 acts as a BLE central controlled over MCP: it captures what nearby devices broadcast (useful for reverse-engineering advertising formats) and can connect to a device to enumerate and read its GATT database. Every call is non-blocking or time-bounded, so a slow or misbehaving peer can never hang the MCP server.
Supported boards: esp32-s3-devkitc-1 and esp32-s3-devkitc-1-n16r8 (PSRAM). On the 4 MB-flash boards the BLE stack cannot fit alongside the WebSocket server and OTA, so the ble/* methods are not compiled there; the nRF52 targets have no MCP server.
Capabilities
| Area | What it does |
|---|---|
| Advertising scan | Active scan (requests scan responses) for a fixed duration or continuously; per device: MAC, address type, name, RSSI, advertised services, manufacturer data, connectable flag, first/last seen |
| Payload history | Distinct raw payloads per device (up to 64, oldest dropped first) — payload changes reveal state changes in the broadcaster |
| Payload registry | Distinct payloads across all devices, independent of MAC — survives phones and trackers that rotate their MAC address |
| Capture filters | minRssi, manufacturerId, serviceUuid, macPrefix decide what is stored, so the device of interest is not crowded out in a busy RF environment |
| Connect | Asynchronous GATT connect with an enforced timeout; public, random static and resolvable private addresses (address type taken from the scan, or given explicitly); repeatable connect / disconnect / reconnect |
| Enumerate & read | All primary/secondary services and characteristics with properties (up to 512 attributes); every readable value read (hex + printable text, first 64 bytes) |
| Notifications | Subscribes to notify/indicate characteristics and captures the latest value within a bounded window (≤ 10 s) |
| Memory-aware limits | Capture limits follow the boot-time memory plan: 64 devices / 48 KB with PSRAM, 24 devices / 16 KB without; least-recently-heard devices are evicted first |
Not supported (yet): writing characteristics, pairing/bonding (encrypted characteristics will fail to read), more than one connection at a time, BLE 5 extended advertising and Coded PHY (only legacy advertising is received), and acting as a peripheral/advertiser.
Verified on hardware (ESP32-S3-DevKitC-1 N16R8) against an nRF52840 peripheral (random static address), an Android phone (rotating random address) and a BlueZ peripheral (public address): connect median 0.43 s (15/15), 47 connect/disconnect cycles with clean teardown on the peer, full enumeration of a phone (7 services / 37 characteristics) with ≥ 49 KB internal heap free throughout.
| Method | Params | Description |
|---|---|---|
ble/scan |
durationMs?, minRssi?, manufacturerId?, serviceUuid?, macPrefix? |
Start a scan (default 5000 ms; 0 = continuous until ble/scan/stop); returns immediately. If a scan is already running: {started:false, scanning:true, remainingMs}. The optional capture filter decides which devices are stored (AND-combined; manufacturerId is a hex prefix of the manufacturer data with the company id little-endian, e.g. 0xDEAD → "adde"), so ambient traffic cannot crowd out the device of interest. Device count and capture size follow the boot-time memory plan (64 devices / 48 KB with PSRAM, 24 / 16 KB without); when full, the least-recently-heard device is evicted |
ble/scan/results |
mac?, serviceUuid?, manufacturerId?, macPrefix? |
Stop the scan (if running) and return captured devices; optional case-insensitive mac returns only that device with its full payload history; optional AND-combined response filters (serviceUuid, manufacturerId hex, macPrefix) never affect capture. Also returns evicted (devices dropped this scan to stay within the limits), maxDevices, and per-device lastSeen |
ble/scan/payloads |
— | Stop the scan and return the MAC-independent payload registry: distinct payloads across ALL broadcasters, first-seen order, with count + firstSeen/lastSeen (ms since scan start), plus dropped: distinct payloads discarded because the registry (64 entries) was full. The primary artifact for protocol reverse-engineering — survives per-MAC MAC-rotation churn |
ble/scan/poll |
— | Live read of the payload registry WITHOUT stopping the scan (stateless; diff by firstSeen) |
ble/scan/stop |
— | Stop a continuous scan early |
ble/connect |
mac, timeoutMs?, addrType? |
Begin an asynchronous GATT connect to a peer MAC (default timeout 5000 ms). addrType is public, random, rpa-public or rpa-random; when omitted, the type recorded by the last scan for that MAC is used (public if it was not seen). Most phones, watches and sensors use random addresses and cannot be opened as public |
ble/connect/results |
— | Poll the outcome of the last async connect |
ble/disconnect |
— | Drop the GATT link |
ble/services/start |
— | Enumerate services/characteristics (background task); returns immediately |
ble/services/results |
notifyCaptureMs? |
Wait for enumeration + notified values (default 3000 ms, capped at 10000 ms) |
ble/services/stop |
— | Unsubscribe from notifications and close the link |
ble/scan / ble/scan/results
{"jsonrpc":"2.0","method":"ble/scan","id":14,"params":{"durationMs":10000}}
{"jsonrpc":"2.0","id":14,"result":{"started":true,"scanning":true}}
{"jsonrpc":"2.0","method":"ble/scan/results","id":15}
{
"jsonrpc":"2.0","id":15,
"result":{"devices":[
{
"mac":"62:38:e6:6b:1c:f9","name":"","rssi":-56,"connectable":true,"addrType":"random",
"count":2,
"payloadHistory":[
"0201010effff006db643ce97fe427cd5964c03038fae",
"0201010effff006db643ce97fe427cc1ba0b03038fae"
],
"services":"0000ae8f-0000-1000-8000-00805f9b34fb",
"manufacturer":"ff006db643ce97fe427cd5964c"
}
]}
}
Notes for reverse engineering:
payloadHistoryholds the distinct raw payloads (oldest → newest) seen per device, bounded to 64 retained entries (payloads capped at 64 bytes each; the device count and total capture size follow the boot-time memory plan). A change between entries is a state change in the broadcaster (e.g. a button press or level change). When the bound is reached the oldest entry is dropped;countis the number of distinct payloads currently retained.- The unfiltered
ble/scan/resultsresponse serialises only the newest 4 payloads per device (countstill reports the true distinct total, sopayloadHistorylength may be less thancount). Pass the optionalmacparam (case-insensitive) to fetch a single device's full history — e.g.ble/scan/resultswith{"mac":"62:38:e6:6b:1c:f9"}. This keeps the quick-look response small even when the BLE radio is flooded while preserving deep per-device capture for protocol reverse-engineering. manufacturercontains the manufacturer-specific data as hex — company ID is the first two bytes (little-endian). The example payload above uses manufacturer ID0xFF00and the 8-byte prefix6D B6 43 CE 97 FE 42 7C(a broadcast-controlled toy protocol), withD5 96 4C→C1 BA 0Bshowing a state change.isConnectableis a heuristic from the BLE flags AD type; absent/malformed flags default to connectable.
ble/connect / ble/connect/results / ble/services/*
{"jsonrpc":"2.0","method":"ble/connect","id":16,"params":{"mac":"00:1b:66:c6:da:c8","timeoutMs":5000}}
{"jsonrpc":"2.0","id":16,"result":{"started":true,"connecting":true,"connected":false,"addrType":"public"}}
ble/connect returns immediately; the open handshake completes in the background within timeoutMs (the parameter is enforced). Poll for the outcome:
{"jsonrpc":"2.0","method":"ble/connect/results","id":17}
{"jsonrpc":"2.0","id":17,"result":{"connecting":false,"connected":true}}
{"jsonrpc":"2.0","method":"ble/services/start","id":18}
{"jsonrpc":"2.0","method":"ble/services/results","id":19}
{
"jsonrpc":"2.0","id":19,
"result":{"connected":true,"services":[
{"uuid":"00001800-0000-1000-8000-00805f9b34fb","characteristics":[
{"uuid":"00002a00-0000-1000-8000-00805f9b34fb","properties":"read",
"valueHex":"4c452d4844203335304254","valueText":"LE-HD 350BT"}
]}
]}
}
GATT enumeration runs in the background so a slow or uncooperative peer cannot block the MCP server. Every operation is time-bounded: the open handshake (timeoutMs), service discovery (10 s cap), characteristic reads (5 s for the whole read phase), and the notified-value capture window (notifyCaptureMs, default 3000 ms, capped at 10000 ms because the call blocks the MCP task while it waits). ble/services/stop (or ble/disconnect) closes the link, tearing down any in-flight work safely.
TypeScript client example
See examples/client/client.ts for a complete typed client.
I2C Sensor Reference
| Sensor | Addresses | Parameters | Units |
|---|---|---|---|
| BME280 / BMP280 | 0x76, 0x77 | temperature, pressure, humidity¹ |
°C, Pa, %RH |
| MPU6050 | 0x68, 0x69 | accel_x/y/z, gyro_x/y/z, temperature |
g, °/s, °C |
| ADS1115 | 0x48–0x4B | ch0_V, ch1_V, ch2_V, ch3_V |
V (±4.096 range) |
| SHT31 | 0x44, 0x45 | temperature, humidity |
°C, %RH |
| BH1750 | 0x23, 0x5C | light_lux |
lux |
| INA219 | 0x40–0x4F | bus_voltage_V, shunt_mV, current_mA, power_mW |
V, mV, mA, mW |
¹ humidity is not available on BMP280.
NMEA 0183 Sentences Supported
| Sentence | Description | Fields Decoded |
|---|---|---|
| GGA | GPS Fix Data | time, lat, lon, fix quality, satellites, HDOP, altitude, geoid separation |
| RMC | Recommended Minimum Navigation | time, active, lat, lon, speed (kn & km/h), course, magnetic variation |
| VTG | Track Made Good / Ground Speed | course (true & magnetic), speed (kn & km/h) |
| GSV | Satellites in View | total satellites, per-satellite PRN, elevation, azimuth, SNR |
| MWV | Wind Speed and Angle | angle, relative/true, speed, unit (K/M/N) |
| DBT | Depth Below Transducer | depth (feet / metres / fathoms) |
| DPT | Depth of Water | depth (metres) |
| HDG | Heading – Deviation & Variation | heading, deviation, variation (E/W) |
| HDT | Heading – True | heading (true north) |
Parsing features: XOR checksum validation, DDMM.MMMM → signed decimal degrees, HHMMSS.SS time, talker ID extraction, graceful empty-field handling.
NMEA 2000 PGNs Supported
| PGN | Name | Key Fields |
|---|---|---|
| 127250 | Vessel Heading | heading, variation |
| 127257 | Attitude | pitch, roll, yaw |
| 128259 | Speed Through Water | speedKnots |
| 128267 | Water Depth | depthMetres, offset |
| 129025 | Position Rapid Update | latitude, longitude |
| 129026 | COG & SOG | courseOverGround, speedOverGround |
| 130306 | Wind Data | speedKnots, angle, apparent/true flag |
| 130310 | Water Temperature | tempCelsius |
OBD-II PIDs Supported (Service 01)
Selected PIDs decoded; full list in src/CANParser.cpp:
| Category | PIDs |
|---|---|
| Engine | RPM, load, coolant temp, intake air temp, MAP, MAF, timing advance |
| Fuel | Fuel pressure, trim (short/long), rail pressure, injection timing, fuel rate |
| Emissions | O2 sensor voltages, EGR, evap system, DPF pressure |
| Motion | Vehicle speed, throttle position, accelerator pedal position |
| Temperature | Oil temp, intake air temp, EGR temp, engine exhaust gas temps (EGT) |
| Electrical | Control module voltage, battery voltage |
| Hybrid | Hybrid battery remaining life |
| Torque | Driver demand, actual torque, reference torque |
| Vehicle info | VIN, calibration IDs (service 09) |
Discovery
On network connect the server registers two mDNS services and periodically sends a UDP capability beacon:
| Discovery | Details |
|---|---|
| mDNS | _mcp._tcp and _http._tcp with TXT records: version, capabilities, detected sensors |
| UDP broadcast | JSON beacon to 255.255.255.255:5354 every 30 s (configurable); includes sensor manifest |
mDNS and UDP are triggered automatically on network join, I2C scan completion, MCP server ready, and bus history config changes.
Testing
Tests compile and run natively on Linux — no hardware required.
# All tests
pio test -e native
# Specific suites
pio test -e native -f test_nmea_parser
pio test -e native -f test_can_parser
pio test -e native -f test_i2c_sensors
pio test -e native -f test_bus_reader
pio test -e native -f test_mcp_server
pio test -e native -f test_metrics_system
# With coverage (requires lcov)
pio test -e native --coverage
| Suite | What is covered |
|---|---|
test_nmea_parser |
GGA, RMC, VTG, GSV, MWV, DBT, DPT, HDG, HDT, checksum validation |
test_can_parser |
OBD-II PIDs, NMEA 2000 PGNs 127250/127257/128259/128267/129025/129026/130306/130310 |
test_i2c_sensors |
BME280, MPU6050, ADS1115, SHT31, BH1750, INA219 via mock I2C |
test_bus_reader |
Timed reads, raw/parsed modes, timeout handling |
test_mcp_server |
JSON-RPC dispatch, resource management, method registration |
test_metrics_system |
Counters, gauges, histograms, LittleFS persistence |
Extending with Custom MCP Methods
Register additional JSON-RPC handlers at runtime:
mcpServer.registerMethodHandler("myns/doSomething",
[](uint8_t clientId, uint32_t reqId, const JsonObject& params) {
StaticJsonDocument<256> doc;
doc["result"]["ok"] = true;
std::string out;
serializeJson(doc, out);
return out;
});
// Remove when no longer needed
mcpServer.unregisterMethodHandler("myns/doSomething");
Contributing
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Add tests for new functionality (
pio test -e nativemust pass) - Open a pull request
See CONTRIBUTING.md for coding style and guidelines.
License
MIT — see LICENSE.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found