A single MicroPython library that ports picozero to the ESP32 family (WROOM, S3, C3, and more).
"One codebase, many boards" — hardware differences are absorbed by the Board Profile system.
Use mpremote to install directly from PyPI to your board:
mpremote mip install espzeroOr manually copy the espzero/ directory to your board's root.
To get autocomplete and linting in your IDE:
pip install espzeroimport espzero
espzero.begin() # initialise: auto-detect board
# Built-in LED — available after begin()
from espzero import esp_led
from time import sleep
while True:
esp_led.on()
sleep(1)
esp_led.off()
sleep(1)Specify a board explicitly instead of auto-detection:
import espzero
espzero.begin("esp32_38pin_nodemcu") # or "esp32_devkit_v1", "esp8266_lolin_v3", ...Use other components after begin():
from espzero import LED, Button, Servo, WiFi
led = LED("internal") # built-in LED — profile maps alias to real GPIO
btn = Button(0) # GPIO 0 (BOOT button)
led.blink(on_time=0.5, n=5) # identical API to picozero
# WiFi (ESP32-specific)
wifi = WiFi()
ip = wifi.connect("MySSID", "password")
print("IP:", ip)
# Capacitive touch (WROOM/WROVER only)
from espzero import CapTouch
touch = CapTouch(pin=4) # GPIO 4 = T0
if touch.is_touched:
esp_led.on()
# Servo
servo = Servo(13)
servo.mid()
servo.value = 0.75 # 0–1 range, same as picozeroespzero/
├── __init__.py # Public API entry point + begin()
├── _hal.py # Hardware Abstraction Layer (HAL)
├── _core.py # Ported picozero core logic
├── _wifi.py # ESP32-specific: WiFi class
├── _touch.py # ESP32-specific: capacitive touch class
└── profiles/
├── _base.py # Abstract BoardProfile base class
├── auto.py # Runtime board auto-detection
└── esp32_boards.py # Profile definitions for all supported boards
class BoardProfile:
NAME = "unknown" # Human-readable board name
CHIP = "esp32" # esp32 / esp32s3 / esp32c3 / esp8266
# Pin aliases — lets users write LED("internal") instead of LED(2)
PIN_ALIASES = {}
# ADC settings
ADC_MAX_RAW = 4095 # 12-bit resolution (0–4095)
ADC_SCALE = 65535 # Scale target for picozero read_u16() compatibility
ADC_ATTEN = None # e.g. machine.ADC.ATTN_11DB; None = firmware default
ADC_VREF = 3.3 # Maximum input voltage (tied to attenuation)
# PWM settings
PWM_DEFAULT_FREQ = 1000 # Hz — Pico default was 100 Hz; 1 kHz recommended for ESP32
PWM_DUTY_MAX = 65535 # Internal scale is always 16-bit
# Servo
SERVO_FREQ = 50 # 50 Hz (20 ms frame) — same as picozero
# Built-in LED type
# "digital" — standard GPIO LED
# "neopixel" — WS2812 RGB (e.g. ESP32-S3 DevKit, M5Stack ATOM)
INTERNAL_LED_TYPE = "digital"
INTERNAL_LED_ACTIVE_HIGH = True
# Boot strapping pins — connecting a button here may cause boot failures
STRAPPING_PINS = [] # e.g. [0, 2, 5, 12, 15]
# ADC2 pins — cannot be used while WiFi is active
ADC2_PINS = [] # e.g. [0, 2, 4, 12, 13, 14, 15, 25, 26, 27]class ESP32DevKitV1(BoardProfile):
NAME = "esp32_devkit_v1"
CHIP = "esp32"
PIN_ALIASES = {"internal": 2, "led": 2}
ADC_ATTEN = ADC.ATTN_11DB # 0–3.6 V
ADC_VREF = 3.6
INTERNAL_LED_TYPE = "digital"
INTERNAL_LED_ACTIVE_HIGH = False # active-low
STRAPPING_PINS = [0, 2, 5, 12, 15]
ADC2_PINS = [0, 2, 4, 12, 13, 14, 15, 25, 26, 27]
class ESP32S3DevKit(BoardProfile):
NAME = "esp32_s3_devkit"
CHIP = "esp32s3"
PIN_ALIASES = {"internal": 48, "led": 48}
INTERNAL_LED_TYPE = "neopixel" # WS2812 RGB on GPIO 48
STRAPPING_PINS = [0, 3, 45, 46]
ADC2_PINS = [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
class ESP32C3Mini(BoardProfile):
NAME = "esp32_c3_mini"
CHIP = "esp32c3"
PIN_ALIASES = {"internal": 8, "led": 8}
INTERNAL_LED_ACTIVE_HIGH = False
STRAPPING_PINS = [2, 8, 9]
ADC2_PINS = [] # C3 has no ADC2import sys
def detect() -> str:
"""
Identify the chip from sys.implementation._machine.
Returns a board key that matches an entry in espzero._PROFILE_MAP.
"""
try:
machine_str = sys.implementation._machine.lower()
except AttributeError:
return "esp32_devkit_v1" # fallback
if "esp32s3" in machine_str or "esp32-s3" in machine_str:
return "esp32_s3_devkit"
elif "esp32c3" in machine_str or "esp32-c3" in machine_str:
return "esp32_c3_mini"
else:
return "esp32_devkit_v1" # default: WROOMAll machine.Pin / machine.PWM / machine.ADC calls are routed through this layer.
Swapping the board profile is enough to support a new board — _core.py requires no changes.
_profile = None # Set by begin() to a BoardProfile instance
_wifi_active = False # True while WiFi is connected
def make_digital_in(pin, pull_up=False):
"""Create input Pin. Warns if GPIO is a strapping pin."""
gpio = resolve_pin(pin)
if gpio in get_profile().STRAPPING_PINS:
print("[espzero] WARNING: GPIO {} is a strapping pin. "
"Attaching a button may cause boot issues.".format(gpio))
...
def set_duty_u16(pwm_obj, value):
"""Set duty cycle. Falls back to duty(0–1023) on firmware < 1.19."""
try:
pwm_obj.duty_u16(value)
except AttributeError:
pwm_obj.duty(value >> 6) # 16-bit → 10-bit
def make_adc(pin):
"""Create ADC. Warns if WiFi is active and pin is in ADC2 group."""
if _wifi_active and gpio in get_profile().ADC2_PINS:
print("[espzero] WARNING: WiFi is active. ADC2 (GPIO {}) "
"cannot be used. Switch to an ADC1 pin.".format(gpio))
...| Topic | picozero (Pico) | espzero (ESP32) | How |
|---|---|---|---|
| Pin creation | Pin(num, ...) directly |
_hal.make_digital_out(pin) |
HAL wrapper |
| PWM channel conflict | PIN_TO_PWM_CHANNEL[] table |
Removed (ESP32 LEDC: any pin, any channel) | Deleted |
| PWM duty write | duty_u16(val) |
Same, with fallback for firmware < 1.19 | _hal.set_duty_u16() |
| ADC read | adc.read_u16() |
adc_read_u16() → scales read()×16 internally |
HAL |
| ADC attenuation | None | ATTN_11DB (set in profile) |
Profile |
| Built-in LED | LED("LED") or LED(25) |
LED("internal") → profile maps to real pin |
Alias system |
| Built-in temp | pico_temp_sensor (ADC ch.4) |
esp_temp_sensor (ESP32 esp32 module) |
Separate class |
| PWM default freq | 100 Hz | 1000 Hz (PWM_DEFAULT_FREQ in profile) |
Profile value |
| Servo freq | 50 Hz | 50 Hz (unchanged) | — |
| WiFi | None | WiFi class |
New |
| Touch | TouchSensor (external TTP223) |
+ CapTouch (ESP32 built-in touch peripheral) |
New |
# picozero: PIN_TO_PWM_CHANNEL table + _check_pwm_channel() → removed
# espzero: ESP32 LEDC assigns each pin an independent channel — no conflicts
class PWMOutputDevice(OutputDevice, PinMixin):
def __init__(self, pin, freq=None, duty_factor=65535, ...):
self._pwm = _hal.make_pwm(pin, freq) # routed through HAL
def _write(self, value):
_hal.set_duty_u16(self._pwm, self._value_to_state(value)) # with fallbackclass AnalogInputDevice(InputDevice, PinMixin):
def __init__(self, pin, ...):
self._adc = _hal.make_adc(pin) # attenuation applied in profile
def _read(self):
raw_u16 = _hal.adc_read_u16(self._adc) # scaled to 0–65535
return self._state_to_value(raw_u16) # upper logic unchanged
@property
def voltage(self):
return self.value * _hal.get_profile().ADC_VREF# picozero: pico_led, pico_temp_sensor
# espzero:
esp_led = LED("internal") # profile resolves "internal" alias
esp_temp_sensor = ESPTemperatureSensor() # uses esp32.raw_temperature()from espzero import WiFi
wifi = WiFi()
ip = wifi.connect("MySSID", "password", timeout=10)
print("Connected:", ip) # blocks until connected or raises OSError
wifi.scan() # returns list of nearby APs
wifi.is_connected # True / False
wifi.ip # current IP string
wifi.disconnect()Note: After
connect(), the HAL sets_wifi_active = Trueautomatically.
Any attempt to use an ADC2 pin after this will print a warning.
from espzero import CapTouch
touch = CapTouch(pin=4, threshold=300) # GPIO 4 = T0 on WROOM
if touch.is_touched:
print("Touched! Raw:", touch.value)Unlike TouchSensor (which wraps an external TTP223 IC), CapTouch uses the ESP32's built-in capacitive touch peripheral directly. Available on pins T0–T9 of WROOM/WROVER modules.
class NeoPixelLED:
"""
Treats a single WS2812 pixel as a simple on/off LED.
Automatically used as esp_led on boards where INTERNAL_LED_TYPE == 'neopixel'
(e.g. ESP32-S3 DevKit GPIO 48, M5Stack ATOM GPIO 27).
"""espzero includes three runtime warnings designed to save beginners from common hardware pitfalls:
| # | Trigger | Warning |
|---|---|---|
| 1 | Using an ADC2 pin while WiFi is active | [espzero] WARNING: WiFi is active. ADC2 (GPIO N) cannot be used... |
| 2 | Attaching a button to a strapping pin | [espzero] WARNING: GPIO N is a strapping pin. Boot issues may occur. |
| 3 | Running on firmware < 1.19 (no duty_u16) |
Silently falls back to duty() — no crash |
| Board | Built-in LED | ADC1 Pins | ADC2 Pins* | Touch Pins |
|---|---|---|---|---|
| ESP32 DevKit V1 (WROOM) | GPIO 2 (active-low) | 32–39 | 0,2,4,12–15,25–27 | T0(4)–T9(32) |
| ESP32-S3 DevKit | GPIO 48 (RGB) | 1–10 | 11–20 | T1–T14 |
| ESP32-C3 Mini | GPIO 8 (active-low) | 0–4 | None | None |
| M5Stack ATOM Lite | GPIO 27 (RGB) | 33,35,36 | 0,2,4,12–15,25–27 | T0(4),T3(15) |
| Wemos D1 Mini32 | GPIO 2 (active-low) | 32–39 | 0,2,4,12–15,25–27 | T0(4)–T9(32) |
| NodeMCU V3 Lolin (ESP8266) | GPIO 2 (active-low) | A0 only (10-bit, 0–1.0 V) | None | None |
* ADC2 pins cannot be used while WiFi is active. For educational use, stick to ADC1 pins only.
ESP8266 ADC note: A0 accepts 0–1.0 V only. Use a voltage divider (e.g. 220 kΩ + 100 kΩ) to measure 3.3 V signals safely.
Phase 1 — Core port (complete)
[x] profiles/_base.py — BoardProfile abstract base class
[x] profiles/esp32_boards.py — WROOM, S3, C3, M5Stack, Wemos profiles
[x] profiles/auto.py — Runtime auto-detection
[x] _hal.py — HAL wrappers
[x] _core.py — Ported picozero core logic
· PWMOutputDevice: removed channel-collision check
· AnalogInputDevice: ADC read routed through HAL
· pico_led / pico_temp_sensor → esp_led / esp_temp_sensor
[x] __init__.py — begin() + public API
Phase 2 — ESP32-specific features (complete)
[x] _wifi.py — WiFi class
[x] _touch.py — CapTouch class
[x] Additional board profiles — S3, C3, M5Stack, Wemos
Phase 3 — Mu Editor integration (complete)
[x] mu/resources/esp32/ — Library bundled with Mu Editor
[x] mu/logic.py — esp32_lib auto-provisioned to mu_code/
[x] mu/interface/editor.py — Jedi search path includes esp32_lib
(dynamic autocomplete via source analysis)
| # | Topic | Decision |
|---|---|---|
| 1 | ADC2 + WiFi warning | _hal.make_adc() checks _wifi_active flag and prints a warning |
| 2 | duty_u16() fallback |
_hal.set_duty_u16() wrapper silently falls back to duty(val>>6) on firmware < 1.19 |
| 3 | Strapping pin warning | make_digital_in() checks STRAPPING_PINS and prints a warning |
| 4 | NeoPixel built-in LED | INTERNAL_LED_TYPE = "neopixel" profile field selects NeoPixelLED wrapper automatically |
| 5 | Lazy profile loading | _PROFILE_MAP dict + importlib — only the selected board's module is imported |
| 6 | Autocomplete | Jedi analyses live source in mu/resources/esp32/ — no separate static API file needed |
MIT License — contributions welcome.
This library is part of the Mu Editor project for educational IoT programming.