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 espzeroimportespzeroespzero.begin() # initialise: auto-detect board# Built-in LED — available after begin()fromespzeroimportesp_ledfromtimeimportsleepwhileTrue:
esp_led.on()
sleep(1)
esp_led.off()
sleep(1)Specify a board explicitly instead of auto-detection:
importespzeroespzero.begin("esp32_38pin_nodemcu") # or "esp32_devkit_v1", "esp8266_lolin_v3", ...Use other components after begin():
fromespzeroimportLED, Button, Servo, WiFiled=LED("internal") # built-in LED — profile maps alias to real GPIObtn=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)fromespzeroimportCapTouchtouch=CapTouch(pin=4) # GPIO 4 = T0iftouch.is_touched:
esp_led.on()
# Servoservo=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
classBoardProfile:
NAME="unknown"# Human-readable board nameCHIP="esp32"# esp32 / esp32s3 / esp32c3 / esp8266# Pin aliases — lets users write LED("internal") instead of LED(2)PIN_ALIASES= {}
# ADC settingsADC_MAX_RAW=4095# 12-bit resolution (0–4095)ADC_SCALE=65535# Scale target for picozero read_u16() compatibilityADC_ATTEN=None# e.g. machine.ADC.ATTN_11DB; None = firmware defaultADC_VREF=3.3# Maximum input voltage (tied to attenuation)# PWM settingsPWM_DEFAULT_FREQ=1000# Hz — Pico default was 100 Hz; 1 kHz recommended for ESP32PWM_DUTY_MAX=65535# Internal scale is always 16-bit# ServoSERVO_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 failuresSTRAPPING_PINS= [] # e.g. [0, 2, 5, 12, 15]# ADC2 pins — cannot be used while WiFi is activeADC2_PINS= [] # e.g. [0, 2, 4, 12, 13, 14, 15, 25, 26, 27]classESP32DevKitV1(BoardProfile):
NAME="esp32_devkit_v1"CHIP="esp32"PIN_ALIASES= {"internal": 2, "led": 2}
ADC_ATTEN=ADC.ATTN_11DB# 0–3.6 VADC_VREF=3.6INTERNAL_LED_TYPE="digital"INTERNAL_LED_ACTIVE_HIGH=False# active-lowSTRAPPING_PINS= [0, 2, 5, 12, 15]
ADC2_PINS= [0, 2, 4, 12, 13, 14, 15, 25, 26, 27]
classESP32S3DevKit(BoardProfile):
NAME="esp32_s3_devkit"CHIP="esp32s3"PIN_ALIASES= {"internal": 48, "led": 48}
INTERNAL_LED_TYPE="neopixel"# WS2812 RGB on GPIO 48STRAPPING_PINS= [0, 3, 45, 46]
ADC2_PINS= [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
classESP32C3Mini(BoardProfile):
NAME="esp32_c3_mini"CHIP="esp32c3"PIN_ALIASES= {"internal": 8, "led": 8}
INTERNAL_LED_ACTIVE_HIGH=FalseSTRAPPING_PINS= [2, 8, 9]
ADC2_PINS= [] # C3 has no ADC2importsysdefdetect() ->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()
exceptAttributeError:
return"esp32_devkit_v1"# fallbackif"esp32s3"inmachine_stror"esp32-s3"inmachine_str:
return"esp32_s3_devkit"elif"esp32c3"inmachine_stror"esp32-c3"inmachine_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 connecteddefmake_digital_in(pin, pull_up=False):
"""Create input Pin. Warns if GPIO is a strapping pin."""gpio=resolve_pin(pin)
ifgpioinget_profile().STRAPPING_PINS:
print("[espzero] WARNING: GPIO {} is a strapping pin. ""Attaching a button may cause boot issues.".format(gpio))
...
defset_duty_u16(pwm_obj, value):
"""Set duty cycle. Falls back to duty(0–1023) on firmware < 1.19."""try:
pwm_obj.duty_u16(value)
exceptAttributeError:
pwm_obj.duty(value>>6) # 16-bit → 10-bitdefmake_adc(pin):
"""Create ADC. Warns if WiFi is active and pin is in ADC2 group."""if_wifi_activeandgpioinget_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 conflictsclassPWMOutputDevice(OutputDevice, PinMixin):
def__init__(self, pin, freq=None, duty_factor=65535, ...):
self._pwm=_hal.make_pwm(pin, freq) # routed through HALdef_write(self, value):
_hal.set_duty_u16(self._pwm, self._value_to_state(value)) # with fallbackclassAnalogInputDevice(InputDevice, PinMixin):
def__init__(self, pin, ...):
self._adc=_hal.make_adc(pin) # attenuation applied in profiledef_read(self):
raw_u16=_hal.adc_read_u16(self._adc) # scaled to 0–65535returnself._state_to_value(raw_u16) # upper logic unchanged@propertydefvoltage(self):
returnself.value*_hal.get_profile().ADC_VREF# picozero: pico_led, pico_temp_sensor# espzero:esp_led=LED("internal") # profile resolves "internal" aliasesp_temp_sensor=ESPTemperatureSensor() # uses esp32.raw_temperature()fromespzeroimportWiFiwifi=WiFi()
ip=wifi.connect("MySSID", "password", timeout=10)
print("Connected:", ip) # blocks until connected or raises OSErrorwifi.scan() # returns list of nearby APswifi.is_connected# True / Falsewifi.ip# current IP stringwifi.disconnect()Note: After
connect(), the HAL sets_wifi_active = Trueautomatically.
Any attempt to use an ADC2 pin after this will print a warning.
fromespzeroimportCapTouchtouch=CapTouch(pin=4, threshold=300) # GPIO 4 = T0 on WROOMiftouch.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.
classNeoPixelLED:
""" 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.