If you want to run Python on an ESP32, the definitive choice is MicroPython (specifically the generic ESP32 firmware, v1.22 or newer). While CircuitPython is excellent for SAMD21 and RP2040 boards, it lacks robust dual-core asyncio and native WiFi support on the original ESP32-WROOM architecture. This guide gives you the exact hardware, pinouts, and production-ready MicroPython code to build an I2C environmental sensor that logs to an MQTT broker, along with the bench-tested debugging paths for when the bus inevitably crashes.
The Python ESP32 Decision Matrix
Before flashing firmware, you must choose your Python fork. Here is the decision path for ESP32 development in 2026:
| Criteria | MicroPython (ESP32) | CircuitPython (ESP32-S2/S3 only) |
|---|---|---|
| Hardware Support | Full support for original ESP32, S2, S3, C3 | Dropped original ESP32; supports S2/S3/C3 |
| Dual-Core / Asyncio | Native uasyncio, utilizes both cores |
Single-core focus, limited async on ESP32 |
| Package Manager | mip (fast, C-modules supported) |
circup (bundle-based, pure Python focus) |
| WiFi / BLE Stack | Deep access via network and bluetooth |
Abstracted via wifi and bleio |
ESP32_GENERIC-SPIRAM-20241025-v1.24.0.bin (or latest stable) from the official MicroPython downloads page.
Hardware Spec Sheet and Pin Mapping
This build targets the most common, cost-effective bench hardware. Total BOM cost is typically under $15.
Parts List
- MCU: ESP32-WROOM-32 DevKit V1 (30-pin variant). Note: Ensure it has the CP2102 or CH340 USB-UART bridge.
- Sensor: BME280 3.3V I2C Breakout (Adafruit Product ID 2652 or generic Bosch BME280 module with onboard 3.3V LDO and pull-ups).
- Passives: 1x 100µF electrolytic capacitor (rated 10V+).
- Wiring: 22 AWG solid core jumper wires.
Pin Mapping Table
| BME280 Breakout Pin | ESP32 DevKit V1 Pin | Function / Notes |
|---|---|---|
| VIN / VCC | 3V3 | Do NOT use 5V if your breakout lacks an LDO. |
| GND | GND | Common ground required for I2C logic reference. |
| SDA | GPIO 21 | Default I2C0 SDA on ESP32. |
| SCL | GPIO 22 | Default I2C0 SCL on ESP32. |
Complete MicroPython Build: I2C Sensor to MQTT
Flash your ESP32 with MicroPython using esptool.py or Thonny IDE. Once flashed, connect to the REPL and run the following setup to install dependencies using the modern mip package manager (which replaced the deprecated upip):
import mip
mip.install("umqtt.simple")
mip.install("bme280")
Create a file named main.py on the ESP32 filesystem and paste the complete, error-handled code below. This script scans the I2C bus, reads the sensor, and publishes to an MQTT broker with automatic reconnection logic.
# main.py - Python ESP32 BME280 to MQTT Logger
import time
import machine
from machine import Pin, I2C
import network
import ubinascii
from umqtt.simple import MQTTClient
import bme280
# --- PIN & CONFIG DEFINITIONS ---
I2C_SDA_PIN = 21
I2C_SCL_PIN = 22
I2C_FREQ = 100000 # 100kHz standard mode
WIFI_SSID = "YourNetworkSSID"
WIFI_PASS = "YourNetworkPassword"
MQTT_BROKER_IP = "192.168.1.50"
MQTT_PORT = 1883
MQTT_TOPIC = b"home/lab/environment"
# Generate unique client ID from MAC address
CLIENT_ID = ubinascii.hexlify(machine.unique_id())
def connect_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
print(f"Connecting to {WIFI_SSID}...")
wlan.connect(WIFI_SSID, WIFI_PASS)
timeout = 15
while not wlan.isconnected() and timeout > 0:
time.sleep(1)
timeout -= 1
if wlan.isconnected():
print(f"WiFi Connected. IP: {wlan.ifconfig()[0]}")
return True
else:
print("WiFi Connection Failed.")
return False
def init_i2c():
# Initialize I2C bus with explicit pin definitions
i2c = I2C(0, scl=Pin(I2C_SCL_PIN), sda=Pin(I2C_SDA_PIN), freq=I2C_FREQ)
devices = i2c.scan()
if not devices:
raise OSError("[Errno 19] ENODEV: No I2C devices found. Check wiring.")
# BME280 default addresses are 0x76 or 0x77
bme_addr = 0x76 if 0x76 in devices else 0x77 if 0x77 in devices else None
if not bme_addr:
raise OSError(f"[Errno 19] ENODEV: BME280 not found. Found: {[hex(d) for d in devices]}")
print(f"BME280 found at {hex(bme_addr)}")
return bme280.BME280(i2c=i2c, address=bme_addr)
def main():
if not connect_wifi():
machine.reset()
sensor = init_i2c()
mqtt = MQTTClient(CLIENT_ID, MQTT_BROKER_IP, port=MQTT_PORT, keepalive=60)
try:
mqtt.connect()
print(f"Connected to MQTT Broker {MQTT_BROKER_IP}")
except OSError as e:
print(f"MQTT Connection Failed: {e}")
machine.reset()
while True:
try:
# Read sensor data
temp_c = sensor.temperature[:-1] # Strip 'C' suffix
humidity = sensor.humidity[:-1] # Strip '%' suffix
pressure = sensor.pressure[:-3] # Strip 'hPa' suffix
# Format payload
payload = f'{{"temp":{temp_c},"hum":{humidity},"pres":{pressure}}}'
# Publish to MQTT
mqtt.publish(MQTT_TOPIC, payload, retain=True)
print(f"Published: {payload}")
# Sleep for 60 seconds
time.sleep(60)
except OSError as e:
print(f"Runtime Error: {e}. Reconnecting...")
try:
mqtt.connect()
except:
machine.reset()
except Exception as e:
print(f"Unexpected Error: {e}")
time.sleep(10)
if __name__ == "__main__":
main()
Debugging: First Three Checks and Exact Error Strings
When embedded Python fails, it rarely fails silently. Here is your decision path for the two most common OSError exceptions thrown by this exact build.
The First Three Things to Check
- Run a raw I2C scan in the REPL: Before running
main.py, instantiate the I2C object and runi2c.scan(). If it returns an empty list[], your hardware is wired wrong or the sensor is dead. - Measure the 3.3V rail under load: Put your multimeter on the 3V3 pin while the ESP32 is actively transmitting WiFi. If it drops below 3.1V, add the 100µF capacitor mentioned in the BOM.
- Verify Port 1883 accessibility: From a PC on the same network, run
telnet 192.168.1.50 1883. If it times out, your MQTT broker (Mosquitto) is down or a firewall is blocking local LAN traffic.
Error String 1: OSError: [Errno 19] ENODEV
Context: Thrown during i2c.scan() or sensor initialization. The ESP32 cannot find the BME280 on the bus.
- Cause 1 (Most Likely): SDA and SCL are swapped. The ESP32 defaults are 21 (SDA) and 22 (SCL). Verify against the physical silkscreen on your specific DevKit, as some 38-pin clones swap these.
- Cause 2: Missing pull-up resistors. The I2C spec requires 4.7kΩ pull-ups to 3.3V. Genuine Adafruit breakouts have them; cheap bare-bones Bosch modules often do not.
- Cause 3: The sensor is wired to 5V, but the ESP32 GPIOs are 3.3V tolerant. The logic high threshold isn't being met. Wire VCC to 3V3.
Error String 2: OSError: [Errno 110] ETIMEDOUT
Context: Thrown during mqtt.connect() or mqtt.publish(). The TCP socket dropped or never established.
- Cause 1 (Most Likely): The MQTT broker IP is incorrect, or the broker service (e.g., Mosquitto) crashed. Check
systemctl status mosquittoon your server. - Cause 2: WiFi router isolated the client (AP Isolation / Guest Network). The ESP32 cannot route to local LAN IPs. Move the ESP32 to your primary IoT VLAN.
- Cause 3: The
keepaliveparameter inMQTTClientis too low for your network latency, causing the broker to drop the socket. The code above sets it to 60 seconds, which is standard.
How to Extend or Simplify the Build
Depending on your end goal, you can scale this architecture up for production or down for quick prototyping.
Simplifying the Build (No MQTT Broker)
If you don't want to maintain a Mosquitto server, strip out the umqtt library entirely. Instead, use the ESP32's native network.WLAN(network.AP_IF) to broadcast its own Access Point, and run a lightweight uasyncio HTTP server. The ESP32 hosts a simple HTML page displaying the latest BME280 JSON payload. This reduces your BOM to just the MCU and sensor, eliminating network infrastructure dependencies.
Extending the Build (Deep Sleep & Home Assistant)
To make this a battery-operated, production-grade node:
- Enable Deep Sleep: Replace the
time.sleep(60)loop withmachine.deepsleep(60000). This drops current draw from ~80mA to ~10µA between reads. - Wire GPIO 32 to EN: To wake from deep sleep via timer, you must physically jumper GPIO 32 to the EN (Enable) pin on the DevKit.
- Home Assistant Discovery: Modify the MQTT payload to publish to the
homeassistant/sensor/esp32_lab/configtopic using the MQTT Discovery JSON schema. Home Assistant will automatically ingest the ESP32 as a native integration without manual YAML configuration.
By standardizing on MicroPython and understanding the physical layer constraints of the ESP32's I2C and power delivery, you eliminate 90% of the "ghost in the machine" errors that plague embedded Python projects.






