From ad0b05850850028407a273611777927fab93358d Mon Sep 17 00:00:00 2001 From: gcormier Date: Mon, 26 Oct 2026 00:56:35 +0000 Subject: [PATCH 1/7] feat: original M5Stack M5Paper support (panel-IC 3005, FastEPD IT8951 path) The M5Paper (classic ESP32, 16 MB flash, 8 MB PSRAM) drives its 4.7" ED047TC1 960x540 panel through an IT8951 over SPI, the same FastEPD path the ED103TC2 (reTerminal E1003) already uses. No new PlatformIO env: it runs the existing esp32-wrover-e-N4R8 build. - vendored structs header: PanelIC 3005 OD_PANEL_IC_M5PAPER_960X540 (Unreleased changelog entry; canonical opendisplay-protocol change to follow -- the vendored copy is already ahead of canonical) - fastepd_driver_used(): include 3005 in the IT8951 set - IT8951 pins for 3005: SPI from the display block (M5Paper defaults 12/13/14/15, busy 27); reset_pin (GPIO23, the IT8951 supply gate -- reset is not wired) doubles as RST/EN/ITE_EN, matching FastEPD's own BB_PANEL_M5PAPER definition. pwr_pin_2/3 stay the power latch. - panel size: explicit 960x540 unmirrored for 3005 (FastEPD 2.2.0 has no BBEP_DISPLAY_ED047TC1) - m5paper_init(), keyed off panel IC 3005 rather than a device_flags bit (bit 5 is already used differently across firmware families): drives GPIO2 (main-power hold) and GPIO5 (EXT power) HIGH before powerLatchBegin(). Deep-sleep hold and power-off of GPIO2 use the existing MOSFET latch (pwr_latch, pwr_pin_2 = 2). Not yet validated on hardware. Without an RTC wake the board holds its rail through ESP32 deep sleep; RTC power-off follows separately. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KZwG6PyfGku5uKoVTyB9MC --- include/opendisplay_structs.h | 10 ++++++++-- src/config_parser.cpp | 9 +++++++++ src/display_fastepd.cpp | 23 ++++++++++++++++++++++- src/display_service.cpp | 5 +++-- src/main.cpp | 23 +++++++++++++++++++++++ src/main.h | 1 + 6 files changed, 66 insertions(+), 5 deletions(-) diff --git a/include/opendisplay_structs.h b/include/opendisplay_structs.h index eac6917..33dcc7d 100644 --- a/include/opendisplay_structs.h +++ b/include/opendisplay_structs.h @@ -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 @@ -88,6 +88,11 @@ * - 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. * - 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. * @@ -704,7 +709,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 -- diff --git a/src/config_parser.cpp b/src/config_parser.cpp index a587609..b9a6482 100644 --- a/src/config_parser.cpp +++ b/src/config_parser.cpp @@ -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 @@ -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 { diff --git a/src/display_fastepd.cpp b/src/display_fastepd.cpp index 7c8965e..8118863 100644 --- a/src/display_fastepd.cpp +++ b/src/display_fastepd.cpp @@ -82,6 +82,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; } @@ -260,7 +274,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; diff --git a/src/display_service.cpp b/src/display_service.cpp index 74d92f6..249d21f 100644 --- a/src/display_service.cpp +++ b/src/display_service.cpp @@ -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 || diff --git a/src/main.cpp b/src/main.cpp index 7b9e148..74b78d4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1509,6 +1509,29 @@ void nm420_init(){ #endif } +// Original M5Stack M5Paper (classic ESP32 + IT8951), selected by panel IC +// OD_PANEL_IC_M5PAPER_960X540 (the panel only ships on this board). The power +// button only switches the board on while it is held; GPIO2 (main-power hold) +// must be driven HIGH to keep it on, and GPIO5 enables the external-power rail. GPIO23 (IT8951 power) is driven by +// FastEPD as the panel's EN pin. Deep-sleep hold and power-off of GPIO2 go +// through the MOSFET latch (DEVICE_FLAG_BATTERY_LATCH, pwr_pin_2 = 2), so the +// preset sets both flags. +void m5paper_init(){ +#if defined(TARGET_ESP32) && defined(CONFIG_IDF_TARGET_ESP32) + static constexpr uint8_t kM5PaperOnPins[] = {2, 5}; + for (uint8_t pin : kM5PaperOnPins) { + // Level before direction, then drop the sleep hold: the pad never + // glitches LOW on a wake from deep sleep. + gpio_set_level((gpio_num_t)pin, 1); + gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT); + gpio_hold_dis((gpio_num_t)pin); + pinMode(pin, OUTPUT); + digitalWrite(pin, HIGH); + } + od_log_info("M5Paper init: main-power hold and EXT power HIGH"); +#endif +} + #ifdef TARGET_ESP32 void nm420HoldForSleep() { #if defined(CONFIG_IDF_TARGET_ESP32S3) diff --git a/src/main.h b/src/main.h index 2c59cef..49808bf 100644 --- a/src/main.h +++ b/src/main.h @@ -193,6 +193,7 @@ void powerDownExternalFlashFromConfig(void); void xiaoinit(); void ws_pp_init(); void nm420_init(); +void m5paper_init(); #ifdef TARGET_ESP32 void nm420HoldForSleep(); void fullSetupAfterConnection(); From f271dc5600b0df4902cb683afabbd829b2165429 Mon Sep 17 00:00:00 2001 From: gcormier Date: Mon, 26 Oct 2026 18:11:42 -0400 Subject: [PATCH 2/7] fix(m5paper): load unmirrored IT8951 frames B_ENDIAN; never drive input-only touch INT Display: FastEPD 2.2.0 loads an IT8951 frame L_ENDIAN unless the panel is MIRROR_X, which swaps the two bytes of every 16-bit word. On the M5Paper (the only unmirrored IT8951 panel) 4bpp pixel pairs landed 2 px off, so the image looked interlaced / shifted horizontally. Full refreshes on unmirrored IT8951 panels now go through it8951_full_write_unmirrored() (same frame and GC16 waveform as FastEPD's writer, loaded B_ENDIAN as M5Stack's M5EPD driver does); the full-screen DU path uses B_ENDIAN too. Mirrored panels (ED103TC2) keep FastEPD's own path, unchanged. Touch: the M5Paper GT911 INT is GPIO36, an input-only pad with no internal pull-up. The INT wake pulse and address-select reset drove it as an output and INPUT_PULLUP failed, logging GPIO errors on every boot and EPD refresh. Skip driving input-only pads and read them as plain INPUT. Verified on an original M5Paper (ESP32-D0WDQ6-V3, USB config): 960x540 4bpp test image sharp (shifted before the fix), orientation correct, gray steps 0 (black) to 15 (white) look right; no GPIO errors at boot; GT911 taps at all four corners and the centre map correctly with touch flags SWAP_XY|INVERT_Y (0x06); rocker 37/38/39 press and release. Not verified on hardware: 1bpp (mono) and the DU fast path. The mirrored (reTerminal) path is unchanged. --- src/display_fastepd.cpp | 61 +++++++++++++++++++++++++++++++++++++++-- src/touch_input.cpp | 21 ++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/display_fastepd.cpp b/src/display_fastepd.cpp index 8118863..340b4d9 100644 --- a/src/display_fastepd.cpp +++ b/src/display_fastepd.cpp @@ -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); @@ -192,8 +195,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; @@ -298,9 +302,62 @@ 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. 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++) { + SPI.writeBytes(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 diff --git a/src/touch_input.cpp b/src/touch_input.cpp index 6be6799..1ec2d64 100644 --- a/src/touch_input.cpp +++ b/src/touch_input.cpp @@ -152,8 +152,23 @@ static void touch_detach_all_configured_ints(void) { } } +// ESP32 GPIO34-39 are input-only with no internal pull-up (M5Paper GT911 INT is +// GPIO36, pulled up on the board): never drive them, read them as plain INPUT. +static bool touch_pin_can_output(uint8_t pin) { +#if defined(TARGET_ESP32) + return GPIO_IS_VALID_OUTPUT_GPIO(pin); +#else + (void)pin; + return true; +#endif +} + +static uint8_t touch_int_input_mode(uint8_t pin) { + return touch_pin_can_output(pin) ? INPUT_PULLUP : INPUT; +} + static void gt911_int_wake_before_irq(const TouchController* t) { - if (t->int_pin == 0xFF) { + if (t->int_pin == 0xFF || !touch_pin_can_output(t->int_pin)) { return; } pinMode(t->int_pin, OUTPUT); @@ -171,7 +186,7 @@ static void attach_touch_int(uint8_t idx, uint8_t pin) { od_log_warn("Touch[%u]: digitalPinToInterrupt failed for GPIO %u โ€” using poll only", idx, pin); return; } - pinMode(pin, INPUT_PULLUP); + pinMode(pin, touch_int_input_mode(pin)); attachInterrupt(irq_num, s_touch_isrs[idx], FALLING); s_touch_rt[idx].int_irq_attached = 1; } @@ -244,7 +259,7 @@ static void gt911_hw_reset(const TouchController* t, bool int_low_for_addr_5d) { if (t->rst_pin == 0xFF) { return; } - if (t->int_pin == 0xFF) { + if (t->int_pin == 0xFF || !touch_pin_can_output(t->int_pin)) { pinMode(t->rst_pin, OUTPUT); digitalWrite(t->rst_pin, LOW); delay(10); From 0b7bf40c8b4b3597967cdfee992ab3f644fde4d2 Mon Sep 17 00:00:00 2001 From: gcormier Date: Tue, 27 Oct 2026 08:14:03 -0400 Subject: [PATCH 3/7] fix(m5paper): clear the on-board RTC's interrupts in m5paper_init() The BM8563 at 0x51 runs from VBAT_IN and its INT line pulls the M5Paper's power-switch gate low. A timer or alarm left armed (M5Stack's factory firmware leaves a repeating timer, and it survives a flash erase) switched the board back on 60-75 s after every reset-button power-off on battery. m5paper_init() now turns AIE/TIE off, clears AF/TF, stops the timer and disables the alarm on every boot, whatever the sensor config says. Verified on hardware (M5Paper, USB config): with the RTC armed by the previous firmware (C2=01, TC=82, 18 s left), boot logged the clear and a readback (temporary, not committed) showed C2=00, TC=03, alarm disabled. Not verified: absence of the phantom power-on after a battery power-off with this build (needs someone at the device). --- src/main.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index 74b78d4..e49b579 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1516,6 +1516,39 @@ void nm420_init(){ // FastEPD as the panel's EN pin. Deep-sleep hold and power-off of GPIO2 go // through the MOSFET latch (DEVICE_FLAG_BATTERY_LATCH, pwr_pin_2 = 2), so the // preset sets both flags. +// +// The on-board BM8563 RTC (0x51 on I2C bus 0) runs from VBAT_IN, and its INT +// line also pulls the power-switch gate low: an alarm or timer left armed (the +// M5Stack factory firmware leaves a repeating timer) switches the board back +// on after every power-off, and it survives a flash erase. m5paper_init() +// therefore turns both interrupts off, clears the flags, stops the timer and +// disables the alarm, whatever the sensor config says. +#if defined(TARGET_ESP32) && defined(CONFIG_IDF_TARGET_ESP32) +static bool m5paperRtcWrite(const uint8_t* regAndData, size_t len) { + Wire.beginTransmission(0x51); + Wire.write(regAndData, len); + return Wire.endTransmission() == 0; +} + +static void m5paperRtcQuiesce() { + static const uint8_t kCtrl2[] = {0x01, 0x00}; // AIE/TIE off, AF/TF cleared + static const uint8_t kTimer[] = {0x0E, 0x03}; // timer off, 1/60 Hz + static const uint8_t kAlarm[] = {0x09, 0x80, 0x80, 0x80, 0x80}; // every alarm field disabled + if (!initOrRestoreWireForBus(0)) { + od_log_warn("M5Paper init: no I2C bus 0, RTC left as is"); + return; + } + const bool ok = m5paperRtcWrite(kCtrl2, sizeof(kCtrl2)) && + m5paperRtcWrite(kTimer, sizeof(kTimer)) && + m5paperRtcWrite(kAlarm, sizeof(kAlarm)); + if (ok) { + od_log_info("M5Paper init: RTC interrupts, timer and alarm cleared"); + } else { + od_log_warn("M5Paper init: RTC not reachable at 0x51"); + } +} +#endif + void m5paper_init(){ #if defined(TARGET_ESP32) && defined(CONFIG_IDF_TARGET_ESP32) static constexpr uint8_t kM5PaperOnPins[] = {2, 5}; @@ -1529,6 +1562,7 @@ void m5paper_init(){ digitalWrite(pin, HIGH); } od_log_info("M5Paper init: main-power hold and EXT power HIGH"); + m5paperRtcQuiesce(); #endif } From 27016a436cbd49046e398f5fd243ac18db94dd3a Mon Sep 17 00:00:00 2001 From: gcormier Date: Tue, 27 Oct 2026 17:04:33 -0400 Subject: [PATCH 4/7] fix(fastepd): IT8951 fast refresh in 4bpp falls back to full instead of sending gray data as 1bpp it8951_fullscreen_du() always loads w/8 bytes per row from the framebuffer and shows them with the 1bpp DU waveform. In 4bpp mode (GRAY16, or the ED103TC2 panel) that framebuffer holds two pixels per byte, so a FAST refresh put the top quarter of the gray buffer on screen as 1bpp noise. The parallel path already falls back to a full refresh for 4bpp; the IT8951 path now does the same, for both direct and partial transfers. Verified on hardware (M5Paper, GRAY16 config): with a temporary IT8951 memory readback, a FAST upload before this fix left the raw 4bpp bytes in the 1bpp image area (2400/2400 sampled bytes equal to the 4bpp buffer). With this fix the same FAST upload logs "EPD refresh: FAST" and then takes the full 4bpp path: 19200/19200 sampled pixels in IT8951 memory match the framebuffer, and nothing is loaded through the 1bpp DU path. Also verified by readback (no code change needed): 4bpp full, 1bpp full and 1bpp DU on the M5Paper load the frame byte-for-byte as sent (B_ENDIAN). Not verified: the ED103TC2 (reTerminal), which takes the same branch; the on-screen 1bpp bit order (needs eyes); partial transfers in 4bpp mode, which still blit the rect as 1bpp (the M5Paper preset has partial updates off). --- src/display_fastepd.cpp | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/display_fastepd.cpp b/src/display_fastepd.cpp index 340b4d9..5e6aed4 100644 --- a/src/display_fastepd.cpp +++ b/src/display_fastepd.cpp @@ -431,13 +431,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(); } @@ -521,10 +519,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). From 34548b0a7e8e00277ca75441248ae0370cac518b Mon Sep 17 00:00:00 2001 From: gcormier Date: Tue, 27 Oct 2026 17:33:10 -0400 Subject: [PATCH 5/7] fix(fastepd): bit-reverse 1bpp rows for unmirrored IT8951 panels The IT8951 1bpp mode takes the leftmost pixel from bit 0 of each byte; FastEPD packs it in bit 7. Mirrored panels (ED103TC2) never showed this, because FastEPD reverses the byte order of each row for them, which together with LSB-first mirrors the whole row. On the unmirrored M5Paper every 8 px group came out mirrored, so mono images were unreadable. Full 1bpp writes and the 1bpp DU path now send each byte bit-reversed, through a 64-byte stack buffer. 4bpp and mirrored panels are unchanged. Verified on hardware (M5Paper, mono config): before the fix a full mono image was unreadable. A test image with four bands pre-scrambled on the host (none, bits reversed in each byte, bytes swapped, both) showed only the bit-reversed band cleanly. After the fix a full refresh of a text/ruler/1 px line test image is clean, and a FAST (DU) refresh is readable with the usual DU ghosting and incomplete fill of large black areas. Not verified: the ED103TC2 (reTerminal), whose mirrored path is unchanged; 1bpp partial (region) refresh. --- src/display_fastepd.cpp | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/display_fastepd.cpp b/src/display_fastepd.cpp index 5e6aed4..03beb10 100644 --- a/src/display_fastepd.cpp +++ b/src/display_fastepd.cpp @@ -179,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) { @@ -216,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(); } @@ -305,8 +325,9 @@ void fastepd_epaper_begin(void) { // 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. Mirrored -// panels (ED103TC2) already get B_ENDIAN from FastEPD. +// 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(); @@ -333,7 +354,8 @@ static void it8951_full_write_unmirrored(void) { SPI.transfer16(0x0000); it8951WaitForReady(st); for (uint16_t row = 0; row < h; row++) { - SPI.writeBytes(s, pitch); + if (gray) SPI.writeBytes(s, pitch); + else it8951_write_1bpp_row_lsb_first(s, pitch); s += pitch; if ((row & 7) == 0) yield(); } From 321f3be700d62ae8bb7ae26502b91feab2978511 Mon Sep 17 00:00:00 2001 From: gcormier Date: Tue, 27 Oct 2026 20:36:10 -0400 Subject: [PATCH 6/7] feat(m5paper): run at 80 MHz and leave the Grove 5 V boost off m5paper_init() drove GPIO5 HIGH, which turns on the 5 V boost for the Grove ports (M5EPD's default). Nothing here uses the ports, so GPIO5 is now driven LOW. The CPU also drops to 80 MHz right after the GPIO2 power hold: BLE needs no more, and a battery wake window is mostly idle polling. Switching after the hold keeps the power-button hold time unchanged. Verified on hardware (M5Paper, USB): reset to GPIO2 hold 1068 ms (1073 ms before), boot log "CPU 80 MHz". A 259200-byte gray upload took 21.72 s on the device (21.76 s at 240 MHz, BLE-bound) and its refresh 1.49 s (1.47 s). Touch still works with EXT power off (a corner tap read x=18, y=51). Cost: the cold-boot QR boot screen renders in 8.2 s instead of 4.2 s; RTC wakes do not draw it. Not verified: the current saving itself (no meter), anything plugged into a Grove port (now unpowered; enabling it needs a config option that does not exist yet). --- src/main.cpp | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index e49b579..b37051f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1551,17 +1551,23 @@ static void m5paperRtcQuiesce() { void m5paper_init(){ #if defined(TARGET_ESP32) && defined(CONFIG_IDF_TARGET_ESP32) - static constexpr uint8_t kM5PaperOnPins[] = {2, 5}; - for (uint8_t pin : kM5PaperOnPins) { - // Level before direction, then drop the sleep hold: the pad never - // glitches LOW on a wake from deep sleep. - gpio_set_level((gpio_num_t)pin, 1); - gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT); - gpio_hold_dis((gpio_num_t)pin); - pinMode(pin, OUTPUT); - digitalWrite(pin, HIGH); - } - od_log_info("M5Paper init: main-power hold and EXT power HIGH"); + // GPIO2 holds main power. Level before direction, then drop the sleep + // hold: the pad never glitches LOW on a wake from deep sleep. + gpio_set_level(GPIO_NUM_2, 1); + gpio_set_direction(GPIO_NUM_2, GPIO_MODE_OUTPUT); + gpio_hold_dis(GPIO_NUM_2); + pinMode(2, OUTPUT); + digitalWrite(2, HIGH); + // GPIO5 enables the 5 V boost for the Grove ports, which nothing here + // uses; M5EPD turns it on by default. Keep it off to save power. + gpio_hold_dis(GPIO_NUM_5); + pinMode(5, OUTPUT); + digitalWrite(5, LOW); + // BLE needs no more than 80 MHz, and the wake window is mostly idle + // polling. Switched after the GPIO2 hold so the power-button hold time + // does not grow. + setCpuFrequencyMhz(80); + od_log_info("M5Paper init: main-power hold, EXT power off, CPU %u MHz", (unsigned)getCpuFrequencyMhz()); m5paperRtcQuiesce(); #endif } From 32a60fdc1b7ec578ec56467fa45efd05fd1d7866 Mon Sep 17 00:00:00 2001 From: gcormier Date: Tue, 27 Oct 2026 20:54:56 -0400 Subject: [PATCH 7/7] docs(m5paper): note how to re-enable the Grove 5 V boost Comment only, no code change. Not verified on hardware (nothing to verify). --- src/main.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index b37051f..db79351 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1558,8 +1558,11 @@ void m5paper_init(){ gpio_hold_dis(GPIO_NUM_2); pinMode(2, OUTPUT); digitalWrite(2, HIGH); - // GPIO5 enables the 5 V boost for the Grove ports, which nothing here - // uses; M5EPD turns it on by default. Keep it off to save power. + // GPIO5 enables the 5 V boost for the Grove ports (A/B/C), which nothing + // here uses; M5EPD turns it on by default. Off by default to save power. + // Anything plugged into a Grove port is unpowered. To power it, drive + // GPIO5 HIGH here instead; no config option exists yet (it would need a + // new DeviceFlags bit in opendisplay-protocol). gpio_hold_dis(GPIO_NUM_5); pinMode(5, OUTPUT); digitalWrite(5, LOW);