Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ad0b058
feat: original M5Stack M5Paper support (panel-IC 3005, FastEPD IT8951…
gcormier Oct 26, 2026
12fa848
feat: RTC power-off sleep via PCF8563/BM8563 + MOSFET latch (M5Paper)
gcormier Oct 26, 2026
207ac66
Merge branch 'greg/awesome-dijkstra-5pq3ha' into greg/awesome-dijkstr…
gcormier Oct 26, 2026
f271dc5
fix(m5paper): load unmirrored IT8951 frames B_ENDIAN; never drive inp…
gcormier Oct 26, 2026
c6f9869
docs: M5Paper hardware bring-up findings (2026-10-26)
gcormier Oct 27, 2026
248b051
Merge branch 'greg/awesome-dijkstra-5pq3ha' into greg/awesome-dijkstr…
gcormier Oct 27, 2026
0b7bf40
fix(m5paper): clear the on-board RTC's interrupts in m5paper_init()
gcormier Oct 27, 2026
d0072af
feat: CMD_POWER_OFF (0x0052) on the MOSFET latch; keep RTC wake flags…
gcormier Oct 27, 2026
27016a4
fix(fastepd): IT8951 fast refresh in 4bpp falls back to full instead …
gcormier Oct 27, 2026
2d9a8f8
Merge branch 'greg/awesome-dijkstra-5pq3ha' into greg/awesome-dijkstr…
gcormier Oct 27, 2026
d672a6b
docs: M5Paper findings: IT8951 readback, 16000 s alarm fire, battery …
gcormier Oct 27, 2026
34548b0
fix(fastepd): bit-reverse 1bpp rows for unmirrored IT8951 panels
gcormier Oct 27, 2026
314e411
Merge branch 'greg/awesome-dijkstra-5pq3ha' into greg/awesome-dijkstr…
gcormier Oct 27, 2026
35ec236
docs: M5Paper findings: 1bpp bit order fixed, mono verified on screen
gcormier Oct 27, 2026
1a94f60
docs: M5Paper findings: 0x0052 power-off verified on battery
gcormier Oct 27, 2026
0cb3e8e
docs: M5Paper findings: power-button hold time about 1.1 s
gcormier Oct 27, 2026
321f3be
feat(m5paper): run at 80 MHz and leave the Grove 5 V boost off
gcormier Oct 28, 2026
f4f73cc
Merge branch 'greg/awesome-dijkstra-5pq3ha' into greg/awesome-dijkstr…
gcormier Oct 28, 2026
54ea4fa
docs: M5Paper findings: battery RTC wakes, 80 MHz and Grove power off
gcormier Oct 28, 2026
32a60fd
docs(m5paper): note how to re-enable the Grove 5 V boost
gcormier Oct 28, 2026
496255d
Merge branch 'greg/awesome-dijkstra-5pq3ha' into greg/awesome-dijkstr…
gcormier Oct 28, 2026
16689b2
docs: M5Paper findings: 80 MHz RTC wakes and upload on battery
gcormier Oct 28, 2026
2c32ae8
docs: M5Paper findings: 16000 s RTC alarm wake on battery
gcormier Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 319 additions & 0 deletions docs/FINDINGS_M5PAPER_BRINGUP_2026-10-26.md

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions include/opendisplay_structs.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
*
* OD_STRUCTS_VERSION 2.1 (MAJOR.MINOR spec marker for THIS file; see
* VERSIONING POLICY below -- NOT sent on the wire)
* LAST CHANGED 2026-08-30
* LAST CHANGED 2026-10-26
*
* The two version schemes carried here are DISTINCT (see §6 Q2 of the plan):
* - OD_STRUCTS_VERSION_* : documents this spec file, like protocol.h's
Expand Down Expand Up @@ -88,6 +88,15 @@
* - SensorType 6 is nPM1300 (already used by another OpenDisplay firmware
* family); SensorType 7 is AHT20, publishing the same 3-byte
* temperature/humidity MSD value as SHT40. GPIO40 gates its supply.
* - PanelIC 3005: OD_PANEL_IC_M5PAPER_960X540 (original M5Stack M5Paper,
* ED047TC1 960x540 behind an IT8951 over SPI; FastEPD IT8951 path).
* Backward-compatible enum addition. Firmware treats the panel type as
* identifying the board (as with 3002-3004) and drives the M5Paper's
* main-power hold and external-power enable itself.
* - SensorType 8: OD_SENSOR_TYPE_PCF8563 (PCF8563 / BM8563 RTC, I2C 0x51).
* Together with OD_DEVICE_FLAG_PWR_LATCH, deep sleep arms the RTC and
* releases the latch; the RTC INT powers the board back on (M5Paper).
* Backward-compatible enum addition; publishes no MSD data.
* - Add each payload-layout change here as it lands. On the next version bump,
* move these under a new "MAJOR.MINOR (YYYY-MM-DD)" heading.
*
Expand Down Expand Up @@ -704,7 +713,8 @@ enum PanelIC {
OD_PANEL_IC_ED103TC2_1872X1404_4GRAY = 3001, /**< @doc "same panel as 3000; 4bpp (16-level gray via FastEPD)" */
OD_PANEL_IC_INKPLATE5V2_1280X720 = 3002, /**< @doc "Soldered Inkplate 5 V2 (ED050WROW, 1280x720, 1bpp; FastEPD native parallel path)" */
OD_PANEL_IC_INKPLATE10_1200X825 = 3003, /**< @doc "Soldered Inkplate 10 (ED097TC2, 1200x825, 1bpp; FastEPD native parallel path)" */
OD_PANEL_IC_M5PAPERS3_960X540 = 3004 /**< @doc "M5Stack PaperS3 (ED047TC1-class, 960x540, 16-gray capable; FastEPD native parallel path)" @since 1.5 */
OD_PANEL_IC_M5PAPERS3_960X540 = 3004, /**< @doc "M5Stack PaperS3 (ED047TC1-class, 960x540, 16-gray capable; FastEPD native parallel path)" @since 1.5 */
OD_PANEL_IC_M5PAPER_960X540 = 3005 /**< @doc "original M5Stack M5Paper (ED047TC1 960x540 + IT8951 over SPI, 16-gray capable; FastEPD IT8951 path)" */
};

/* DisplayConfig.transmission_modes @bits TransmissionModes (bits 5-6 reserved --
Expand Down Expand Up @@ -801,7 +811,8 @@ enum SensorType {
OD_SENSOR_TYPE_SHT40 = 4, /**< @doc "Sensirion SHT40 (I2C); writes 3 LE bytes into MSD dynamic data (21 bits: 0.1C, 0.1% RH)" */
OD_SENSOR_TYPE_BQ27220 = 5, /**< @doc "TI BQ27220 fuel gauge (I2C 0x55); voltage in MSD bytes 14-15, one packed dynamic byte (bits 0-6 SOC%, bit7 charging)" */
OD_SENSOR_TYPE_NPM1300 = 6, /**< @doc "nPM1300 PMIC (other firmware family; not implemented by this firmware)" */
OD_SENSOR_TYPE_AHT20 = 7 /**< @doc "AHT20 (I2C 0x38); wake-time sample in SHT40-compatible 3-byte MSD format. NM-EPD-420 power gate GPIO40 is off between samples." */
OD_SENSOR_TYPE_AHT20 = 7, /**< @doc "AHT20 (I2C 0x38); wake-time sample in SHT40-compatible 3-byte MSD format. NM-EPD-420 power gate GPIO40 is off between samples." */
OD_SENSOR_TYPE_PCF8563 = 8 /**< @doc "NXP PCF8563 / BM8563 RTC (I2C 0x51) as a power-off wake source; with pwr_latch, deep sleep releases the latch and the RTC INT powers the board back on. Publishes no MSD data." */
};

/** @struct SensorData @packet 0x23 @repeatable max=4
Expand All @@ -811,7 +822,7 @@ struct SensorData {
uint8_t instance_number; /**< @doc "0-based sensor-block index." */
uint16_t sensor_type; /**< @enum SensorType @endian le @doc "sensor type." */
uint8_t bus_id; /**< @doc "data_bus instance this sensor lives on." */
uint8_t i2c_addr_7bit; /**< @doc "I2C 7-bit address; 0 or 0xFF = per-sensor default (SHT40 0x44, BQ27220 0x55, AHT20 0x38)." @default 0xFF */
uint8_t i2c_addr_7bit; /**< @doc "I2C 7-bit address; 0 or 0xFF = per-sensor default (SHT40 0x44, BQ27220 0x55, AHT20 0x38, PCF8563 0x51)." @default 0xFF */
uint8_t msd_data_start_byte; /**< @doc "first dynamicreturndata index this sensor publishes into. SHT40/AHT20 use 3 bytes (0/0xFF = default 7); BQ27220 uses 1 packed byte (0xFF = do not publish)." @default 0xFF */
uint8_t reserved[24]; /**< @reserved @doc "future use; must be 0." */
} __attribute__((packed));
Expand Down
9 changes: 9 additions & 0 deletions src/config_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ void xiaoinit();
void powerDownExternalFlashFromConfig(void);
void ws_pp_init();
void nm420_init();
void m5paper_init();
extern bool encryptionInitialized;

// Defined in main.h (the single-inclusion globals header), so it needs an extern
Expand Down Expand Up @@ -958,6 +959,14 @@ void full_config_init() {
nm420_init();
od_log_info("nm420_init() completed");
}
// The M5Paper panel type identifies the board. Before powerLatchBegin():
// that releases the deep-sleep pad hold on pwr_pin_2, and on the M5Paper
// that pin (GPIO2) must already be driven HIGH or the board loses power
// on battery.
if (globalConfig.loaded && globalConfig.display_count > 0 &&
globalConfig.displays[0].panel_ic_type == OD_PANEL_IC_M5PAPER_960X540) {
m5paper_init();
}
// Must run after config load: latch pins/flag come from globalConfig.
powerLatchBegin();
} else {
Expand Down
12 changes: 7 additions & 5 deletions src/device_control.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1039,14 +1039,16 @@ void handlePowerOffCommand(const uint8_t* payload, uint16_t payloadLen) {
(void)payloadLen;
// powerLatch* are defined on every target (no-op stubs off ESP32; the header
// guarantees callers need no guards), so no #ifdef TARGET_ESP32 is required here:
// powerLatchDffConfigured() returns false off-ESP32 -> falls through to the NACK.
if (powerLatchDffConfigured()) {
// Fire-and-forget hard rail-cut: queue the ACK, then release the D-FF latch.
// On latch HW the rail usually drops before the ACK is actually transmitted.
// Both predicates return false off-ESP32 -> falls through to the NACK.
if (powerLatchDffConfigured() || powerLatchMosfetConfigured()) {
// Fire-and-forget hard rail-cut: queue the ACK, then release the latch (D-FF:
// clock D low; MOSFET: drive pwr_pin_2 low, e.g. the M5Paper's GPIO2). On latch
// HW the rail usually drops before the ACK is actually transmitted. On USB the
// MOSFET path parks in deep sleep until reset (or the pwr_pin_3 button).
uint8_t ok[] = {RESP_ACK, RESP_POWER_OFF, 0x00, 0x00};
sendResponse(ok, sizeof(ok));
delay(100);
powerLatchPowerOff();
powerLatchTriggerOff();
return;
}
// ANCHOR(power-off-no-latch-fallback): FUTURE WORK for a later agent/implementer.
Expand Down
127 changes: 113 additions & 14 deletions src/display_fastepd.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ extern void it8951LoadImgAreaStart(FASTEPDSTATE* pState, uint16_t endian, uint16
extern void it8951WriteCmdCode(FASTEPDSTATE* pState, uint16_t cmd);
extern void it8951DisplayArea1Bit(FASTEPDSTATE* pState, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
uint16_t mode, uint8_t bg_gray, uint8_t fg_gray);
extern void it8951DisplayArea(FASTEPDSTATE* pState, uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t mode);
extern uint16_t it8951ReadReg(FASTEPDSTATE* pState, uint16_t addr);
extern void it8951WriteReg(FASTEPDSTATE* pState, uint16_t addr, uint16_t val);
// FastEPD's cached PCAL6416A write
extern void bbepPCALDigitalWrite(uint8_t pin, uint8_t value);
static void fastepd_inkplate_wakeup_off(void);
Expand Down Expand Up @@ -82,6 +85,20 @@ void opendisplay_fastepd_load_pins_from_display(const struct DisplayConfig* d, c
s_ite_en = fastepd_aux_pin(sys->pwr_pin_3, 21);
}
break;
case OD_PANEL_IC_M5PAPER_960X540:
// M5Paper: IT8951 reset is not wired to the ESP32; GPIO23 gates the
// IT8951 supply, so reset_pin doubles as RST/EN/ITE_EN (same as
// FastEPD's own BB_PANEL_M5PAPER definition). pwr_pin_2/pwr_pin_3
// are the board's power latch here, never panel enables.
s_sclk = (d->clk_pin != 0xFF) ? (int8_t)d->clk_pin : 14;
s_mosi = (d->data_pin != 0xFF) ? (int8_t)d->data_pin : 12;
s_miso = (d->dc_pin != 0xFF) ? (int8_t)d->dc_pin : 13;
s_cs = (d->cs_pin != 0xFF) ? (int8_t)d->cs_pin : 15;
s_busy = (d->busy_pin != 0xFF) ? (int8_t)d->busy_pin : 27;
s_rst = (d->reset_pin != 0xFF) ? (int8_t)d->reset_pin : 23;
s_en = s_rst;
s_ite_en = s_rst;
break;
default:
break;
}
Expand Down Expand Up @@ -162,6 +179,26 @@ static void blit_1bpp_rect(uint8_t* dst, const uint8_t* src, uint32_t src_len,
*io_offset = off;
}

// The IT8951 1bpp mode takes the leftmost pixel from bit 0; FastEPD packs it
// in bit 7. Mirrored panels get away with reversing the byte order of each
// row (that, plus LSB-first, mirrors the whole row). Unmirrored panels need
// each byte bit-reversed (verified on an M5Paper).
static void it8951_write_1bpp_row_lsb_first(const uint8_t* src, unsigned n) {
uint8_t chunk[64];
while (n) {
const unsigned k = n < sizeof(chunk) ? n : (unsigned)sizeof(chunk);
for (unsigned i = 0; i < k; i++) {
uint8_t b = src[i];
b = (uint8_t)((b >> 4) | (b << 4));
b = (uint8_t)(((b & 0xCC) >> 2) | ((b & 0x33) << 2));
chunk[i] = (uint8_t)(((b & 0xAA) >> 1) | ((b & 0x55) << 1));
}
SPI.writeBytes(chunk, k);
src += k;
n -= k;
}
}

/** Full-screen 1bpp DU (mode 1) — non-flashing. Matches FastEPD full-frame
* packing (MIRROR_X → B_ENDIAN + byte-reverse) with IT8951 DU waveform. */
static bool it8951_fullscreen_du(void) {
Expand All @@ -178,8 +215,9 @@ static bool it8951_fullscreen_du(void) {
it8951WaitForLUTReady(st);
it8951SetImgBufBaseAddr(st);

const uint16_t endian = mirror_x ? IT8951_LDIMG_B_ENDIAN : IT8951_LDIMG_L_ENDIAN;
it8951LoadImgAreaStart(st, endian, IT8951_8BPP, IT8951_ROTATE_0, 0, 0, pitch, h);
// B_ENDIAN either way: L_ENDIAN swaps the bytes of each 16-bit word (see
// it8951_full_write_unmirrored()).
it8951LoadImgAreaStart(st, IT8951_LDIMG_B_ENDIAN, IT8951_8BPP, IT8951_ROTATE_0, 0, 0, pitch, h);

uint8_t* rowbuf = mirror_x ? (uint8_t*)malloc(pitch) : nullptr;
if (mirror_x && !rowbuf) return false;
Expand All @@ -198,7 +236,7 @@ static bool it8951_fullscreen_du(void) {
}
SPI.writeBytes(rowbuf, pitch);
} else {
SPI.writeBytes(src, pitch);
it8951_write_1bpp_row_lsb_first(src, pitch);
}
if ((row & 7) == 0) yield();
}
Expand Down Expand Up @@ -260,7 +298,14 @@ void fastepd_epaper_begin(void) {
}

if (!g_epd.currentBuffer()) {
rc = g_epd.setPanelSize(BBEP_DISPLAY_ED103TC2);
// M5Paper: ED047TC1 960x540, unmirrored. FastEPD 2.2.0 has no
// BBEP_DISPLAY_ED047TC1, so size it explicitly. The IT8951 runs its own
// waveforms; the gray matrix that setPanelSize(id) would add is unused.
if (globalConfig.displays[0].panel_ic_type == OD_PANEL_IC_M5PAPER_960X540) {
rc = g_epd.setPanelSize(960, 540, BB_PANEL_FLAG_NONE, -1600);
} else {
rc = g_epd.setPanelSize(BBEP_DISPLAY_ED103TC2);
}
if (rc != BBEP_SUCCESS || !g_epd.currentBuffer()) {
s_init_failed = true;
s_hw_initialized = false;
Expand All @@ -277,9 +322,64 @@ void fastepd_epaper_begin(void) {
s_hw_initialized = true;
}

// FastEPD 2.2.0 loads an unmirrored IT8951 frame as L_ENDIAN, which swaps the
// two bytes of every 16-bit word: 4bpp pixel pairs land 2 px off, 1bpp bytes
// 8 px off (verified on an M5Paper). Same frame and GC16 waveform as
// it8951WriteFramebuffer4Bit/1Bit, loaded B_ENDIAN as M5EPD does; 1bpp rows
// are also bit-reversed (it8951_write_1bpp_row_lsb_first()). Mirrored panels
// (ED103TC2) already get B_ENDIAN from FastEPD.
static void it8951_full_write_unmirrored(void) {
FASTEPDSTATE* st = g_epd.state();
const uint8_t* s = g_epd.currentBuffer();
if (!st || !s) return;
const bool gray = (g_epd.getMode() == BB_MODE_4BPP);
const uint16_t w = (uint16_t)st->native_width;
const uint16_t h = (uint16_t)st->native_height;
const unsigned pitch = gray ? (unsigned)((w + 1) / 2) : (unsigned)((w + 7) / 8);

g_epd.einkPower(1);
it8951WaitForLUTReady(st);
it8951SetImgBufBaseAddr(st);
if (gray) {
// Leave 1bpp mode (it8951DisplayArea1Bit() sets it).
it8951WriteReg(st, IT8951_REG_UP1SR + 2, it8951ReadReg(st, IT8951_REG_UP1SR + 2) & ~(1 << 2));
it8951LoadImgAreaStart(st, IT8951_LDIMG_B_ENDIAN, IT8951_4BPP, IT8951_ROTATE_0, 0, 0, w, h);
} else {
it8951LoadImgAreaStart(st, IT8951_LDIMG_B_ENDIAN, IT8951_8BPP, IT8951_ROTATE_0, 0, 0, pitch, h);
}

gpio_set_level((gpio_num_t)st->u8CS, LOW);
SPI.beginTransaction(SPISettings(st->spi_frequency, MSBFIRST, SPI_MODE0));
it8951WaitForReady(st);
SPI.transfer16(0x0000);
it8951WaitForReady(st);
for (uint16_t row = 0; row < h; row++) {
if (gray) SPI.writeBytes(s, pitch);
else it8951_write_1bpp_row_lsb_first(s, pitch);
s += pitch;
if ((row & 7) == 0) yield();
}
SPI.endTransaction();
gpio_set_level((gpio_num_t)st->u8CS, HIGH);

it8951WriteCmdCode(st, IT8951_TCON_LD_IMG_END);
if (gray) {
it8951DisplayArea(st, 0, 0, w, h, IT8951_MODE_2);
} else {
it8951DisplayArea1Bit(st, 0, 0, w, h, IT8951_MODE_2, 0x00, 0xff);
}
it8951WaitForReady(st);
g_epd.einkPower(0);
}

// REFRESH_FULL: parallel panels flash-clear to flush ghosting; IT8951 clears
// internally (ignores the mode) so it stays CLEAR_NONE.
static void fastepd_full_refresh_impl(void) {
FASTEPDSTATE* st = g_epd.state();
if (!fastepd_is_parallel() && !(st->iFlags & BB_PANEL_FLAG_MIRROR_X)) {
it8951_full_write_unmirrored();
return;
}
int clear = CLEAR_NONE;
if (fastepd_is_parallel()) {
// The PaperS3 needs the library-default 10-pass clear: CLEAR_FAST's
Expand Down Expand Up @@ -353,13 +453,11 @@ void fastepd_direct_refresh(int refresh_mode) {
if (!g_epd.currentBuffer()) return;
if (refresh_mode == 1) {
// FAST: parallel 1bpp uses native diff; IT8951 uses its DU waveform.
// 4bpp has no fast path (fastUpdate is 1bpp-only) -> fall back to full.
if (fastepd_is_parallel()) {
if (fastepd_panel_is_4gray()) fastepd_full_refresh_impl();
else g_epd.fastUpdate(true);
} else {
it8951_fullscreen_du();
}
// 4bpp has no fast path (fastUpdate and it8951_fullscreen_du() are
// 1bpp-only) -> fall back to full.
if (fastepd_panel_is_4gray()) fastepd_full_refresh_impl();
else if (fastepd_is_parallel()) g_epd.fastUpdate(true);
else it8951_fullscreen_du();
} else {
fastepd_full_refresh_impl();
}
Expand Down Expand Up @@ -443,10 +541,11 @@ bool fastepd_partial_refresh(int refresh_mode) {

if (refresh_mode == 0) {
fastepd_full_refresh_impl();
} else if (fastepd_is_parallel()) {
} else if (fastepd_panel_is_4gray()) {
// Rect already blitted into the framebuffer; 4bpp has no fast path.
if (fastepd_panel_is_4gray()) fastepd_full_refresh_impl();
else g_epd.fastUpdate(true);
fastepd_full_refresh_impl();
} else if (fastepd_is_parallel()) {
g_epd.fastUpdate(true);
} else {
// Region rect is still applied to the framebuffer via blit; refresh is
// full-screen DU (IT8951 region DU had persistent edge alignment issues).
Expand Down
8 changes: 6 additions & 2 deletions src/display_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -864,9 +864,10 @@ bool fastepd_driver_used(void) {
#else
if (globalConfig.display_count < 1) return false;
const struct DisplayConfig& d = globalConfig.displays[0];
// FastEPD IT8951 (SPI) path: E Ink ED103TC2 (Seeed reTerminal).
// FastEPD IT8951 (SPI) path: E Ink ED103TC2 (Seeed reTerminal), M5Stack M5Paper.
const bool it8951 = (d.panel_ic_type == OD_PANEL_IC_ED103TC2_1872X1404 ||
d.panel_ic_type == OD_PANEL_IC_ED103TC2_1872X1404_4GRAY);
d.panel_ic_type == OD_PANEL_IC_ED103TC2_1872X1404_4GRAY ||
d.panel_ic_type == OD_PANEL_IC_M5PAPER_960X540);
// FastEPD native parallel path: Soldered Inkplate 5V2 / 10, M5Stack PaperS3.
const bool parallel = (d.panel_ic_type == OD_PANEL_IC_INKPLATE5V2_1280X720 ||
d.panel_ic_type == OD_PANEL_IC_INKPLATE10_1200X825 ||
Expand Down Expand Up @@ -1200,6 +1201,9 @@ void initSensors(){
else if(sensor->sensor_type == OD_SENSOR_TYPE_AHT20){
od_log_debug(" AHT20 (wake-time I2C + MSD slot)");
}
else if(sensor->sensor_type == OD_SENSOR_TYPE_PCF8563){
od_log_debug(" PCF8563/BM8563 RTC (power-off wake source with pwr_latch)");
}
else if(sensor->sensor_type == OD_SENSOR_TYPE_BQ27220){
od_log_debug(" BQ27220 fuel gauge (MSD voltage + optional dynamic SOC/status bytes)");
}
Expand Down
Loading