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 └─────────────────────┘
-
Flash the firmware once — open
firmware/firmware.inoin Arduino IDE (esp32 core 3.x, partition scheme Huge APP), hit Upload. Details:firmware/README.md. -
Install the Python library on the Pi/PC:
pip install python-esp-bridge # USB only pip install "python-esp-bridge[ble]" # + Bluetooth support
-
Go:
from espbridge import Bridge with Bridge() as esp: # auto-detects the USB port print(esp.info) # chip, MAC, capabilities esp.gpio.mode(2, "output") # like RPi GPIO, but on the ESP32 esp.gpio.write(2, 1) 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 at the top offirmware.ino):with Bridge(ble=True, password="espbridge") as esp: # over Bluetooth esp.gpio.write(2, 1)
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, 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 primitives — no firmware changes to add your own):
from espbridge.neopixel import NeoPixel # WS2812/SK6812 strips
from espbridge.dht import DHT # DHT11/DHT22 temp+humidity
from espbridge.ds18b20 import DS18B20 # 1-Wire thermometers (multi-drop)
from espbridge.hcsr04 import HCSR04 # ultrasonic ranging
from espbridge.ir import IrSender, IrReceiver # NEC remotes + raw IR
from espbridge.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)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).
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.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")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.
| path | what |
|---|---|
firmware/ |
Arduino firmware (flash once; Bluetooth password lives at the top of firmware.ino) |
src/ |
Python package python-esp-bridge (import espbridge; transports: USB serial + BLE) |
examples/ |
grouped: basics/, devices/ (NeoPixel, DHT, DS18B20, HC-SR04, IR, stepper, CAN, I2S), system/ (files, NVS, deep sleep, OTA), wireless/, network/, displays/, compat/ (gpiozero, adafruit, luma, smbus, rpi_gpio) |
tests/ |
hardware-free protocol/bridge tests (pytest tests/) |
docs/PROTOCOL.md |
binary wire protocol spec (framing, transports, auth) |
Primary target: classic ESP32 DevKits (ESP-32S / ESP-32D, 30- and 38-pin, CP2102/CH340 USB). ESP32-S2/S3/C3 build via the same sketch (native USB; ESP-NOW works everywhere; no DAC on S3/C3). Capabilities are reported by the firmware at connect time, so the Python API fails fast with a clear error for anything your chip lacks.
Bluetooth note: arduino-esp32 core 3.3.x ships the NimBLE host on S3/C3/C6 — 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 have everything regardless. The Python API raises a clearUnsupportedErroreither way (Cap.SLEEP,Cap.SDMMCprobing).
| ESP32 | S2 | S3 | C3 | C6 | |
|---|---|---|---|---|---|
| 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) | ✓ | ✓ | ✓ | ✓ | ✓ |
