Skip to content

LilyGo T5 S3 Pro / Pro Lite: own ED047TC2 waveform, boost-EN frontlight, and a real deep sleep - #51

Open
jetaudio wants to merge 5 commits into
Free-Ink:mainfrom
jetaudio:feat/lilygo-t5s3-pro
Open

LilyGo T5 S3 Pro / Pro Lite: own ED047TC2 waveform, boost-EN frontlight, and a real deep sleep#51
jetaudio wants to merge 5 commits into
Free-Ink:mainfrom
jetaudio:feat/lilygo-t5s3-pro

Conversation

@jetaudio

Copy link
Copy Markdown

Everything here comes out of bringing the LilyGo T5 S3 Pro and Pro Lite up as a daily-driver reader. Most of it is board-specific, but three pieces are general and opt-in, so no other board changes behaviour unless its profile asks.

Both variants were tested on hardware. LILYGO publishes a schematic only for the Pro; the vendor states the variants share the core design, and the findings below held on both.

The panel was running on the wrong waveform

The board was using LovyanGFX's stock LUTs, whose fast bank drives only the two rails. Anti-aliasing greys had nowhere to land, so Panel_EPD::_draw_pixels() Bayer-dithered them to black — hard speckle along every glyph edge — and a clean refresh left pixels behind, because a two-level waveform never flashes both ways.

This adds a real ED047TC2 waveform (BoardT5S3/ED047TC2Waveform, generated by tools/gen_ed047tc2_waveform.py, selected against the panel temperature from the on-board thermistor), plus the driver seams it needs:

  • LgfxEpdConfig::grayDark / grayLight — the canvas byte to write for a grey is a property of the board's LUT, not a constant. Panel_EPD quantises a canvas byte as (v + bayer - 8) >> 4, so only v == (level << 4) | 8 lands on one level for every Bayer cell; anything else alternates between two and shows as speckle. grayLevelByte() builds a safe value. Boards that set neither keep the even thirds the driver has always written.
  • LgfxEpdConfig::grayNudgeInFastBank — true when the board's fast bank carries grey columns, which lets the grayscale push go out through the differential bank: no lut_eraser flash, and the same bank the B/W base used, so the per-pixel diff still skips what did not change. Left false elsewhere; those boards keep exactly the push they have today.
  • displayGrayscaleFrame() / supportsGrayFrame() — a page and its greys reach the panel as one waveform. The two-push flow it replaces existed to normalize fringe pixels to black before a from-black grey nudge; grey columns are self-normalizing, so the base pass has nothing left to do and the page has no intermediate state to show. It also halves the drive imbalance per page turn. Falls back to a plain displayBuffer() where the driver cannot do it, or where output is inverted — the planes' meaning does not survive inversion.

A freeze worth flagging on its own

waitDisplay() can return before the refresh it was called for has begun. Panel_EPD::display() raises _display_busy, yields (vTaskDelay(1)), and only then posts the job; the yield lets the panel task reach the top of its loop, where it assigns _display_busy = remain unconditionally — false on an idle panel — clearing the flag the caller just raised. Between xQueueSend() returning and the task waking, the flag reads false for a refresh that has not started, so a caller that trusts it walks into the panel task's diff copy and tears it. Torn step state is how a pixel gets a step index that never terminates, remain never clears, and the next waitDisplay() blocks forever — the reader frozen with input still alive. settleDisplay() yields before waiting, so the task can ingest the job and re-raise the flag.

Board profile: the power latch is not real

The profile carried power.latch0 = GPIO2 with an M5Paper-style "main-power MOSFET, hold HIGH or the board dies on USB unplug" note. IO2 is nothing of the sort here: it is RTC_INT — the PCF8563's open-drain alarm output, pulled up through 10K — and the schematic shows no soft power latch anywhere. Power sequencing is the BQ25896's job, its /QON pin wired to the S4 button straight to ground.

holdPowerRails() drives latch pins OUTPUT HIGH first thing in setup(), so the stale entry had the firmware fighting the RTC's output on every alarm. Nothing had noticed while no alarm was armed — but now that the RTC is wired up (already on main), alarms and interrupts would simply never have been seen. latchConflictsWithBus() cannot catch this: it guards display and SD bus pins, and SensorsConfig carries no RTC interrupt pin to compare against.

The profile also gains a note at input.power that IO48 must not be mapped as Confirm — on the Pro Lite it reads spurious active-low pulses, which opened the reader menu by itself.

Deep sleep: the peripherals with no rail to cut

powerDownRailsForSleep() only ever cut rails, so a board with no gated rail was left alone entirely. Here that meant the GT911 kept scanning through deep sleep — several mA, on its own the difference between a milliamp-class and a microamp-class sleep.

Two holds fill the gap, both only where there is no rail to cut, and neither applied where the rail is cut (driving an input of an unpowered chip can back-power it through its protection diode):

  • Touch parks in reset, opt-in per board via TouchConfig::holdResetInSleep rather than inferred from a missing powerEnable.
  • SD chip-select is held deasserted, so a still-powered card idles deselected instead of floating into an undefined selection state once esp_sleep_config_gpio_isolate() runs.

gpio_hold_en survives the wake reset and a held pad silently swallows writes, so each hold has a matching release on the way back up — InputManager's GT911 and FT6336U bring-up now gpio_hold_dis() the reset pin before the reset dance, which would otherwise be a no-op leaving touch dead after the first wake, and only after a wake. SDCardManager::prepareForSleep() also unmounts cleanly, flushing SdFat's cache rather than leaving the card mid-transaction; boards that cut the SD rail want this too.

Frontlight on a boost converter

Here the PWM gates the EN pin of a PT4103 boost rather than driving the LEDs, which breaks two assumptions:

  • Dimming. A boost emits no light for an on-time under its start-up window, so the dim end of a plain gamma curve falls below the physical floor and the light goes out — which is what blacked it at ordinary reading brightnesses. minHoldPermille remaps (0, full] onto [floor, full], keeping the curve's shape while making 1% the dimmest level the hardware can sustain; minStartPermille gives the off→on edge a brief burst at the ignition floor, since a boost sustains below the duty it can ignite from. Both default to zero — an identity mapping — so unconfigured boards are untouched. The PWM also runs at 1 kHz / 12-bit here; 13 bits made the attach fail outright.
  • Sleep. off() writes a zero duty, but the pad still belongs to LEDC and sleep entry hands it to esp_sleep_config_gpio_isolate(), which floats it — what the light does then is down to an external pull (here, EN through a 100R). A frontlight left lit is the largest load a sleeping reader can carry, so prepareForDeepSleep() drives and latches it off, and begin() releases the hold on the next boot.

Notes for review

  • Every general change is gated on a board-supplied field that defaults to the current behaviour: grayDark/grayLight, grayNudgeInFastBank, holdResetInSleep, minHoldPermille/minStartPermille. No other profile is touched.
  • settleDisplay() is the one change that affects every LgfxEpdDriver board. 1.5.16 shipped this guard, 1.5.17 reverted it on a ghosting suspicion; the ghosting survived the revert, which clears it of that charge.
  • docs/lilygo-t5s3-support.md is updated: it still listed the PCF8563 RTC and the capacitive home key as peripherals the SDK does not cover, though both landed in the profile earlier. The rest of the update covers what this PR adds.
  • Builds clean for -e lilygo (Crosspoint firmware, ESP32-S3), and both variants were run on hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau

jetaudio and others added 5 commits August 22, 2026 23:37
…ields

The T5 S3 Pro / Pro Lite profile carried `power.latch0 = GPIO2` with an
M5Paper-style "main-power MOSFET, hold HIGH or the board dies on USB
unplug" note. GPIO2 is nothing of the sort here: the T5 E-paper S3 Pro
schematic maps ESP32 IO2 to RTC_INT, the PCF8563's open-drain alarm
output pulled up through 10K, and shows no soft power latch anywhere --
power sequencing is the BQ25896's job, its /QON pin wired to the S4
button straight to ground. holdPowerRails() drives latch pins OUTPUT
HIGH first thing in setup(), so the stale entry had the firmware
fighting the RTC's output on every alarm. Nothing had noticed while no
alarm was armed; now that the RTC is wired up, alarms and interrupts
would simply never have been seen. (latchConflictsWithBus() cannot catch
this -- it guards display and SD bus pins, and SensorsConfig carries no
RTC interrupt pin to compare against.)

The rest is new configuration for the two commits that follow:

* TouchConfig::holdResetInSleep -- park the digitizer in reset through
  deep sleep on boards with no switched touch rail. Opt-in rather than
  inferred from `powerEnable < 0`, because a pin named "reset" is not
  always one: MURPHY_M3's touch pin 45 is an active-LOW PMOS power gate,
  so driving it LOW would POWER the controller, the exact opposite of
  the intent, on the one board the inference would silently catch.

* FrontlightConfig::minStartPermille / minHoldPermille -- boost-driver
  floors, zero (and inert) on boards whose LEDs hang off the PWM pin.

* The frontlight itself moves to 1 kHz / 12-bit. What the PT4103 needs
  is a minimum ON-TIME, not a minimum duty (~10 us lit, ~4.4 us did
  not), so the PWM period sets how dim the light can go: at 5 kHz the
  5 us hold floor was 25 permille -- already ~18% of perceived full
  brightness, and 1% -> 2% moved the pulse by 0.2 us, under the
  threshold where the boost's output changes at all. At 1 kHz the same
  on-times are 10 and 5 permille, so 1% lands five times dimmer and each
  LSB is 244 ns instead of 49 ns. 1 kHz is also the floor for the
  frequency itself: IEEE 1789's low-risk flicker limit is depth <
  8% x f_Hz and these near-floor pulses are ~100% depth. 13-bit at the
  old 5 kHz is what failed the attach outright -- the Arduino-3 LEDC
  path auto-picks its clock and 5 kHz x 2^13 = 40.96 MHz overran the
  40 MHz XTAL, leaving the light completely dead.

Also documents, at input.power, that IO48 must not be mapped as Confirm:
on the Pro Lite it reads spurious active-low pulses, which opened the
reader menu by itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau
powerDownRailsForSleep() only ever cut rails. A board with no gated rail
was left alone entirely -- so on the T5 S3 Pro the GT911 kept scanning
all through deep sleep, several mA on its own, the difference between a
milliamp-class and a microamp-class sleep.

Two holds fill that gap, both only where there is no rail to cut:

* Touch parks in reset (asserted LOW) on profiles that opt in via
  TouchConfig::holdResetInSleep. Opt-in rather than inferred from a
  missing powerEnable, because a board may have other reasons to leave
  RESET alone.
* SD chip-select is held DEASSERTED (HIGH), so a card that keeps its
  power idles deselected instead of floating into an undefined selection
  state once esp_sleep_config_gpio_isolate() runs.

Neither is applied where the rail IS cut, for the same reason RESET is
not driven HIGH there: driving an input of an unpowered chip can
back-power it through its protection diode.

gpio_hold_en survives the deep-sleep wake reset, and a held pad silently
swallows writes, so every hold needs a release on the way back up:
InputManager's GT911 and FT6336U bring-up now call gpio_hold_dis() on
the reset pin before the reset dance, which would otherwise be a no-op
and leave touch dead after the first wake -- and only after a wake.

SDCardManager::prepareForSleep() also unmounts cleanly before sleep,
flushing SdFat's FAT/directory cache rather than leaving the card
mid-transaction. Boards that cut the SD rail want this too: unmounting
before the power cut beats yanking the rail with the cache dirty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau
…yscale

The T5 S3 Pro panel was running on LovyanGFX's stock LUTs, whose fast
bank drives only the two rails. Anti-aliasing greys had nowhere to land,
so Panel_EPD's _draw_pixels() Bayer-dithered them to black -- hard
speckle along every glyph edge -- and a clean refresh left pixels behind
because the two-level waveform never flashed both ways.

This adds a real ED047TC2 waveform, generated by
tools/gen_ed047tc2_waveform.py and matched to the panel temperature read
from the on-board thermistor, plus the driver plumbing it needs:

* LgfxEpdConfig::grayDark / grayLight, because the canvas byte a board
  should write for a grey is a property of its LUT, not a constant.
  Panel_EPD quantises a canvas byte as (v + bayer - 8) >> 4, so only
  v == (level << 4) | 8 lands on one level for every Bayer cell;
  anything else alternates between two and shows as speckle.
  grayLevelByte() builds a safe value. Boards that do not set these keep
  the even thirds the driver has always written.

* LgfxEpdConfig::grayNudgeInFastBank, true when the board's fast bank
  carries grey columns, which lets the grayscale push go out through the
  differential bank: no lut_eraser flash, and the same bank the B/W base
  used, so the per-pixel diff still skips what did not change. Left
  false for boards on the stock LUTs -- they keep exactly the push they
  have today.

* displayGrayscaleFrame() / supportsGrayFrame(): a page and its greys
  reach the panel as one waveform. The two-push flow it replaces existed
  to normalize fringe pixels to black before a from-black grey nudge;
  the fast bank's grey columns are self-normalizing, so the base pass
  has nothing left to do and the page has no intermediate state to show.
  It also halves the drive imbalance per page turn, since a fringe pixel
  no longer swings black-to-grey twice. Falls back to a plain B/W
  displayBuffer() where the driver cannot do it, or where output is
  inverted (the planes' meaning does not survive inversion).

* settleDisplay(): waitDisplay() alone can return before the queued
  refresh has begun. Panel_EPD's display() raises _display_busy, yields,
  and only then posts the job; the yield lets the panel task reach the
  top of its loop and assign _display_busy = remain -- false on an idle
  panel -- clearing the flag the caller just raised. A caller that
  trusts it walks into the panel task's diff copy and tears it, and torn
  step state is how `remain` never clears and the next waitDisplay()
  blocks forever, the reader frozen with input still alive. Yielding
  before the wait lets the task ingest the job and re-raise the flag.

Grey-level selection also breaks ties on separation rather than loop
order, and the fast bank starts under block 128, without which the
display came up blank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau
… off in sleep

On the T5 S3 Pro the PWM does not drive the LEDs directly -- it gates the
EN pin of a PT4103 boost converter. That breaks two assumptions the
manager was making.

Dimming. A boost produces NO light for an on-time under its start-up
window, so the dim end of a plain gamma curve lands below the physical
floor and the light simply goes out -- which is what blacked it at
ordinary reading brightnesses. Two board-supplied numbers fix it:
minHoldPermille remaps (0, full] onto [floor, full], keeping the curve's
shape while making 1% the dimmest level the hardware can sustain; and
minStartPermille gives the off->on edge a brief burst at the ignition
floor before settling to the target, since a boost sustains below the
duty it can ignite from. Boards that configure neither are untouched --
zero leaves the identity mapping. The PWM also runs at 1 kHz with a
12-bit duty here; 13 bits made the attach fail outright, and the higher
carrier put 1% below the boost's start-up window again.

Deep sleep. off() writes a zero duty, but the pad still belongs to LEDC,
and sleep entry hands it to esp_sleep_config_gpio_isolate(), which
floats it -- what the light does then is down to an external pull, a
board-layout detail (here, EN through a 100R). A frontlight left lit is
by far the largest load a sleeping reader can carry, so
prepareForDeepSleep() stops the PWM, drives the pin to the LED's
inactive level and latches it with gpio_hold_en. begin() releases the
hold on the next boot, without which the light would be dead after the
first sleep/wake cycle -- a held pad silently ignores both the LEDC
routing and any GPIO write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau
The RTC and the capacitive home key were listed as board peripherals the
SDK does not cover; both are in the profile now. Also records where the
ED047TC2 waveform comes from, why the grey canvas bytes are a board
property, the boost-EN frontlight floor, and the touch-reset hold that
deep sleep uses on a board with no touch rail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant