cmods
August 13, 2026 · View on GitHub
An optional multi-module workspace layout and build helper for MicroPython and CircuitPython.
cmods makes it easy to build custom firmware containing multiple user C modules side-by-side (such as displayif, lvgl-micropython, and pygraphics):
- MicroPython external C modules (
USER_C_MODULES,micropython.mk/micropython.cmake) - CircuitPython native extensions (
shared-bindings/shared-module, via each repo'sapply_cp_patches.sh)
Note for Experienced Developers:
cmodsis completely optional. If you already have an established build habit (such as standardmake USER_C_MODULES=...or custom CMake workflows), you can continue building MicroPython and CircuitPython exactly as you always have. This workspace is provided as a friction-free aggregator to make multi-module firmware development faster and more convenient.
🚀 Workspace setup
1. Get the tooling
Option A — clone this repo (recommended when starting fresh):
git clone https://github.com/PyDevices/cmods.git
cd cmods
Option B — copy into an existing build workspace (when you already have a directory with MicroPython / CircuitPython / usermods):
Copy the contents of this repo into that workspace root (the directory that
should contain build_mp.sh, manifest-*.py, and optionally patches/). Do
not nest a second cmods/ folder unless you intend that to be the workspace
root.
2. Add the repos you need
Either clone them into the workspace, or clone them as siblings and symlink:
# Into the workspace
git clone https://github.com/micropython/micropython.git micropython
cd micropython && git submodule update --init --recursive && cd ..
# Or as siblings + symlink (example layout: ../micropython next to the workspace)
ln -s ../micropython micropython
Repeat for each usermod or runtime you want (displayif, pygraphics,
lvgl-micropython, circuitpython, …). Each MicroPython usermod must be an immediate subdirectory of the workspace (clone or symlink) and provide a micropython.mk there (optional manifest.py for frozen Python).
Patches (optional; naming convention)
patches/ is optional. When present, build_mp.sh applies every
file whose name contains micropython-<port> for the selected --port (e.g.
micropython-unix, micropython-windows). This workspace currently ships two
such patches (windows networking/SSL, unix scheduler depth). Other ports find
no matches and skip. Details: patches/README.md.
Quick build (after setup)
# Optional — only for LVGL
git clone https://github.com/PyDevices/lvgl-micropython.git lvgl-micropython
git clone https://github.com/PyDevices/lvgl-bindings.git lvgl-bindings
cd lvgl-bindings && git submodule update --init lvgl && cd ..
./lvgl-bindings/regenerate_lvmp.sh
./build_mp.sh --port unix --variant standard
The LVGL clone and regenerate_lvmp.sh steps are optional — use them only
when building with LVGL. For other user C modules, add those repos (or
symlinks) instead.
How it works
USER_C_MODULES=$(pwd)— MicroPython discovers*/micropython.mkin immediate subdirectoriesmanifest-micropython.py— frozen Python from cmod sibling repos, then includes the MicroPython upstream freeze viaFROZEN_MANIFEST_UPSTREAMmanifest-circuitpython.py— same aggregator shape for CircuitPython (build_cp.sh)build_mp.sh— setsFROZEN_MANIFEST_UPSTREAMto the freeze file MicroPython would use for the selected port/board/variant (same as a manualmakewithout override)build_cp.sh— auto-discovers*/apply_cp_patches.sh(optional extensions) and usesmanifest-circuitpython.pyfor all portsmicropython.cmake— aggregates*/micropython.cmakefor CMake ports (ESP32, RP2)
Build scripts
| Script | Role |
|---|---|
build_mp.sh | Any MicroPython port (interactive or --port / --board / --variant) |
build_cp.sh | CircuitPython ports (interactive or --port / --board / --variant) |
build_runtimes.sh | Desktop/wasm interpreters for local pydevices-examples work — see below |
Examples:
./build_mp.sh # interactive
./build_mp.sh --port unix --variant standard
./build_mp.sh --port rp2 --board RPI_PICO2_W
./build_mp.sh --port esp32 --board ESP32_GENERIC_P4 --variant C6_WIFI
./build_cp.sh # interactive
./build_cp.sh --port unix --variant coverage
./build_runtimes.sh --only mp-unix,cp-unix
build_runtimes.sh builds the host interpreters used by PyDevices and installs them under workspace bin/ (micropython, micropython.exe, circuitpython, and the wasm micropython.{mjs,wasm} pair). Targets are mp-unix, mp-windows, mp-wasm, and cp-unix. When the pydevices core repo sits as a sibling of this workspace, the script copies the built binaries directly into pydevices/bin/ to publish them. Use --only to build a subset, or --install-only to refresh installs from an existing build. Re-run after changing usermods (or frozen manifests) that link into these binaries.
Desktop SDL (usdl2): when displayif is present,
MicroPython unix / windows and CircuitPython unix link native import usdl2
from that repo (not a separate usdl2 usermod). Unix needs libsdl2-dev. Windows
needs an unpacked SDL2 MinGW development ZIP
under the workspace (e.g. SDL2-2.30.10/); build_mp.sh auto-sets SDL2_DEV
or you can export it (see displayif tools/sdl2_dev_env.sh).
🎨 Hardware example: ESP32-P4 display + touch
End-to-end bring-up for the
Waveshare ESP32-P4-WIFI6-Touch-LCD-4B
(4″ 720×720 ST7703 on MIPI DSI, GT911 on I2C) using displayif + pydevices-examples.
This is not stock MicroPython — firmware must include the displayif mipidsi
cmod.
Board configs (pydevices):
| Runtime | Path |
|---|---|
| MicroPython | board_configs/fbdisplay/esp32-p4-wifi6-touch-lcd-4b |
| CircuitPython | board_configs/cp/fbdisplay/esp32-p4-wifi6-touch-lcd-4b |
git clone https://github.com/PyDevices/displayif.git displayif
# C6_WIFI — this board’s ESP32-C6 WiFi/BLE coprocessor (use C5_WIFI if yours is C5)
./build_mp.sh --port esp32 --board ESP32_GENERIC_P4 --variant C6_WIFI
build_mp.sh can flash when the build finishes (offset from board.json,
0x2000 for ESP32_GENERIC_P4). Manual flash:
esptool -b 460800 --before default_reset --after hard_reset \
write_flash 0x2000 micropython/ports/esp32/build-ESP32_GENERIC_P4/firmware.bin
The cmods build discovers pydevices/manifest.py and freezes the
core product packages. Install the matching board config (and optional
examples from pydevices-examples) with mpremote:
mpremote mip install --target "." \
"github:PyDevices/pydevices/board_configs/fbdisplay/esp32-p4-wifi6-touch-lcd-4b"
# optional: mpremote mip install --target "./examples" "github:PyDevices/pydevices-examples/packages/examples.json"
Smoke checks:
mpremote run displayif/tools/test_mipidsi_smoke.py
import board_config
import eventsys
display_drv = board_config.display_drv
runtime = eventsys.Runtime.from_board_config(board_config)
display_drv.fill_rect(0, 0, 200, 200, 0xF800)
display_drv.show()
# Touch: poll until quit
while not runtime.quit_requested:
for e in runtime.poll():
print(e)
Pinout matches the Waveshare BSP (reset 27, backlight 26, I2C 7/8,
GT911 @ 0x5D). If the panel stays black, check backlight polarity
(backlight_on_high=False in the board config) and the displayif P4 DSI LDO
path (channel 3 @ 2.5 V). Validate display/touch over USB serial before WiFi.
Related repos
| Repo | Role |
|---|---|
| lvgl-micropython | LVGL MicroPython glue |
| lvgl-bindings | LVGL binding generator |
| lvgl-circuitpython | LVGL CircuitPython glue (separate workflow) |
CircuitPython does not use USER_C_MODULES. Clone lvgl-circuitpython into this workspace if you want CP and MP trees side by side.
CircuitPython (optional extensions; see lvgl-circuitpython README for CP clone setup):
Native CP modules here follow Adafruit’s
Extending CircuitPython
architecture (shared-bindings / shared-module / CIRCUITPY_*), but stay
out-of-tree: each extension keeps spikes in its own repo and
apply_cp_patches.sh copies them into a local (uncommitted) CircuitPython tree.
Adafruit’s Learn guide assumes in-tree edits; there is no official out-of-tree
C-module path. Per-repo READMEs map Learn steps → spikes/patches.
./build_cp.sh --port unix --variant standard
build_cp.sh runs every sibling */apply_cp_patches.sh when present (pygraphics, LVGL, …). Clone only the extensions you need.
Optional: place a user_post_mpconfigport.mk at the workspace root (CircuitPython’s
user-config hook; build_cp.sh passes -I when it exists) to freeze Adafruit
asyncio/ticks for multimer.AsyncTimer. See CircuitPython building.md
and multimer building docs.
MicroPython frozen asyncio (required for multimer.AsyncTimer on unix/windows):
# pydevices/manifest.py is discovered automatically.
# Add only personal extras to the optional manifest-user.py.
./build_mp.sh --port unix --variant standard
./build_mp.sh --port windows --variant dev
Direct build (without this tooling)
Create any workspace directory, clone micropython and the usermods you need as
siblings (or symlink them), and build from micropython/ with USER_C_MODULES
pointing at the workspace root. See each usermod’s README (e.g.
lvgl-micropython).