Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
m5stack-core
Board support crate for M5Stack Fire27 (ESP32) and CoreS3 (ESP32-S3).
Provides chip-agnostic drivers, shared I2C bus, and reusable async IO task loops with fn(...) callbacks.
Features
| Feature | Target | Chip |
|---|---|---|
fire27 |
xtensa-esp32-none-elf |
ESP32 |
cores3 |
xtensa-esp32s3-none-elf |
ESP32-S3 |
Exactly one board feature must be enabled. Orthogonal opt-ins:
| Feature | Enables | Pulls in |
|---|---|---|
display |
board::display (ILI9342C bring-up) + board::spi2 device construction |
lcd-async, embassy-embedded-hal |
buttons |
io::buttons::Buttons — debounced Fire27 front-panel driver |
async-button |
heap |
mem::init_heap — BSP owns the global heap (DRAM regions + HIL-proven per-board sizes) |
esp-alloc, esp-bootloader-esp-idf |
psram |
external-PSRAM region + checked psram_box/psram_vec (implies heap) |
(via heap) |
console-serial |
the io::console serial transport (UART0 / USB-Serial-JTAG); off = R9 production backstop (no serial symbols, panic still breadcrumbs + halts) |
— |
panic-handler |
BSP-provided #[panic_handler] → io::console::on_panic (opt in, or supply your own) |
— |
multicore |
board::run_app_core — park + start the APP core on an esp-rtos InterruptExecutor |
esp-rtos |
serial-cmd |
io::serial_cmd HIL command endpoint |
— |
search-masks |
masked 1-Wire ROM search | — |
ble/wifi/wifi-sta/coex |
radio (see the radio section) | embassy-net (sta) |
A binary built on this BSP can be a thin entry shell: mem::init_heap owns the
heap, io::console::install owns logging + the panic breadcrumb, the
panic-handler feature provides #[panic_handler], app_desc! the descriptor,
board::run_app_core the second core, and io::input_caps() the input model —
so per-board boilerplate stays in the BSP, not the application.
Modules
Drivers (driver::)
| Module | Description |
|---|---|
pcnt |
Pulse counter wrapper for RPM sensing (PcntDriver) |
pps |
Programmable Power Supply I2C driver (0x35) — voltage, current, temperature |
ds18b20 |
1-Wire temperature sensor via RMT (chip-specific RMT channel selection) |
aw9523b |
I2C GPIO expander (CoreS3, 0x58) — LCD/touch reset pulses, M-Bus 5 V enable (enable_bus_5v) |
axp2101 |
PMIC (CoreS3, 0x34) — backlight voltage, battery ADC, VBUS detection |
ft6336u |
Capacitive touch controller (0x38) — stateless read_touch() |
ip5306 |
Fire27 / classic-Core battery gauge (I2C 0x75) — coarse battery %, charge / charge-full flags (CoreS3 uses axp2101 instead) |
sk6812 |
M5GO Battery Bottom RGB LED bars (SK6812/WS2812 via RMT) — write() a colour frame |
radio |
Shared radio (esp-radio). Parent of radio::ble (BLE BleConnector) and radio::wifi (WiFi controller + STA stack) — see WiFi + BLE |
M5GO Battery Bottom
The M5GO Battery Bottom plugs into the M-Bus and adds a LiPo cell and ten RGB LEDs (the A014 "Base M5GO Bottom" uses SK6812; the CoreS3-matched A014-D "Bottom3" uses WS2812 — the RMT driver drives both). The LED data line sits on a fixed physical M-Bus pin (pin 23) that maps to a different GPIO per core:
| Fire27 (ESP32) | CoreS3 (ESP32-S3) | |
|---|---|---|
| RGB LEDs (RMT, M-Bus pin 23) | GPIO15 |
GPIO13 |
| Battery | ip5306 @ I2C 0x75 (onboard) |
axp2101 @ I2C 0x34 (onboard) |
| LED 5 V rail | always present | must be enabled via aw9523b |
The LEDs are a one-wire NRZ protocol (RMT), not I2C. Battery management
differs by board: the Fire (and the PMIC-less classic Core, via the bottom's
own IP5306) report through an IP5306 at 0x75; the CoreS3 manages the
cell — including the bottom's battery — with its onboard AXP2101 at 0x34, so
a bottom's IP5306 does not appear on the CoreS3 I2C bus.
CoreS3 — powering the LEDs. The bottom's LEDs are fed from the CoreS3 M-Bus
5 V rail, which is off by default and gated by the AW9523 expander. Call
[Aw9523bDriver::enable_bus_5v] to bring it up — it asserts BOOST_EN (P1_7) and
BUS_OUT_EN (P0_1) high (both active-HIGH; P0 must be switched to push-pull
first, as it is open-drain by default). Guard it as M5Unified does: only enable
when a battery is present or USB is absent (the bus output shares the USB
VBUS node, so enabling it with no battery on USB contends the rail). Note the
A014 bottom is a classic-Core part — it can't sustain the CoreS3 on battery
(the board powers down on unplug), so in practice it runs on USB with the
bottom's battery present. The CoreS3-matched bottom is the Bottom3 (A014-D).
Both examples drive a colour-wheel animation on the bars and show the battery reading on the LCD (Fire27: IP5306 %; CoreS3: AXP2101 mV).
Radio: WiFi + BLE (driver::radio)
The on-package radio is shared between BLE and WiFi, modelled as
sub-modules of driver::radio and gated by cargo features so a binary only
compiles (and pays the RAM for) the radios it uses. All WiFi is async.
| Feature | Enables | Pulls in |
|---|---|---|
ble |
radio::ble::BleRadio — BleConnector (HCI transport) |
— |
wifi |
radio::wifi::Wifi — controller + scan() (no IP stack) |
— |
wifi-sta |
Wifi::into_sta() → embassy_net::Stack (STA + DHCP/static) |
embassy-net |
wifi-ap |
reserved for AP mode (not yet implemented) | embassy-net |
coex |
run BLE and WiFi simultaneously (implies wifi + ble) |
extra RAM |
The BSP exposes only the BLE controller (BleConnector); the BLE host stack
(trouble-host) is an application dependency — see the cores3 coex example. On
this esp-hal 1.1 line BleConnector speaks bt-hci 0.8, so pair it with
trouble-host 0.6 (the older 0.5 / bt-hci 0.6 line won't bind). coex costs
significant heap (~96 KB reclaimed on ESP32); enable it only when both radios run
together. esp_rtos::start(..) must run before any radio is created.
STA bring-up. The BSP owns the controller + net runner; the app supplies a
seed (from its own TRNG — the BSP leaves RNG/ADC1 free) and a
static-lifetime StackResources, then spawns one task:
use ;
let wifi = new?;
let = wifi.into_sta?;
spawner.spawn; // manages assoc + runs the stack
stack.wait_config_up.await; // IP acquired
let aps = control.scan.await?; // scan while associated
wifi_task is the single owner of the controller: it auto-connects, reconnects
on link loss, and serves WifiControl commands (scan/connect/disconnect)
so scanning never races association. Scan-only firmware can skip wifi-sta and
call Wifi::scan() directly. AP mode is a planned extension point (into_ap +
Config::AccessPoint).
Variant note: the esp-radio WiFi API is identical on both chips; only RAM
differs. The ControllerConfig RX buffers are trimmed on Fire27 (ESP32). Fire27
cannot DMA from PSRAM — keep StackResources/socket buffers in internal RAM.
IO Tasks (io::)
Async task loops using embassy_time::Ticker with fn(...) callbacks for decoupled integration.
| Module | Loop interval | Callback |
|---|---|---|
rpm |
configurable | fn(f32) — RPM value |
pps |
500 ms | fn(&PpsReadings) + fn() -> PpsSetpoint |
ow_temp |
3 s | fn(&[(u64, f32)]) — address/temperature pairs |
shared_i2c |
— | SharedI2cBus async mutex for multi-task I2C access |
Input (io::buttons, io::touch_buttons)
Both input flavours emit the same ButtonEvent { id: Left/Center/Right, action: Short(taps)/Long }, so an application maps input in one place for both boards:
- Fire27 (feature
buttons):ButtonResources::into_buttons()→Buttons::next_event().await— debouncedasync-buttonover the physical A/B/C keys (GPIO39/38/37). - CoreS3:
TouchButtons::new(i2c, TouchButtonsConfig::default())→next_event().await— the FT6336U bottom strip split into three zones, with short/multi-tap/long-press detection.
Watchdog (io::watchdog)
watchdog_feed_loop(rtc, timeout_secs, feed_every_secs) arms the RWDT
(hardware reset on timeout) and feeds it from the calling executor — the
backstop for a fully wedged executor.
Board bring-up (board::)
board::init()— esp-hal at max CPU clock (heap setup stays with the app).board::fire27::Board::split(peripherals)/board::cores3::Board::split(...)— the boards' pin wiring as data:spi2(display+SD bus resources),i2c0(hardened config; returned blocking so the app binds the IRQ core viainto_async()), buttons, radio, console peripherals, free M5-Bus pins, andSystemResources(timers, SW interrupts,CPU_CTRL,LPWR).board::display(featuredisplay) — ILI9342C panel init shared by both boards;board::spi2— the shared display+SD bus with the bring-up ordering encoded (display first, bounded SD attempts, CoreS3 GPIO35 MISO/DC re-mux).Spi2Parts::finish_sd(card_cs, presence)is the one-call SD bring-up: it runs the mandatory ≥74-clock SD power-up idle on the still-exclusive bus, brings the display up unconditionally, and returns a presence-resolvedPreparedCard<CS>— the app supplies only its SD driver (SdSpi::new(prepared.into_inner())) plus retry/degrade policy.finishremains the lower-level primitive.CardPresence::{Detect, ForceAbsent}forces the absent-card degrade path with a card physically inserted (HIL), via thePresenceCs<CS>frozen chip-select carried insidePreparedCard.- The SD-card driver (
sdspi) stays an application dependency until it is published on crates.io; see theboard::spi2module docs for the wiring pattern.
board::cores3::power_display_reset— AW9523B LCD/touch reset pulses + AXP2101 backlight rail over the shared I2C bus.
Memory (mem::)
The BSP owns the global heap (feature heap, implied by psram): one call,
mem::init_heap(profile, psram), declares the esp-alloc DRAM regions for a
HeapProfile (the HIL-proven per-board sizes) and, given Some(psram),
registers external PSRAM — so a binary never spells out esp_alloc::heap_allocator!:
use ;
init_heap; // reclaimed + DRAM + PSRAM
init_heap; // reclaimed-ROM only, no PSRAM
init_heap; // more controller heap
PSRAM specifics, behind the psram feature. Both boards have external SPI
PSRAM (Fire27 ~4 MB, CoreS3 ~8 MB); init_heap(_, Some(psram)) (or
mem::init_psram_heap(psram) directly) maps it as an external region of the
esp-alloc global heap. Applications can then allocate from it either
implicitly (the global allocator spills into PSRAM after internal DRAM) or
explicitly — preferably via the checked helpers:
use mem;
let psram_free = init_psram_heap;
let mut big = ; // in PSRAM; atomics rejected at compile time
let scratch = psram_box; // in PSRAM
let dma = dma_buffer; // in internal DRAM; DMA-safe
The raw markers ExternalMemory / InternalMemory are still re-exported for
direct allocator_api2 use, but they skip the atomic check — use them only when
you know what's going into PSRAM.
For a private PSRAM region — one to hand to a foreign allocator (e.g.
LVGL's built-in TLSF via lv_mem_add_pool) rather than the shared global heap —
use mem::psram_split, which splits mapping from registering:
use mem;
// Carve 512 KiB private off the base; register the remainder with the global heap.
let split = psram_split?;
// `split.private: &'static mut [MaybeUninit<u8>]` — no unsafe at the call site,
// base is large-aligned. `split.global_free` = external bytes now in the heap.
reserve: None makes the whole region private (nothing global); it returns
Result<PsramSplit, PsramSplitError> (NotMapped, or TooSmall { available }).
init_psram_heap (map + register all-global) is unchanged and kept alongside.
For headroom checks, mem::internal_free() / mem::external_free() return the
global heap's free internal-DRAM / external-PSRAM bytes (O(1)) without a binary
touching esp_alloc.
The three hardware caveats are now mostly enforced rather than just documented:
| Caveat | Enforcement |
|---|---|
No Atomic* in PSRAM (broken atomic RMW on ESP32/-S3) |
Compile-time — psram_box/psram_vec bound T: PsramSafe, a Send/Sync-style auto trait with negative impls for the atomics. A type embedding an atomic (directly or transitively) won't compile. |
| ESP32 (Fire27) can't DMA out of PSRAM | Runtime debug_assert — mem::assert_dma_capable(buf) rejects a PSRAM-backed buffer on Fire27 (no-op on CoreS3, which can DMA from PSRAM). Use mem::dma_buffer(n) to get an internal-DRAM buffer. |
PSRAM timing needs opt-level > 0 |
Build-time — build.rs fails the build if the psram feature is on at opt-level = 0. Both profiles already use "s". |
PsramSafe requires the esp toolchain's auto_traits + negative_impls
(enabled only when psram is on). No esp-hal Cargo feature is required — PSRAM
itself is available under the already-enabled unstable feature.
Serial console (io::console)
The complete async logging console for the firmware — both the target-agnostic
pipeline AND the per-target hardware. No esp-println/esp-backtrace/RTT.
install(spawner, Config) -> Console— the one call: register thelogbackend at a level and (whenConfig::serialisSome) build the chip transport (UART0 @ 1 Mbaud on Fire27, USB-Serial-JTAG CDC on CoreS3) + spawn the drain. ReturnsConsole { rx }whose RX half feedsserial_cmd(log TX + command RX share one port — no debug probe needed).serial: Noneis the R9 production backstop. Replaces the oldinit/setup/drain_task/enable_asyncsequence (still public for advanced use).markers—PANIC/CONSOLE_DROP/PREV_PANIC, the stable strings the HIL crash detector greps; treat as contract.on_panic(&PanicInfo) -> !— writes an RTC-persistent breadcrumb ({file, message digest}) before a best-effort transport print, then halts (the RWDT recovers). Installed for you by thepanic-handlerfeature.take_panic_breadcrumb() -> Option<PanicCrumb>— call once at boot, beforeinstall, and log the result: a previous-run panic survives reset and is reported once as thePREV_PANICline — the cross-transport fault contract, identical on both targets.send_line(Arguments)— back-pressuring bulk-dump emit (the:catread-back) and the R10 injection point: a control crate defines its ownLineSinktrait and the binary forwards tosend_line, so the trait never enters the BSP.
With the console-serial feature off (R9), the transport compiles out
entirely: log! is a no-op into the ring, install only registers the backend,
and on_panic still breadcrumbs + halts — zero serial surface.
Key types
// io::rpm
pub async pub async
Examples
All examples live in one crate, examples/demos — small, single-topic
binaries (one subsystem each) that build for both boards, with the board
selected by a cargo feature: fire27 (default) or cores3. Each bin leans on
the BSP's Board::split + board::display + io loops, so the board-specific
wiring lives in the BSP and the per-board duplication is gone; the per-bin glue
that remains is concentrated in examples/demos/src/board.rs. Chip-agnostic
drawing helpers (colour wheel, splash/status rendering, I2C scan) stay in the
shared examples/common crate.
Select a board by feature and a bin with --bin <name>:
# Fire27 (ESP32) — the default board; default target is xtensa-esp32-none-elf
# CoreS3 (ESP32-S3)
| bin | what it shows | needs |
|---|---|---|
display |
unified front-panel events (Fire27 buttons / CoreS3 touch) + input_caps() |
— |
i2c_scan |
I2C bus scan (0x08..0x77), addresses on LCD | — |
m5go |
SK6812 LED colour-wheel (M-Bus pin 23) + battery readout | M5GO bottom attached |
wifi_sta |
WiFi STA + DHCP + AP scan, IP on LCD | WIFI_SSID/WIFI_PASSWORD |
onewire |
DS18B20 1-Wire temperature read over RMT (G26) | Fire27 only |
lvgl |
LVGL (oxivgl) UI: 3 focusable buttons navigated by the front panel + frame counter | --features lvgl |
coex |
wifi_sta plus a BLE peer-MAC scanner |
--bin coex --features coex, --release |
sd |
mount the SD card on the shared SPI2 bus + list the root dir (read-only) | --features sd |
multicore |
runs a task on the APP core via board::run_app_core (interleaved PRO/APP ticks) |
--features multicore |
Notes on building:
WIFI_SSID/WIFI_PASSWORDare read at build time; unset → WiFi is skipped and the display still runs. Them5gobin needs the M5GO Battery Bottom attached to do anything visible.onewireis Fire27-only (required-features = ["fire27"]); a CoreS3 build skips it automatically.lvglandcoexare gated byrequired-featuresso a plaincargo build -p demos(and--workspace) skips them (and the LVGL C build).- Build
coexon its own —cargo build -p demos --bin coex --features coex. esp-radio's WiFi/BLE coexist blob is a crate-global link dependency that only the BLE-initialisingcoexbin can satisfy, so enabling--features coexwhile building the other (non-BLE) bins fails to link with a crypticbtdm_rf_bb_reg_init. Because the bin is gated byrequired-features, thecoexfeature stays off for every normal build (cargo build,--workspace,-p demos, per-bin) — only an explicit--features coexwithout--bin coexhits it, which is why the coex bin is always built alone. - The
sdbin is gated by--features sd(it pulls thesdspi+embedded-fatfsfork, which isn't on crates.io, so it's example-only). It's the one demo that exercisesboard::spi2::finish_sd— the display + SD shared bus, including the CoreS3 GPIO35 MISO/DC mux. It mounts read-only (never writes, so it won't touch existing logs) and handles both MBR-partitioned cards (mounts the first FAT partition via aStreamSlice) and superfloppies (FAT at sector 0); a dead/absent card still lets the display come up. - Input is unified: both boards emit the same
ButtonEvent(Fire27 reads the three buttons; CoreS3 splits the FT6336U touch strip into three zones), so thedisplaybin's readout loop is identical. Thedisplaybin also queriesio::input_caps()(#32) and logs the board's input model. - Logging goes through the BSP console (
io::console::install, theconsole-serial+panic-handlerfeatures) on both boards — Fire27 over UART0 @ 1 Mbaud, CoreS3 over the probe-free USB-Serial-JTAG CDC. Noesp-println/esp-backtrace/RTT; the bins carry no panic boilerplate (the BSP provides#[panic_handler]+app_desc!). A panic is recoverable across reset via the RTC breadcrumb (logged once at next boot as[bc] previous panic). - The sensor/peripheral demos all render through one
common::draw_panel(a cyan board/title header + body lines) so they look alike, and each shows everything it has on screen — not just the console (onewiredisplays the sensor ROMs + °C;wifi_sta/coexshow the nearby-AP scan).displayandlvglkeep their own rendering.
WIFI_SSID=myssid WIFI_PASSWORD=secret
# CoreS3:
GPIO — Fire27: I2C SDA=21/SCL=22, SPI CLK=18/MOSI=23/MISO=19, Display CS=14/DC=27/RST=33/BL=32, Buttons=39/38/37, M5GO LEDs=15, IP5306@0x75. CoreS3: I2C SDA=12/SCL=11, SPI CLK=36/MOSI=37, Display CS=3/DC=35, RST via AW9523B, BL via AXP2101 DLDO1, M5GO LEDs=13, AXP2101@0x34.
The lvgl bin
Drives the panel with oxivgl
(safe LVGL 9 bindings) instead of embedded-graphics, with the SPI flush on a
high-priority InterruptExecutor so the UI stays smooth while the main task
works. It's interactive: three focusable buttons that you navigate from the
front panel — PREV/NEXT move focus, ENTER clicks (a counter ticks up).
The input path is identical on both boards and is the point of the demo: the
unified io::buttons::ButtonEvent (Fire27 physical buttons / CoreS3 FT6336U
touch zones) is mapped to LVGL navigation keys feeding an oxivgl KeypadState,
which run_app_nav_keypad_events reads and routes to the view's focus group.
Per-board bring-up is board::lvgl_bringup (display via
board::spi2::into_display_only + the right Input); the flush glue, view, and
the input adapters live in examples/demos/src/ui/, so src/bin/lvgl.rs is
only the wiring. CoreS3 additionally drives a direct-touch POINTER indev
(oxivgl 0.5 PointerIndev, fed by an async FT6336U poll task that bridges the
I2C read into the indev's sync read callback), so on-screen widgets can be
tapped by coordinate — the bottom-strip keys stay on the button API for
focus-nav (multi-tap / long-press, multi_tap_ms tuned to 150 ms) and taps
land anywhere on screen (#32 I3).
Notes:
- The flush uses an explicit
SpiDmaBus(.with_dma/.with_buffers): Fire27 drives SPI2 on GPIO18/23 over PDMA (a plainSpi::into_async()flush goes "usr-stuck" after the first frame; a descriptor-backed DMA bus avoids it); CoreS3 drives SPI2 on GPIO36/37 over GDMA, with the panel reset via the AW9523 expander and the backlight via the AXP2101 (no GPIO reset/backlight pins). - Logging is the BSP console on both boards (
io::console, no probe). Read it over the serial port — Fire27 at 1 Mbaud (e.g.espflash monitor --baud 1000000, orscreen /dev/tty… 1000000), CoreS3 at the default rate on the USB-Serial-JTAG CDC port. Flash CoreS3 withprobe-rs download+reset(orespflash), then read the CDC port, not RTT. Never emit a per-frame log stream over the console — an undrained UART/CDC back-pressures the ring and can stall the render loop (HIL-confirmed at oxivgl Trace level); keep the LVGL demo atInfo. A panic is reported once at the next boot as the[bc] previous panicbreadcrumb line, so a crash is never silently lost. oxivgl-sysdownloads and compiles LVGL 9.5 at build time, so this example needs network access, the target C compiler (xtensa-esp32{,s3}-elf-gcc) andlibclangforbindgen(withBINDGEN_EXTRA_CLANG_ARGSpointing at the newlib sysroot) — all provided by the devcontainer.
Dependencies & the esp-hal fork
The library depends only on stock crates.io crates (esp-hal 1.1.1,
esp-radio 0.18.0, esp-sync, esp-alloc) — it uses no fork-specific API
(the 1-Wire-over-RMT driver is vendored in-tree; see driver::onewire).
The examples, and all local workspace builds, are redirected to a fork —
emobotics-dev/esp-hal —
via [patch.crates-io]. The fork is esp-hal 1.1.1 plus a small set of ESP32
fixes not yet upstream, primarily SPI-DMA correctness that the LVGL
display example's SpiDmaBus flush depends on:
feat(spi)— zero-copy DMA inwrite_asyncfix(spi-dma)— ESP32 PDMA TX unaligned-length wedge (chained-descriptor fix)fix(spi/dma)— recover from an RX descriptor fault instead of hangingfix(spi)— bound the ESP32 post-DONE busy-re-wake (silent SD-card wedge)
plus assorted ESP32 robustness fixes (linker stack-guard sizing, I2C NACK
handling, esp-println UART critical-section bound).
Both the [patch] and the example git dependencies are pinned to a commit
rev, not a branch, so builds are reproducible. cargo publish ignores
[patch], so a published m5stack-core resolves the plain crates.io versions.
The example UI crate uses the crates.io oxivgl 0.5 / oxivgl-sys 0.2
releases (no git pin).
Roadmap: upstream these patches; once they land in a released esp-hal, the
fork and the [patch] are dropped.
Design
- Chip differences handled via
#[cfg(feature = "...")](e.g. RMT channel inds18b20) SharedI2cBuswrapsMutex<RawMutex, I2c>— safe for single-executor async tasks- Resource pattern:
*Resourcesstructs bundle peripherals, consumed byinto_driver()or task loops - IO loops use error counting with threshold (e.g. PPS breaks after 10 consecutive errors)
- GPIO35 (CoreS3): GPIO35 is the display DC line (and is hardware-shared with SPI2 MISO). The cores3 example uses no SD/MISO, so it drives DC as a plain
Output—Output::newconfigures the pad's IO-MUX so the pin actually drives. (A consumer that also needs MISO on the same bus, like alternator-regulator's SD card, must instead claim GPIO35 as MISO and toggle DC via register-level muxing.)
License
Licensed under either of MIT (LICENSE-MIT) or Apache-2.0 (LICENSE-APACHE) at your option.