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
Open
LilyGo T5 S3 Pro / Pro Lite: own ED047TC2 waveform, boost-EN frontlight, and a real deep sleep#51jetaudio wants to merge 5 commits into
jetaudio wants to merge 5 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 bytools/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_EPDquantises a canvas byte as(v + bayer - 8) >> 4, so onlyv == (level << 4) | 8lands 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: nolut_eraserflash, 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 plaindisplayBuffer()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 = remainunconditionally — false on an idle panel — clearing the flag the caller just raised. BetweenxQueueSend()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,remainnever clears, and the nextwaitDisplay()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 = GPIO2with 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/QONpin wired to the S4 button straight to ground.holdPowerRails()drives latch pins OUTPUT HIGH first thing insetup(), 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 onmain), alarms and interrupts would simply never have been seen.latchConflictsWithBus()cannot catch this: it guards display and SD bus pins, andSensorsConfigcarries no RTC interrupt pin to compare against.The profile also gains a note at
input.powerthat 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):
TouchConfig::holdResetInSleeprather than inferred from a missingpowerEnable.esp_sleep_config_gpio_isolate()runs.gpio_hold_ensurvives 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 nowgpio_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:
minHoldPermilleremaps(0, full]onto[floor, full], keeping the curve's shape while making 1% the dimmest level the hardware can sustain;minStartPermillegives 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.off()writes a zero duty, but the pad still belongs to LEDC and sleep entry hands it toesp_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, soprepareForDeepSleep()drives and latches it off, andbegin()releases the hold on the next boot.Notes for review
grayDark/grayLight,grayNudgeInFastBank,holdResetInSleep,minHoldPermille/minStartPermille. No other profile is touched.settleDisplay()is the one change that affects everyLgfxEpdDriverboard. 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.mdis 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.-e lilygo(Crosspoint firmware, ESP32-S3), and both variants were run on hardware.🤖 Generated with Claude Code
https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau