Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
02c5851
Merge main into greg/awesome-dijkstra-5pq3ha-rtc
jonasniesner Nov 8, 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.

9 changes: 7 additions & 2 deletions include/opendisplay_structs.h
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@
* 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 @@ -807,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 @@ -817,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
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
3 changes: 3 additions & 0 deletions src/display_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,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
35 changes: 31 additions & 4 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "display_service.h"
#include "power_latch.h"
#include "wake_button.h"
#include "rtc_pcf8563.h"
#include "touch_input.h"
#include "encryption.h"
#include "ble_transport.h"
Expand Down Expand Up @@ -197,6 +198,20 @@ void setup() {
// reservation above depends on. One call site serves both the normal-boot and the
// deep-sleep-wake path, which share this stretch of setup().
odLanReserveRxBuffer();
#endif
#ifdef TARGET_ESP32
// A cold boot raised by the RTC (power-off sleep, see enterDeepSleep()) is a
// timer wake in all but the ESP32's own wake cause. Always called when an RTC
// is configured: it also clears the flag that holds INT (and the rail) on.
// RTC memory died with the rail, so displayed_etag is 0 (the host sends a
// full image) and rebootFlag is cleared here -- this is not a reboot.
if (rtcConsumeWake() && !is_deep_sleep_wake) {
is_deep_sleep_wake = true;
woke_from_deep_sleep = true;
deep_sleep_count++;
rebootFlag = 0;
od_log_info("=== WOKE FROM RTC POWER-OFF ===");
}
#endif
if (is_deep_sleep_wake) { od_log_info("[wake] << full_config_init >> initio"); od_log_flush(); }
#if defined(TARGET_NRF) && defined(OPENDISPLAY_BOOT_DIAG)
Expand Down Expand Up @@ -1242,12 +1257,22 @@ void enterDeepSleep(bool force, uint16_t overrideSleepSeconds) {
// manipulation then cannot disturb freshly configured RTC pulls, and its
// gpio_hold_en() touches only the latch pin, never the wake pads.
armButtonWakeSources();
od_log_info("Entering deep sleep for %u seconds%s", sleepSeconds,
overrideSleepSeconds ? " (host override, one cycle)" : " (config)");
// RTC power-off (PCF8563/BM8563 + MOSFET latch): the RTC switches the board
// back on, so the latch is released instead of held. On an I2C failure the
// rail stays held and this is a plain timer deep sleep. The ESP32 timer
// above stays armed either way: it is what wakes a USB-powered board.
const bool rtcPowerOff = rtcArmWake(sleepSeconds);
od_log_info("Entering deep sleep for %u seconds%s%s", sleepSeconds,
overrideSleepSeconds ? " (host override, one cycle)" : " (config)",
rtcPowerOff ? ", RTC power-off" : "");
od_log_info("Heap: free=%u min=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMinFreeHeap());
od_log_flush(); // drain UART/Serial prior to deep sleep
delay(100); // Brief delay to ensure serial output is sent
powerLatchHoldForSleep();
if (rtcPowerOff) {
powerLatchReleaseForRtcSleep();
} else {
powerLatchHoldForSleep();
}
touchPrepareForDeepSleep();
nm420HoldForSleep();
esp_deep_sleep_start();
Expand Down Expand Up @@ -1571,7 +1596,9 @@ void m5paper_init(){
// does not grow.
setCpuFrequencyMhz(80);
od_log_info("M5Paper init: main-power hold, EXT power off, CPU %u MHz", (unsigned)getCpuFrequencyMhz());
m5paperRtcQuiesce();
// With RTC power-off configured, rtcConsumeWake() clears it later in
// setup() and needs the flags to recognise an RTC wake.
if (!rtcWakeConfigured()) m5paperRtcQuiesce();
#endif
}

Expand Down
12 changes: 12 additions & 0 deletions src/power_latch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,18 @@ void powerLatchPowerOff() {
dffLatchRelease();
}

void powerLatchReleaseForRtcSleep() {
if (dffLatchEnabled() || !latchEnabled()) {
return;
}
// padHold, not padIsolateAndHold: the wake pads armed before this keep
// their pulls for the USB-powered deep-sleep fallback.
padHoldDisable(latchPin());
pinMode(latchPin(), OUTPUT);
digitalWrite(latchPin(), LOW);
padHold(latchPin());
}

bool powerLatchMosfetConfigured(void) {
return latchEnabled();
}
Expand Down
7 changes: 6 additions & 1 deletion src/power_latch.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ void powerButtonPoll();
// esp_deep_sleep_start().
void powerLatchHoldForSleep();

// Release 74AHC1G79 D-FF latch (pwr_latch_dff only). Used by command 0x0052.
// Release 74AHC1G79 D-FF latch (pwr_latch_dff only). Command 0x0052 uses
// powerLatchTriggerOff(), which also cuts the MOSFET latch.
bool powerLatchDffConfigured(void);
bool powerLatchMosfetConfigured(void);
// RTC power-off sleep (MOSFET latch only): drive the hold pin LOW and keep it
// LOW through sleep. On battery the rail drops inside this call; on USB it
// stays up and the caller's esp_deep_sleep_start() is the fallback.
void powerLatchReleaseForRtcSleep();
void powerLatchPowerOff();
void powerLatchTriggerOff();
128 changes: 128 additions & 0 deletions src/rtc_pcf8563.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#include "rtc_pcf8563.h"

#ifdef TARGET_ESP32
#include "display_service.h"
#include "power_latch.h"
#include "structs.h"
#include "od_log.h"

#include <Arduino.h>
#include <Wire.h>

extern struct GlobalConfig globalConfig;

// PCF8563 / BM8563 register map (the two are register-compatible).
#define RTC_DEFAULT_ADDR 0x51
#define RTC_REG_CTRL1 0x00 // bit5 STOP
#define RTC_REG_CTRL2 0x01 // bit3 AF, bit2 TF, bit1 AIE, bit0 TIE
#define RTC_REG_SECONDS 0x02 // bit7 VL
#define RTC_REG_ALARM_MIN 0x09 // 0x09..0x0C: minute/hour/day/weekday, bit7 AE = disabled
#define RTC_REG_TIMER_CTRL 0x0E // bit7 TE, bits1:0 TD
#define RTC_REG_TIMER 0x0F

#define RTC_CTRL2_FLAGS 0x0C
#define RTC_CTRL2_AIE 0x02
#define RTC_CTRL2_TIE 0x01
#define RTC_TIMER_ENABLE 0x80
#define RTC_TIMER_1HZ 0x02
#define RTC_TIMER_1_60HZ 0x03 // also the lowest-power setting while disabled
#define RTC_ALARM_OFF 0x80

static const SensorData* rtcSensor(void) {
for (uint8_t i = 0; i < globalConfig.sensor_count; i++) {
if (globalConfig.sensors[i].sensor_type == OD_SENSOR_TYPE_PCF8563) {
return &globalConfig.sensors[i];
}
}
return nullptr;
}

// Selects the sensor's bus and returns its address, or 0 on failure.
static uint8_t rtcBegin(void) {
const SensorData* s = rtcSensor();
if (!s) return 0;
const uint8_t busId = (s->bus_id == 0xFF) ? 0 : s->bus_id;
if (!initOrRestoreWireForBus(busId)) return 0;
return (s->i2c_addr_7bit == 0 || s->i2c_addr_7bit == 0xFF) ? RTC_DEFAULT_ADDR : s->i2c_addr_7bit;
}

static bool rtcWrite(uint8_t addr, uint8_t reg, const uint8_t* data, uint8_t len) {
Wire.beginTransmission(addr);
Wire.write(reg);
Wire.write(data, len);
return Wire.endTransmission() == 0;
}

static bool rtcWrite1(uint8_t addr, uint8_t reg, uint8_t value) {
return rtcWrite(addr, reg, &value, 1);
}

static bool rtcRead(uint8_t addr, uint8_t reg, uint8_t* data, uint8_t len) {
Wire.beginTransmission(addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(addr, (size_t)len, true) != len) return false;
for (uint8_t i = 0; i < len; i++) data[i] = Wire.read();
return true;
}

static uint8_t bcdToBin(uint8_t v) { return (uint8_t)((v >> 4) * 10 + (v & 0x0F)); }
static uint8_t binToBcd(uint8_t v) { return (uint8_t)(((v / 10) << 4) | (v % 10)); }

// Interrupts off, flags cleared, timer stopped, every alarm field disabled.
static bool rtcQuiesce(uint8_t addr) {
static const uint8_t alarmsOff[4] = {RTC_ALARM_OFF, RTC_ALARM_OFF, RTC_ALARM_OFF, RTC_ALARM_OFF};
return rtcWrite1(addr, RTC_REG_CTRL2, 0x00) &&
rtcWrite1(addr, RTC_REG_TIMER_CTRL, RTC_TIMER_1_60HZ) &&
rtcWrite(addr, RTC_REG_ALARM_MIN, alarmsOff, sizeof(alarmsOff));
}

bool rtcWakeConfigured(void) {
return rtcSensor() != nullptr && powerLatchMosfetConfigured();
}

bool rtcConsumeWake(void) {
if (!rtcWakeConfigured()) return false;
const uint8_t addr = rtcBegin();
uint8_t ctrl2 = 0;
if (addr == 0 || !rtcRead(addr, RTC_REG_CTRL2, &ctrl2, 1)) {
od_log_warn("RTC: not reachable at boot");
return false;
}
// INT is level-mode (TI_TP = 0): it holds the board on until this clears.
if (!rtcQuiesce(addr)) od_log_warn("RTC: failed to clear wake flags");
return (ctrl2 & RTC_CTRL2_FLAGS) != 0;
}

bool rtcArmWake(uint32_t seconds) {
if (seconds == 0 || !rtcWakeConfigured()) return false;
const uint8_t addr = rtcBegin();
// Clock running (STOP = 0) or neither the countdown nor the alarm advances.
if (addr == 0 || !rtcWrite1(addr, RTC_REG_CTRL1, 0x00) || !rtcQuiesce(addr)) {
od_log_warn("RTC: arm failed (I2C)");
return false;
}
if (seconds <= 255) {
return rtcWrite1(addr, RTC_REG_TIMER, (uint8_t)seconds) &&
rtcWrite1(addr, RTC_REG_TIMER_CTRL, RTC_TIMER_ENABLE | RTC_TIMER_1HZ) &&
rtcWrite1(addr, RTC_REG_CTRL2, RTC_CTRL2_TIE);
}
const uint32_t minutes = (seconds + 59) / 60;
if (minutes <= 255) {
return rtcWrite1(addr, RTC_REG_TIMER, (uint8_t)minutes) &&
rtcWrite1(addr, RTC_REG_TIMER_CTRL, RTC_TIMER_ENABLE | RTC_TIMER_1_60HZ) &&
rtcWrite1(addr, RTC_REG_CTRL2, RTC_CTRL2_TIE);
}
// Beyond 255 min (deep_sleep_time_seconds tops out near 18 h): minute+hour
// alarm, which matches once within the next 24 h.
uint8_t now[3];
if (!rtcRead(addr, RTC_REG_SECONDS, now, sizeof(now))) return false;
const uint32_t nowMin = (uint32_t)bcdToBin(now[2] & 0x3F) * 60u + bcdToBin(now[1] & 0x7F);
const uint32_t target = (nowMin + minutes) % (24u * 60u);
const uint8_t alarm[4] = {binToBcd((uint8_t)(target % 60)), binToBcd((uint8_t)(target / 60)),
RTC_ALARM_OFF, RTC_ALARM_OFF};
return rtcWrite(addr, RTC_REG_ALARM_MIN, alarm, sizeof(alarm)) &&
rtcWrite1(addr, RTC_REG_CTRL2, RTC_CTRL2_AIE);
}

#endif // TARGET_ESP32
27 changes: 27 additions & 0 deletions src/rtc_pcf8563.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#ifndef RTC_PCF8563_H
#define RTC_PCF8563_H

#include <stdint.h>

// NXP PCF8563 / M5Stack BM8563 RTC as a power-off wake source
// (SensorType OD_SENSOR_TYPE_PCF8563). Used only together with the MOSFET
// power latch (DEVICE_FLAG_BATTERY_LATCH): instead of holding the rail through
// ESP32 deep sleep, the sleep path arms the RTC and releases the latch; the
// RTC's open-drain INT line switches the board back on (M5Paper wiring).

// True when an RTC sensor block is configured AND the MOSFET latch is enabled.
bool rtcWakeConfigured(void);

// Call once per boot after full_config_init() (the board's power hold is
// already asserted by then). Clears the timer/alarm flags and disables both
// interrupts so INT releases, and returns true if one of them had fired, i.e.
// this cold boot is an RTC wake. No I2C traffic unless rtcWakeConfigured().
bool rtcConsumeWake(void);

// Arm a wake `seconds` from now. <= 255 s: 1 Hz countdown timer; <= 255 min:
// 1/60 Hz countdown (rounded up to whole minutes, first tick may come up to a
// minute early); longer: minute/hour alarm. Returns false on any I2C failure,
// in which case the caller must keep the rail held and use ESP32 deep sleep.
bool rtcArmWake(uint32_t seconds);

#endif
2 changes: 1 addition & 1 deletion tools/od-device-cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ class Block(NamedTuple):
# sets only trigger a warning (not an error) on encode - the firmware may define values
# this tool doesn't know about yet.
VALID_ENUMS: dict[tuple[str, str], set[int]] = {
("sensors", "sensor_type"): {1, 2, 3, 4, 5, 6, 7}, # SENSOR_TYPE_TEMPERATURE..AHT20
("sensors", "sensor_type"): {1, 2, 3, 4, 5, 6, 7, 8}, # SENSOR_TYPE_TEMPERATURE..PCF8563
("displays", "color_scheme"): {0, 1, 2, 3, 4, 5, 6, 7, 8, 100, 101, 102}, # COLOR_SCHEME_MONO..BWGBRY_SPLIT, RGB565/RGB888/RGB16BPC
("touch_controllers", "touch_ic_type"): {0, 1}, # TOUCH_IC_NONE, TOUCH_IC_GT911
("power_option", "capacity_estimator"): {1, 2, 3, 4, 5}, # CAPACITY_EST_LI_ION..SEEED_LI_ION
Expand Down
Loading