Connect an ESP32 to a Raspberry Pi (or any PC) over USB or Bluetooth and drive every ESP32 peripheral live from Python β GPIO, PWM, ADC, DAC, capacitive touch, I2C, SPI, extra UARTs, RMT pulse trains (NeoPixels, IR, DHT, ultrasonic, steppers), 1-Wire, CAN bus, I2S audio, files (LittleFS/SD), NVS storage, deep sleep, Wi-Fi (including TCP/UDP sockets through the ESP32 radio), Ethernet, camera, BLE and ESP-NOW β plus firmware updates over the link itself. Flash the bridge firmware once; after that, everything is Python on the host. No reflashing per project.
The design rule: the firmware exposes minimal hardware primitives; device protocols (WS2812 timing, NEC IR, DHT decoding, 1-Wire search, stepper ramps) are implemented in Python where they are easy to read, test and extend.
ββββββββββββββββββ USB serial (β€921600 Bd) or BLE βββββββββββββββββββββββ
β Pi / PC β ββββββββββββββββββββββββββββββββΊ β ESP32 (bridge fw) β
β Python: β binary protocol, COBS+CRC16 β FreeRTOS tasks: β
β espbridge β ββββββββββββββββββββββββββββββββ β tx / rx / network β
ββββββββββββββββββ replies + async events βββββββββββββββββββββββ
Works on Raspberry Pi OS, Linux, Windows, and macOS (requires Python β₯ 3.11).
-
Flash the firmware once. No Arduino IDE needed β flash the bundled prebuilt firmware straight from the host (lists the serial ports, you pick one, it writes a Huge APP image with esptool):
uvx --from "python-esp-bridge[flash]" flash # zero-install via uv
Prefer building it yourself? Install the
python esp bridgelibrary (Arduino IDE Library Manager), open File β Examples β python esp bridge β Bridge, pick partition scheme Huge APP, hit Upload. The whole sketch isEspBridge.begin();. Details:docs/FIRMWARE.md. -
Install the Python library on the Pi/PC β with pip:
pip install python-esp-bridge # USB + Bluetooth, both included pip install "python-esp-bridge[oled]" # + Pillow, for OLED displays pip install "python-esp-bridge[mcp]" # + the MCP server (espbridge-mcp) pip install "python-esp-bridge[flash]" # + esptool, for `espbridge flash`
...or with uv:
uv add python-esp-bridge # into a uv project (USB + Bluetooth) uv add "python-esp-bridge[oled]" # with an extra (oled / mcp / all) uv pip install python-esp-bridge # ...or into the active environment
Bluetooth works out of the box (no extra); the old
[ble]extra is kept as a no-op for back-compat. -
Go:
from espbridge import Bridge with Bridge() as esp: # Bluetooth first, then USB serial print(esp.info) # chip, MAC, capabilities esp.gpio.mode(2, "output") # like RPi GPIO, but on the ESP32 esp.gpio.write(2, 1) # returns the pin's read-back level esp.gpio.write(2, 1, verify=True) # ...and raises if it didn't take print(esp.adc.read_mv(34), "mV") esp.dac.write(25, 128) # true analog out (classic ESP32) esp.pwm.servo(13, angle=90) esp.i2c.init(sda=21, scl=22) print([hex(a) for a in esp.i2c.scan()]) esp.wifi.connect("ssid", "password") # the ESP32's radio... status, body = esp.net.http_get("http://example.com/") # ...as your modem
Or with no USB cable at all β boards advertise as
espbridge_<mac>(plus your custom name) and require a password (defaultespbridge, change it viaEspBridge.begin("yourpassword")in the sketch):with Bridge(ble=True, password="espbridge") as esp: # over Bluetooth esp.gpio.write(2, 1)
Bridge()prefers Bluetooth and falls back to USB serial; passble=Falsefor USB / COM only (Bluetooth off),ble="name"to pin a specific board over Bluetooth, orport="COM7"for a specific serial port.espbridgeon the command line prints connection info;espbridge portslists candidate serial ports;espbridge scanprobes every attached board andespbridge scan --blefinds bridges advertising over Bluetooth.
| module | highlights |
|---|---|
| GPIO | modes incl. pull-up/down & open-drain, batch writes, writes return the pin's read-back level for confirmation (verify= raises on mismatch), edge interrupts with debounce β Python callbacks |
| ADC | raw + calibrated mV, attenuation config (ADC2/Wi-Fi conflict guarded) |
| DAC | 8-bit output + hardware cosine generator (classic ESP32 / S2) |
| PWM | LEDC: any pin, freq/resolution, duty_pct, tone, servo |
| Touch | capacitive touch pad reads |
| I2C | 2 buses, scan, write/read, register helpers, repeated-start |
| SPI | 2 hosts, full-duplex transfers, CS handling |
| UART | UART1/2 bridged: write from Python, RX streamed back as events |
| Wi-Fi | scan, STA join, AP mode, status/RSSI, state events |
| NET | TCP client/server + UDP through the ESP32 radio, socket-like API, credit-window flow control |
| BLE | scan, advertise, GATT server (notify/write callbacks), GATT client |
| ESP-NOW | connectionless ESP32βESP32 messaging: peers + broadcast, delivery ACKs, RX with RSSI, PMK/LMK encryption β coexists with Wi-Fi and BLE |
| RMT | generic pulse-train play/capture β the one primitive behind neopixel, ir, dht, hcsr04, stepper (below) |
| 1-Wire | bus primitives on any pin; ROM search + CRC8 in Python (esp.onewire, DS18B20 driver included) |
| CAN | TWAI controller: 25kβ1M bit/s, filters, send/recv + callbacks (esp.can; transceiver chip required) |
| I2S | PCM in/out for MEMS mics & DACs/amps (esp.i2s; link bandwidth caps rates ~16-bit/32 kHz mono) |
| Files | LittleFS on internal flash + SD cards: open/read/write/list/β¦ (esp.fs) |
| NVS | persistent key/value storage on the board (esp.nvs) |
| Sleep | deep + light sleep with timer/GPIO wake (esp.deep_sleep(); see chip notes) |
| OTA | reflash the firmware over USB or Bluetooth (esp.ota.flash("fw.bin"); dual-app partition scheme) |
| Ethernet | RMII (WT32-ETH01, Olimex POEβ¦) or SPI (W5500) β NET sockets ride it automatically (firmware opt-in) |
| Camera | JPEG snapshots from ESP32-CAM / XIAO-S3-Sense / ESP-EYE (firmware opt-in, PSRAM) |
| MCPWM | complementary PWM pair with hardware deadtime for H-bridges (esp.mcpwm; not on S2/C3) |
Device drivers in pure Python (over the RMT/1-Wire/I2C primitives β no firmware changes to add your own):
from espbridge.drivers.neopixel import NeoPixel # WS2812/SK6812 strips
from espbridge.drivers.dht import DHT # DHT11/DHT22 temp+humidity
from espbridge.drivers.ds18b20 import DS18B20 # 1-Wire thermometers (multi-drop)
from espbridge.drivers.hcsr04 import HCSR04 # ultrasonic ranging
from espbridge.drivers.ir import IrSender, IrReceiver # NEC remotes + raw IR
from espbridge.drivers.stepper import Stepper # A4988/DRV8825 with ramps
NeoPixel(esp, pin=5, n=30).fill((0, 0, 64))
print(DHT(esp, 4).read()) # (23.1, 65.5)
Stepper(esp, step_pin=12, dir_pin=14).move(400, speed=800, accel=1600)Those are reference implementations, not the limit. Every bundled driver
lives in espbridge/drivers/, and a driver is just
a Python class whose constructor takes the bridge and talks to a device over the
primitives above β drivers/dht.py is 75
lines. So any sensor, display or protocol is a host-side class away, with no
firmware change:
class MyTempSensor: # any class taking the bridge first
def __init__(self, esp, address=0x48):
self._i2c, self._addr = esp.i2c, address
def read_c(self):
hi, lo = self._i2c.read_reg(self._addr, 0x00, 2)
return ((hi << 8 | lo) >> 4) * 0.0625
MyTempSensor(esp).read_c() # works as-is, nothing to registerRegister a name for the esp.<name>(...) sugar, or ship a pip package that
others install and your driver shows up on every bridge automatically:
from espbridge import register_driver
register_driver("mytemp", MyTempSensor)
esp.mytemp(address=0x48).read_c() # == MyTempSensor(esp, address=0x48)espbridge drivers lists everything available (the bundled
drivers/ and any installed plugins). Full guide:
docs/DRIVERS.md. And if a driver already exists in the
Adafruit / luma / gpiozero / smbus2 ecosystems, it runs unchanged through the
compat shims β no rewrite needed.
The firmware is fully event-driven on FreeRTOS: serial TX, command handling and the network stack run as separate tasks, so a blocking Wi-Fi/BLE operation never delays a GPIO read (~1 ms round-trips at 921600 Bd).
A board's link can't be opened twice, but one Bridge is thread-safe β
share it across threads and their requests pipeline on the wire, correlated by
sequence number, so a slow call on one thread never stalls a fast call on
another (the firmware runs a matching task split; see
rtos_concurrency.py).
For easy integration, don't pass a Bridge around β call connect() anywhere
and get the same shared, auto-reconnecting link:
import espbridge
esp = espbridge.connect(ble=False) # same live link from any thread/module
esp.gpio.write(2, 1) # safe to call concurrently
# e.g. a FastAPI/Flask route β every request shares the one connection:
@app.get("/adc/{pin}")
def read(pin: int):
return {"mV": espbridge.connect(ble=False).adc.read_mv(pin)}For await, wrap any bridge β fan out concurrent I/O with asyncio.gather
(async_fanout.py):
from espbridge import AsyncBridge
async with AsyncBridge(ble=False) as esp: # or AsyncBridge.wrap(espbridge.connect())
t, h = await asyncio.gather(esp.adc.read(34), esp.adc.read(35))Multiple processes? One process owns the link (e.g. the
MCP or an HTTP server) and the others talk to
it. See shared_connection.py.
espbridge speaks the wire protocols of the popular Python hardware ecosystems, so existing code, drivers and tutorials run unchanged β the ESP32's pins just take the place of the Pi's:
gpiozero β full pin factory (LED, Button, PWMLED, edge callbacks, β¦):
from gpiozero import LED, Button
from espbridge.compat.gpiozero import EspBridgeFactory
factory = EspBridgeFactory(esp)
led, btn = LED(2, pin_factory=factory), Button(4, pin_factory=factory)
btn.when_pressed = led.toggleAdafruit CircuitPython drivers (hundreds of sensors/displays) β busio/digitalio-compatible I2C, SPI and DigitalInOut:
from adafruit_bme280.basic import Adafruit_BME280_I2C
from espbridge.compat.blinka import I2C
bme = Adafruit_BME280_I2C(I2C(esp)) # the driver doesn't know it's bridged
print(bme.temperature)smbus2 β classic Pi I2C code, unchanged:
from espbridge.compat.smbus import SMBus
bus = SMBus(esp) # instead of smbus2.SMBus(1)
temp = bus.read_byte_data(0x48, 0x00)luma.oled / luma.lcd β I2C and SPI display interfaces (LumaI2C, LumaSPI),
RPi.GPIO β espbridge.compat.rpi_gpio shim, and the native objects follow
stdlib conventions too: UART ports are pyserial-like (in_waiting, readline),
bridged TCP/UDP sockets support settimeout/recv/sendall.
I2C OLEDs (SSD1306 / SH1106 / the ubiquitous clones) are supported directly β
pip install "python-esp-bridge[oled]", draw with PIL:
from espbridge.drivers.oled import OLED
oled = OLED(esp) # bus init + auto-detect + clone-safe power-up
with oled.draw() as d: # d is a PIL ImageDraw
d.text((0, 10), "Hello!", fill="white")Expose the whole bridge to an LLM as a Model Context Protocol server β 100+ tools covering every peripheral (GPIO, ADC/DAC, PWM, I2C, SPI, UART, Wi-Fi, NVS, filesystem, 1-Wire, ESP-NOW, CAN, MCPWM, Ethernet, camera, OTA). The agent can then read sensors, toggle pins, scan I2C and more in plain language.
Install the server once, then plug in the board β the port auto-detects:
uv tool install "python-esp-bridge[mcp]" # or: pip install "python-esp-bridge[mcp]"Works with Claude Code, Gemini CLI, Codex CLI, Antigravity, Cursor/Windsurf
and Ollama β all launch the same espbridge-mcp command. This repo ships
ready configs for Claude Code (.mcp.json) and Gemini CLI
(.gemini/settings.json): just open the assistant in the repo. Every other
client uses the same one-line config (key mcpServers):
Tools are grouped by peripheral (gpio_*, i2c_*, wifi_*, β¦); raw byte
payloads go in and out as hex strings. Embed it in your own server with
from espbridge.mcp import build_server. Per-assistant setup (incl. Codex,
Antigravity, Ollama): docs/MCP.md.
Give each board a persistent name once (espbridge -p COM7 set-name relays β
stored in the ESP32's flash, survives reboots and port renumbering), then:
import espbridge
from espbridge import Bridge
esp = Bridge(name="relays") # or Bridge(mac="aa:bb:cc:dd:ee:ff")
with espbridge.connect_all() as boards: # or just open all of them
boards.by_name("sensors").adc.read(34)
boards.by_name("relays").gpio.write(2, 1)Errors name the command and say what to check (I2C_WRITE (0x4003) failed: IO β no ACK on the wire β check wiring, power, device address and pull-ups).
A timeout additionally pings the board so the message tells you whether the
link itself died or a single frame got lost. Useful knobs:
esp = Bridge(retries=1) # default: re-send safe commands once on timeout
esp.free_heap() # heap + dropped-frame counters from the firmwareESPBRIDGE_DEBUG=1 python app.py # trace every request/response with namesLost frames on a busy link are also prevented now: pipelined bursts (OLED frames, NeoPixel updates) are automatically throttled to what the firmware's link buffer can absorb, on both USB serial and Bluetooth.
The repo root is the Arduino library (so it's publishable to the Arduino
Library Manager); the Python package lives under python/.
| path | what |
|---|---|
src/ + examples/Bridge/ |
Arduino library β the flash-once firmware (C/C++) + its example sketch (EspBridge.begin()) |
library.properties, keywords.txt |
Arduino Library Manager metadata (at the repo root, as the registry requires) |
python/ |
Python package python-esp-bridge (import espbridge) with its own tests/ and grouped examples/ (basics/, devices/, system/, wireless/, network/, displays/, compat/) |
docs/MCP.md |
MCP server (espbridge-mcp): drive the bridge from an AI agent |
docs/PROTOCOL.md |
binary wire protocol spec (framing, transports, auth) |
docs/FIRMWARE.md |
firmware flashing, partition scheme & build-flag reference |
Primary target: classic ESP32 DevKits (ESP-32S / ESP-32D, 30- and 38-pin, CP2102/CH340 USB). ESP32-S2/S3/C3/C6/H2 build via the same sketch (native USB; ESP-NOW works everywhere; no DAC on S3/C3/C6/H2). Capabilities are reported by the firmware at connect time, so the Python API fails fast with a clear error for anything your chip lacks.
Tested with arduino-esp32 core 3.3.6 on classic ESP32 (CP2102), flashed
with the Minimal SPIFFS partition scheme (1.9 MB app + OTA β the firmware
is ~95% of that slot; Huge APP also works if you don't need OTA). Verified:
USB at 1.5 Mbaud, the BLE link, ESP-NOW, and Wi-Fi/BLE/ESP-NOW coexistence β
see python/examples/wireless/stress_test.py for the soak/coex suite used.
Bluetooth note: arduino-esp32 core 3.x ships the NimBLE host on S3/C3/C6/H2 β the bridge's Bluetooth code (BLE link +
esp.ble) speaks Bluedroid, so on those chips the firmware currently builds USB-only. Classic ESP32 keeps Bluedroid: full BLE link + Wi-Fi + ESP-NOW coexistence.
Classic-ESP32 IRAM trade-off: with Wi-Fi + Bluetooth both loaded the chip's instruction RAM is full, so the default classic build skips SD-card support (LittleFS still works) and deep/light sleep. Build with
BRIDGE_ENABLE_BLE 0(USB-only) to get SD + sleep back; S2/S3/C3/C6/H2 have everything regardless. The Python API raises a clearUnsupportedErroreither way (Cap.SLEEP,Cap.SDMMCprobing).
| ESP32 | S2 | S3 | C3 | C6 | H2 | |
|---|---|---|---|---|---|---|
| RMT / 1-Wire / CAN / I2S / NVS / OTA | β | β | β | β | β | β |
| LittleFS | β | β | β | β | β | β |
| SD (SPI) / sleep | BLE off only | β | β | β | β | β |
| SDMMC slot | BLE off only | β | β | β | β | β |
| MCPWM (deadtime pair) | β | β | β | β | β | β |
| Camera (opt-in) | β (PSRAM) | β (PSRAM) | β (PSRAM) | β | β | β |
| Ethernet RMII (opt-in) | β | β | β | β | β | β |
| Ethernet SPI W5500 (opt-in) | β | β | β | β | β | β |

{ "mcpServers": { "espbridge": { "command": "espbridge-mcp", "args": [] } } }