m5stack-core 0.4.0

Board support crate for M5Stack Fire27 and CoreS3 (ESP32/ESP32-S3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# 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]#radio-wifi--ble-driverradio |

#### 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:

```rust
use m5stack_core::driver::radio::wifi::{self, AuthenticationMethod, IpSetup, StaCredentials};

let wifi = wifi::Wifi::new(peripherals.WIFI)?;
let (stack, control, runner) = wifi.into_sta(
    StaCredentials { ssid, password, auth: AuthenticationMethod::Wpa2Personal },
    IpSetup::Dhcp,                                       // or IpSetup::Static(..)
    seed,
    make_static!(embassy_net::StackResources::<3>::new()),
)?;
spawner.spawn(wifi::wifi_task(runner).unwrap());        // 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` — debounced `async-button` over 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 via
  `into_async()`), buttons, radio, console peripherals, free M5-Bus pins, and
  `SystemResources` (timers, SW interrupts, `CPU_CTRL`, `LPWR`).
- `board::display` (feature `display`) — 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).
  The SD-card *driver* (`sdspi`) stays an application dependency until it is
  published on crates.io; see the `board::spi2` module 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!`:

```rust
use m5stack_core::mem::{self, HeapProfile};

mem::init_heap(HeapProfile::Default, Some(board.psram)); // reclaimed + DRAM + PSRAM
mem::init_heap(HeapProfile::Lvgl, None);                 // reclaimed-ROM only, no PSRAM
mem::init_heap(HeapProfile::Coex, Some(board.psram));    // 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:

```rust
use m5stack_core::mem;

let psram_free = mem::init_psram_heap(peripherals.PSRAM);
let mut big = mem::psram_vec::<u8>(512 * 1024);  // in PSRAM; atomics rejected at compile time
let scratch = mem::psram_box([0u32; 1024]);      // in PSRAM
let dma = mem::dma_buffer(4 * 1024);             // 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.

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 the `log`
  backend at a level and (when `Config::serial` is `Some`) build the chip
  transport (UART0 @ 1 Mbaud on Fire27, USB-Serial-JTAG CDC on CoreS3) + spawn
  the drain. Returns `Console { rx }` whose RX half feeds `serial_cmd` (log TX +
  command RX share one port — no debug probe needed). `serial: None` is the R9
  production backstop. Replaces the old `init`/`setup`/`drain_task`/`enable_async`
  sequence (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 the **`panic-handler`** feature.
- **`take_panic_breadcrumb() -> Option<PanicCrumb>`** — call once at boot, before
  `install`, and log the result: a previous-run panic survives reset and is
  reported once as the `PREV_PANIC` line — the cross-transport fault contract,
  identical on both targets.
- **`send_line(Arguments)`** — back-pressuring bulk-dump emit (the `:cat`
  read-back) and the **R10 injection point**: a control crate defines its own
  `LineSink` trait and the binary forwards to `send_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

```rust
// io::rpm
pub struct RpmConfig { pub loop_time_ms: u64, pub pole_pairs: f32, pub pulley_ratio: f32 }
pub fn read_rpm(pcnt: &mut PcntDriver, config: &RpmConfig) -> f32
pub async fn rpm_loop(resources: RpmResources<'static>, config: RpmConfig, on_rpm: fn(f32))

// io::pps
pub struct PpsReadings { pub voltage: f32, pub current: f32, pub temperature: f32, ... }
pub struct PpsSetpoint { pub current_limit: Option<f32>, pub voltage_limit: Option<f32>, pub enabled: Option<bool> }
pub async fn pps_loop(resources: PpsResources, on_read: fn(&PpsReadings), get_setpoint: fn() -> PpsSetpoint)

// io::ow_temp
pub async fn ow_loop(resources: OnewireResources<'static>, on_temperatures: fn(&[(u64, f32)]))
```

## 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>`:

```bash
# Fire27 (ESP32) — the default board; default target is xtensa-esp32-none-elf
cargo +esp run --release -p demos --bin <name>

# CoreS3 (ESP32-S3)
cargo +esp run --release -p demos --bin <name> \
  --no-default-features --features cores3 --target xtensa-esp32s3-none-elf
```

| 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_PASSWORD` are read at build time; unset → WiFi is skipped
  and the display still runs. The `m5go` bin needs the M5GO Battery Bottom
  attached to do anything visible.
- `onewire` is **Fire27-only** (`required-features = ["fire27"]`); a CoreS3
  build skips it automatically. `lvgl` and `coex` are gated by
  `required-features` so a plain `cargo build -p demos` (and `--workspace`)
  skips them (and the LVGL C build).
- **Build `coex` on 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-initialising `coex` bin can satisfy, so enabling `--features
  coex` while building the *other* (non-BLE) bins fails to link with a cryptic
  `btdm_rf_bb_reg_init`. Because the bin is gated by `required-features`, the
  `coex` feature stays off for every normal build (`cargo build`,
  `--workspace`, `-p demos`, per-bin) — only an explicit `--features coex`
  without `--bin coex` hits it, which is why the coex bin is always built alone.
- **The `sd` bin** is gated by `--features sd` (it pulls the `sdspi` +
  `embedded-fatfs` fork, which isn't on crates.io, so it's example-only). It's
  the one demo that exercises `board::spi2::finish()` — 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 a `StreamSlice`) 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
  the `display` bin's readout loop is identical. The `display` bin also queries
  `io::input_caps()` (#32) and logs the board's input model.
- **Logging goes through the BSP console** (`io::console::install`, the
  `console-serial` + `panic-handler` features) on both boards — Fire27 over
  **UART0 @ 1 Mbaud**, CoreS3 over the **probe-free USB-Serial-JTAG CDC**. No
  `esp-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 (`onewire` displays the
  sensor ROMs + °C; `wifi_sta`/`coex` show the nearby-AP scan). `display` and
  `lvgl` keep their own rendering.

```bash
WIFI_SSID=myssid WIFI_PASSWORD=secret cargo +esp run --release -p demos --bin wifi_sta
cargo +esp run --release -p demos --bin lvgl --features lvgl
cargo +esp run --release -p demos --bin coex --features coex
# CoreS3:
cargo +esp run --release -p demos --bin coex \
  --no-default-features --features cores3,coex --target xtensa-esp32s3-none-elf
```

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](https://github.com/emobotics-dev/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 *plain* `Spi::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`, or `screen /dev/tty… 1000000`), CoreS3 at the default rate on
  the USB-Serial-JTAG CDC port. Flash CoreS3 with `probe-rs download` + `reset`
  (or `espflash`), 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 at `Info`. A panic is reported once at the *next* boot as the
  `[bc] previous panic` breadcrumb line, so a crash is never silently lost.
- `oxivgl-sys` downloads and compiles LVGL 9.5 at build time, so this example
  needs network access, the target C compiler (`xtensa-esp32{,s3}-elf-gcc`) and
  `libclang` for `bindgen` (with `BINDGEN_EXTRA_CLANG_ARGS` pointing 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`](https://github.com/emobotics-dev/esp-hal/tree/local) —
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 in `write_async`
- `fix(spi-dma)` — ESP32 PDMA TX unaligned-length wedge (chained-descriptor fix)
- `fix(spi/dma)` — recover from an RX descriptor fault instead of hanging
- `fix(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 in `ds18b20`)
- **`SharedI2cBus`** wraps `Mutex<RawMutex, I2c>` — safe for single-executor async tasks
- **Resource pattern**: `*Resources` structs bundle peripherals, consumed by `into_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::new` configures 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](LICENSE-MIT)) or **Apache-2.0**
([LICENSE-APACHE](LICENSE-APACHE)) at your option.