diff --git a/docs/FINDINGS_M5PAPER_BRINGUP_2026-10-26.md b/docs/FINDINGS_M5PAPER_BRINGUP_2026-10-26.md new file mode 100644 index 0000000..12d54a8 --- /dev/null +++ b/docs/FINDINGS_M5PAPER_BRINGUP_2026-10-26.md @@ -0,0 +1,319 @@ +# M5Paper (original, ESP32) hardware bring-up — 2026-10-26 + +Hardware validation of the two M5Paper branches on a real device: + +- `greg/awesome-dijkstra-5pq3ha` (PR 1): panel IC 3005 on the FastEPD IT8951 + path, `m5paper_init()`. +- `greg/awesome-dijkstra-5pq3ha-rtc` (PR 2, stacked): RTC power-off sleep, + sensor type 8 (PCF8563/BM8563) and the MOSFET latch. + +**Device:** original M5Stack M5Paper. ESP32-D0WDQ6-V3, MAC 3c:61:05:09:20:54, +16 MB flash, **CP2104** USB-UART (not CH9102, so it needs the Silicon Labs +CP210x driver). The panel is rigid, which suggests v1.0, but this is not +confirmed. Built as `esp32-wrover-e-N4R8`, pioarduino 55.03.39, flashed from +Windows 11 on COM16. The original 16 MB flash was backed up before erasing. + +**Host-side tooling:** serial logging opened with DTR/RTS deasserted, because +opening the CP2104 normally resets the ESP32. BLE uploads used `py-opendisplay` +7.14.1. Touch and button events are only published in the BLE MSD, not on +serial at INFO level, so they were read from the advertisements or from a +`-DOD_LOG_LEVEL=OD_LOG_DEBUG` build. + +## Summary + +| Area | Result | +|---|---| +| Boot, `m5paper_init()`, FastEPD init | PASS | +| 4bpp gray image | **FAIL → fixed** (byte-swapped IT8951 load, see §1) | +| Orientation | PASS (landscape, not mirrored) | +| Contrast at FastEPD's fixed VCOM −1.5 V | Acceptable to the eye; −2.30 V patch kept unapplied (§2) | +| Repeated pushes | PASS (several full pushes, no wedge, no WDT) | +| Mono (color_scheme 0), DU fast path | **FAIL → fixed** (1bpp bit order, §1). Full clean; DU readable with normal DU ghosting | +| FAST refresh in 4bpp mode | **FAIL → fixed** (gray data sent as 1bpp DU, §1) | +| Touch (GT911) | PASS with flags **0x06**. Fixed GPIO36 errors (§3) | +| Buttons 37/38/39 | PASS (press and release on all three) | +| Battery hold (GPIO2) after unplugging USB | PASS (19 min on battery, still advertising) | +| Power-button hold time | About **1.1 s** minimum; hold 2–3 s (§6) | +| Battery voltage factor | **0xAE** (2:1 divider, measured at full charge, §7) | +| RTC sleep on USB (fallback path) | PASS | +| RTC power-off on battery, 120 s | PASS: 20 arms, 19 wakes + 1 pending, 0 missed (§4) | +| 600 s (1/60 Hz countdown) | PASS on USB: RTC timer fired (TF set) before the ESP32 timer | +| 16000 s (minute/hour alarm) | PASS on USB (alarm flag at the expected minute) and on battery (woke after 4 h 27 min) (§4) | +| Image push in a wake window | see §5 | +| Off current | **Not measured** | +| Boot screen not redrawn on battery RTC wakes | PASS: a gray image survived about 1.5 h (about 38 wakes) on battery | +| Wake-window power | 80 MHz CPU, Grove 5 V off (§8): 28 wakes and an upload on battery, PASS. Saving not measured | +| All CI targets build | PASS (12 envs in `firmware-targets.json`, not 11) | + +## 1. IT8951 byte order (FastEPD bug) — fixed on PR 1 + +**Symptom:** the 4bpp test image was readable and the right way up, but fine +detail looked "interlaced / shifted horizontally". + +**Cause:** FastEPD 2.2.0 loads IT8951 frames with `IT8951_LDIMG_L_ENDIAN` +unless the panel has `BB_PANEL_FLAG_MIRROR_X`. That swaps the two bytes of +every 16-bit word, so 4bpp pixel pairs land 2 px off and 1bpp bytes land +8 px off. M5Stack's M5EPD driver sends the same high-nibble-first bytes with +`IT8951_LDIMG_B_ENDIAN`. The reTerminal ED103TC2 is MIRROR_X, so it already +gets B_ENDIAN, and nobody had seen the bug. The old note in +`fastepd_partial_refresh()` about "IT8951 region DU had persistent edge +alignment issues" may be the same bug. + +**Fix (`f271dc5`):** for unmirrored IT8951 panels (only the M5Paper today), +full refreshes go through `it8951_full_write_unmirrored()` in +`src/display_fastepd.cpp`. It sends the same frame with the same GC16 +waveform, loaded B_ENDIAN. `it8951_fullscreen_du()` uses B_ENDIAN too. +Mirrored panels keep FastEPD's own writer, unchanged. + +**Verified:** the 4bpp image is sharp after the fix (user confirmed). +A temporary readback of IT8951 memory after each load (MEM_BST_RD, sampled +every 27th row, not committed) confirms the frame lands as sent: + +| Path | Sampled bytes matching the framebuffer | +|---|---| +| Full, 4bpp (GC16) | 19200/19200 | +| Full, 1bpp (mono config) | 2400/2400 (1232 with the old byte swap) | +| DU, 1bpp (mono config, FAST upload) | 2400/2400 | + +Worth reporting upstream to FastEPD. + +**1bpp bit order (fixed, `34548b0`, PR 1):** with the bytes loading correctly, +a full mono refresh was still unreadable ("vertical interlacing"). A test image +with four bands, each pre-scrambled on the host (none, bits reversed within +each byte, bytes swapped, both), showed only the bit-reversed band cleanly: +the IT8951 1bpp mode takes the leftmost pixel from bit 0, while FastEPD packs +it in bit 7. Mirrored panels never showed it, because FastEPD reverses the +byte order of each row for them, and that plus LSB-first mirrors the whole row. +Unmirrored panels now send each 1bpp byte bit-reversed (full writes and DU). +After the fix a full refresh of a text, ruler and 1 px line test image is +clean (user confirmed). A FAST (DU) refresh is readable but shows faint ghosts +of the previous image and some white specks in large black areas, which is +normal for the DU waveform. + +**FAST refresh in 4bpp mode (fixed, `27016a4`, PR 1):** the readback also +showed that a FAST upload with the GRAY16 config sent the 4bpp framebuffer +through `it8951_fullscreen_du()`, which loads w/8 bytes per row as 1bpp. Every +sampled byte in IT8951 memory equalled the raw 4bpp bytes, so the top quarter +of the gray image would show as 1bpp noise. The parallel path already fell +back to a full refresh for 4bpp. The IT8951 path now does too, for direct +and partial transfers. After the fix the same upload logs `EPD refresh: FAST` +and loads through the 4bpp full path (19200/19200). The ED103TC2 (reTerminal) +takes the same branch but was not tested. + +## 2. VCOM + +`bbepInitIT8951()` always writes VCOM −1.5 V. The `iVCOM` passed to +`setPanelSize()` is not used on the IT8951 path, so the branch's `-1600` is a +no-op. M5EPD uses −2.30 V for this panel. Gray steps 0 (black) to 15 (white) +looked right at −1.5 V, so nothing was changed. A tested patch that writes +−2.30 V after `initIT8951()` for panel 3005 is available if a side-by-side +comparison favours it. + +## 3. Touch + +- **Mapping:** the GT911 reports portrait coordinates (540×960). With + flags 0 the firmware clamps y to 539. Corner taps logged with SWAP_XY only: + TL (63,483), TR (906,488), BR (903,46), BL (50,58), centre (479,271). So + x is correct and y is inverted, giving **flags = SWAP_XY | INVERT_Y = 0x06**, + the same as M5EPD's ROTATE_0. The opendisplay.org `m5-paper` preset is + updated (`a075d2c`). +- **GPIO36 errors (fixed, `f271dc5`):** GT911 INT is GPIO36, an input-only pad + with no internal pull-up. `gt911_int_wake_before_irq()`, `attach_touch_int()` + and `gt911_hw_reset()` drove it as an output or set INPUT_PULLUP, which + logged `GPIO can only be used as input mode` and `gpio_pullup_en` errors on + every boot and every EPD refresh. These pads are now never driven and are + read as plain INPUT. +- **I2C address varies:** because INT can't be driven, the GT911 comes up at + 0x5D on some boots and 0x14 on others. Auto-detect handles it. + +## 4. RTC power-off (PR 2) + +**Schematic (A4-SYS_PWR):** FET1 (PMOS) switches VBAT. Its gate, `PWR_EN`, +is pulled up to VBAT_IN and pulled low by FET2 (`PS_ON` = GPIO2), by +`KEY_PUSH` (the rocker pushed in) through D10, and by `RTC_ALM` (PCF8563 INT) +through D7. `KEY_PUSH_IO` (GPIO38) reads the key through D8 and appears to +share the R34 pull-up node with `RTC_ALM`, so an asserted RTC INT probably +also reads as "rocker pushed" until it is cleared. + +**USB fallback:** `Entering deep sleep for 120 seconds (config), RTC +power-off`, then a timer wake (`Reset reason: DEEPSLEEP`). The boot screen is +not redrawn, the 20 s window applies, and it re-arms. No `RTC: not reachable` +or `arm failed` errors. + +**Battery, 120 s:** BLE presence alone was misleading, because the Windows +scanner misses whole 20 s windows. A temporary NVS counter (not committed) +recorded arms and wakes instead. Over about 46 min (16 min on USB, then +30 min on battery): **arms = 20, wake boots = 19, plus 1 pending at replug** +(`TC=82 T=5E`, 94 s left). No arm went without a wake. 18 of the 19 wake +boots had TF set. The one without was a USB wake where the ESP32 timer beat +the RTC by about 1 s (`T=01` seen at that boot). + +**600 s:** boot dump at the wake shows `TC=83 T=0A C2=05`: 1/60 Hz countdown +from 10, TF set, TIE on. + +**16000 s:** armed at RTC 13:33:27. 267 min (rounded up) gives a target of +18:00. Readback `AL=00 18 80 80`, `C2=02` (AIE), `TC=03` (timer off). Correct. +It fires up to 59 s early because the alarm ignores seconds. + +**16000 s fire (USB, second run):** armed at RTC 04:11:40 with `AL=38 08 80 80` +(08:38). For the test only, the ESP32 backstop timer was extended by 600 s so +the RTC would act first. At the next boot (RTC 08:42:38) the dump showed +`C2=0A`: **AF set**, AIE on, so the alarm matched. The ESP32 itself woke on its +timer (`Wake-up reason: timer`): on USB the RTC INT only pulls `PWR_EN`, and the +rail is already up, so nothing wakes the chip. That is expected. On battery the +same INT switches the rail on. The ESP32 timer (16600 s nominal) expired after +about 16260 s of RTC time, so the ESP32 slow clock ran about 2% fast. The wake +cycle after that re-armed the 120 s countdown and fired normally (`C2=05`, TF). +**16000 s wake on battery** (80 MHz build `496255d`): a one-time 0x0053 +sleep of 16000 s was sent at 22:07:39. The device did not advertise again until +02:34:10, 15991 s later. That is within the alarm's one-minute resolution. +It then went back to the configured 120 s cycle (gaps of 141–146 s). The +reported battery was 4.14–4.15 V, down from 4.15–4.17 V the evening before. + +**Leftover RTC state from M5Stack's factory firmware:** with the USB-only +config (no sensor type 8), the board switched itself back on about 60–75 s +after every reset-button power-off. A repeating RTC timer left armed by the +factory firmware survives a flash erase, because the RTC is on VBAT_IN. The +RTC config clears it at every boot. **Fixed (`0b7bf40`, PR 1):** +`m5paper_init()` now clears the RTC interrupts, timer and alarm on every +boot. On -rtc it skips that when RTC power-off is configured, because +`rtcConsumeWake()` does the clear itself and needs the flags. Verified with a +temporary readback: armed `C2=01 TC=82` before, `C2=00 TC=03` after. Not +verified: that the phantom power-on is gone after a battery power-off. + +**VL flag:** the seconds register has VL (bit 7) set, meaning the RTC lost +power at some point (probably long ago). The firmware doesn't use wall-clock +time, so it's harmless. + +## 5. Image push in a wake window + +PASS on USB, pushed during a 20 s timer-wake window: `[EPD session] +acquire: COLD bring-up` (the IT8951 re-inits after deep sleep), then +`DW complete: 71 chunks, 259200/259200 bytes … 11.92 s` and `EPD refresh: +FULL`, `refreshSuccess=1`. It was a full image transfer, as expected. Not +repeated during a battery RTC-wake window, where the boot path differs only in +how the wake is detected. + +## 6. Power-on, power-off and buttons + +- **Power button = rocker pushed in** (KEY_PUSH, GPIO38). The rocker only + powers the rail while it is held; the board stays on once `m5paper_init()` + drives GPIO2. Timed on the PC over serial (USB, EN reset, 3 runs): first app + log line at 1058 ms, `M5Paper init` at 1073 ms, so **about 1.1 s minimum + hold**. Roughly 0.9 s of that is before the app starts: the prebuilt + bootloader validates the whole app image on every power-on + (`BOOTLOADER_SKIP_VALIDATE_ON_POWER_ON` off) and PSRAM is set up. On battery, + after 0x0052: 0.3 s and 1 s holds did not power on, a hand-timed 2 s hold + did once and not once, and 5 s worked every time, including 15 s after a + power-off (so it is hold time, not leftover charge in the rail). M5Stack's + "hold 2 s" advice fits; 3 s is reliable. Driving GPIO2 from an earlier hook + (a global constructor) might save the app-side part; not tried. +- **Reset button (back)** cuts power on battery (GPIO2 released), unless an + RTC interrupt is holding `PWR_EN`. +- **`CMD_POWER_OFF` (0x0052)** used to NACK on the MOSFET latch. It now + drives GPIO2 low via `powerLatchTriggerOff()` (on -rtc). On USB the board + went silent on serial and BLE for 60 s and the reset button brought it + back. **On battery: PASS.** Sent at 17:47:07, advertising stopped by 17:47:16 +and stayed off for 20 min (one RSSI −127 cached report from the Windows +scanner at 17:48, not a real power-on), so no phantom power-on. A 2 s rocker +push brought it back (advertising and answering at 4.13 V). The ACK was not +received (fire-and-forget). The vendored protocol header still says + "D-FF only" and needs an upstream update; no wire bytes change. +- **Deep-sleep button wake:** a classic ESP32 has ext0 only, so just GPIO37 + (rocker up) is armed. The log warns `pin 37 has no internal pull`. 38 and 39 + are timer-only. On battery with RTC power-off the ESP32 has no power at all, + so only the rocker push (hardware) or the RTC can switch it on. Rocker + up/down can't. +- **Cold boot stays awake 120 s** before the first sleep + (`DEFAULT_MIN_WAKE_TIME_SECONDS`). This is by design. Timer wakes use the + 20 s window. + +## 7. Battery voltage + +The schematic shows R83 3 kΩ / R86 11 kΩ (VBAT_S = 0.79 × VBAT). M5EPD +assumes 2:1 (`SCALE_INV 2`). After a night on USB (battery full, about 4.20 V), +GPIO35 read raw 2416 and the eFuse-calibrated `analogReadMilliVolts()` read +2101 mV, which is exactly 2:1. So this board does not match that schematic +revision. The firmware computes V = raw × factor / 100000, so 4.20 V needs +factor 174 = **0xAE**. The `m5-paper` preset is updated on both opendisplay.org +branches (`5479e3f`, merged as `4d6e092`). The board now reports 4.20 V. Not +checked against a multimeter, or at a lower charge, where ADC non-linearity +may add error. + +## 8. Power during the wake window + +Between wakes the RTC power-off cuts the whole board, so the 20 s advertising +window is where the battery goes. The M5Paper build has no WiFi +(`OPENDISPLAY_ENABLE_WIFI` is not set for `esp32-wrover-e-N4R8`) and no +ESP-IDF power management (`CONFIG_PM_ENABLE` off), so the CPU polls through +the window. Two changes (`321f3be`, PR 1): + +- **80 MHz CPU**, set in `m5paper_init()` right after the GPIO2 hold, so the + power-button hold time is unchanged (reset to hold 1068 ms vs 1073 ms). + A 259200-byte gray upload took 21.72 s on the device vs 21.76 s at 240 MHz + (BLE-bound), and its refresh 1.49 s vs 1.47 s. The cold-boot QR boot screen + now takes 8.2 s to render instead of 4.2 s. RTC wakes don't draw it. +- **Grove 5 V boost off:** GPIO5 (EXT power) is now driven LOW. M5EPD turns it + on by default; nothing here uses the Grove ports. Touch still works + (a corner tap read x=18, y=51). + +Verified on USB with the RTC config: cold boot, RTC power-off sleep, timer +wake at 80 MHz with the 20 s window, back to sleep. Not measured: the current +saved (no meter). + +**On battery at 80 MHz** (`496255d`, Grove off, 120 s sleep, 20 s window): +a 1bpp full upload in a wake window took 18 s and displayed correctly. A BLE +watch from 20:56 to 22:06 saw 21 distinct windows. Every gap was one cycle +(127–159 s) or two (272–303 s, the Windows scanner missing a window, as seen +on USB). There were no longer gaps. That is 28 cycles in +67 min. The reported battery stayed at 4.15–4.17 V throughout (ADC noise). +Turning the Grove ports back on needs a config option, for example a new +DeviceFlags bit, which is a protocol change upstream. + +**Wake-interval settings** (all config, no firmware change): +`deep_sleep_time_seconds` (time asleep, up to about 18 h; the RTC picks the +seconds countdown, the minutes countdown or the alarm), `sleep_timeout_ms` (the +advertising window, 20 s in the preset), `min_wake_time_seconds` (the cold-boot +hold, 120 s by default), and command 0x0053 (host override for one cycle, 60 s +minimum). A longer sleep and a shorter window are the biggest saving. + +## Changes + +| Commit | Branch | What | +|---|---|---| +| `f271dc5` | Firmware `greg/awesome-dijkstra-5pq3ha` | IT8951 B_ENDIAN for unmirrored panels; never drive input-only touch INT | +| `207ac66` | Firmware `-rtc` | merge of the above | +| `a075d2c` | opendisplay.org `greg/awesome-dijkstra-5pq3ha` | `m5-paper` touch flags 0x6 | +| `05de81b` | opendisplay.org `-rtc` | merge of the above | +| `0b7bf40` | Firmware `greg/awesome-dijkstra-5pq3ha` | `m5paper_init()` clears the RTC interrupts, timer and alarm | +| `d0072af` | Firmware `-rtc` | merge of the above; 0x0052 on the MOSFET latch; skip the board-init RTC clear when RTC power-off is configured | + +| `5479e3f` | opendisplay.org `greg/awesome-dijkstra-5pq3ha` | `m5-paper` voltage_scaling_factor 0xAE | +| `4d6e092` | opendisplay.org `-rtc` | merge of the above | +| `27016a4` | Firmware `greg/awesome-dijkstra-5pq3ha` | IT8951 FAST refresh in 4bpp falls back to full | +| `2d9a8f8` | Firmware `-rtc` | merge of the above | +| `d672a6b` | Firmware `-rtc` | readback, alarm-fire and battery results in this report | +| `34548b0` | Firmware `greg/awesome-dijkstra-5pq3ha` | bit-reverse 1bpp rows for unmirrored IT8951 panels | +| `314e411` | Firmware `-rtc` | merge of the above | +| `35ec236` | Firmware `-rtc` | 1bpp bit-order result in this report | +| `1a94f60`, `0cb3e8e` | Firmware `-rtc` | 0x0052 battery result, power-button hold time | +| `321f3be` | Firmware `greg/awesome-dijkstra-5pq3ha` | M5Paper at 80 MHz, Grove 5 V boost off | +| `f4f73cc` | Firmware `-rtc` | merge of the above | +| `54ea4fa` | Firmware `-rtc` | battery RTC wake and wake-window power results in this report | +| `32a60fd`, `496255d` | Firmware PR 1, merged into `-rtc` | comment on re-enabling the Grove 5 V | +| `16689b2` | Firmware `-rtc` | 80 MHz battery wake results in this report | +| (this commit) | Firmware `-rtc` | 16000 s battery wake result in this report | + +No changes to the RTC driver itself were needed. + +## Open items + +- DU fast-refresh quality (ghosting, fill); a different fast waveform may do better. +- Battery voltage against a multimeter, and at lower charge. +- Off-current measurement. +- A config option to turn the Grove 5 V back on (needs a protocol flag). +- Wake-window current at 80 MHz vs 240 MHz (needs a meter). +- FAST refresh in 4bpp on the ED103TC2 (reTerminal). +- Follow-ups: update the 0x0052 `@targets`/`@errors` text in + opendisplay-protocol (MOSFET latch now supported); upstream FastEPD endian + bug report. diff --git a/include/opendisplay_structs.h b/include/opendisplay_structs.h index eac6917..f94c673 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,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. * @@ -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 -- @@ -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 @@ -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)); 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/device_control.cpp b/src/device_control.cpp index f5867a8..9e18dbb 100644 --- a/src/device_control.cpp +++ b/src/device_control.cpp @@ -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. diff --git a/src/display_fastepd.cpp b/src/display_fastepd.cpp index 7c8965e..03beb10 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); @@ -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; } @@ -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) { @@ -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; @@ -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(); } @@ -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; @@ -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 @@ -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(); } @@ -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). diff --git a/src/display_service.cpp b/src/display_service.cpp index 74d92f6..2e03012 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 || @@ -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)"); } diff --git a/src/main.cpp b/src/main.cpp index 7b9e148..d86ff6d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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" @@ -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) @@ -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(); @@ -1509,6 +1534,74 @@ 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()); + // With RTC power-off configured, rtcConsumeWake() clears it later in + // setup() and needs the flags to recognise an RTC wake. + if (!rtcWakeConfigured()) m5paperRtcQuiesce(); +#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(); diff --git a/src/power_latch.cpp b/src/power_latch.cpp index 9c787f2..d3166f7 100644 --- a/src/power_latch.cpp +++ b/src/power_latch.cpp @@ -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(); } diff --git a/src/power_latch.h b/src/power_latch.h index 687fe29..c404264 100644 --- a/src/power_latch.h +++ b/src/power_latch.h @@ -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(); diff --git a/src/rtc_pcf8563.cpp b/src/rtc_pcf8563.cpp new file mode 100644 index 0000000..d8f2632 --- /dev/null +++ b/src/rtc_pcf8563.cpp @@ -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 +#include + +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 diff --git a/src/rtc_pcf8563.h b/src/rtc_pcf8563.h new file mode 100644 index 0000000..d46527a --- /dev/null +++ b/src/rtc_pcf8563.h @@ -0,0 +1,27 @@ +#ifndef RTC_PCF8563_H +#define RTC_PCF8563_H + +#include + +// 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 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); diff --git a/tools/od-device-cli.py b/tools/od-device-cli.py index 57f4036..d0fdebb 100755 --- a/tools/od-device-cli.py +++ b/tools/od-device-cli.py @@ -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