Skip to content
10 changes: 8 additions & 2 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,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.
*
Expand Down Expand Up @@ -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 --
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
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
5 changes: 3 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
66 changes: 66 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,72 @@ 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.
//
// 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)
// 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 (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);
// 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
}

#ifdef TARGET_ESP32
void nm420HoldForSleep() {
#if defined(CONFIG_IDF_TARGET_ESP32S3)
Expand Down
1 change: 1 addition & 0 deletions src/main.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
21 changes: 18 additions & 3 deletions src/touch_input.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down