Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 37 additions & 5 deletions docs/lilygo-t5s3-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ PSRAM:
- The 16-gray path (`displayGray` + `copyGrayscaleLsb/Msb` +
`writeGrayscalePlaneStrip`) combines the B/W base with the LSB/MSB planes into
four gray levels in the canvas, then `pushSprite`s at the quality waveform.
- `displayGrayscaleFrame()` sends a page and its anti-aliasing greys as **one**
waveform, on boards whose fast bank carries grey columns
(`LgfxEpdConfig::grayNudgeInFastBank`). 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 page arrives finished rather than showing
an intermediate state.

This board supplies its own ED047TC2 waveform (`BoardT5S3/ED047TC2Waveform`,
generated by `tools/gen_ed047tc2_waveform.py` and selected against the panel
temperature) rather than running on LovyanGFX's stock LUTs, whose fast bank
drives only the two rails — there the anti-aliasing greys get Bayer-dithered to
black. The canvas byte to write for each grey is a property of that LUT, so it
comes from the board via `LgfxEpdConfig::grayDark`/`grayLight`; use
`grayLevelByte()` to build one that quantises cleanly for every Bayer cell.

`BoardConfig::LILYGO_T5S3` carries the geometry, `DisplayController::LgfxEpd`, the
GT911 touch config, the PWM backlight, and the I²C battery gauge.
Expand Down Expand Up @@ -59,7 +73,23 @@ reference port at **`lib/Board_T5S3/FreeInkLgfxConfig.cpp`**.
- **Touch** — GT911, handled by `InputManager` (polled, reset/address dance). The
profile uses `BoardConfig::LILYGO_T5_PRO_GT911`.
- **Backlight** — PWM `FrontlightConfig` on BL_EN (GPIO11), driven by
`FrontlightManager`; `CAP_FRONTLIGHT` is on for this device.
`FrontlightManager`; `CAP_FRONTLIGHT` is on for this device. The PWM gates the
EN pin of a PT4103 boost converter rather than driving the LEDs, so the
profile sets `minHoldPermille`/`minStartPermille`: a boost emits no light
below its start-up window, and it sustains below the duty it can ignite from.
`prepareForDeepSleep()` drives and latches the pin off on the way into sleep —
a zero duty alone leaves the pad to `esp_sleep_config_gpio_isolate()`, which
floats it.
- **RTC** — PCF8563 at 0x51 on the shared SDA39/SCL40 bus. The vendor schematic
shows a PCF8563TS; the product table's "PCF85063" is a misnomer.
- **Home key** — the board has a capacitive key below the panel, absent from the
vendor wiki's button list and found by tracing the GT911 status bit (`0x10`).
The profile sets `touch.hasHomeKey`, without which `wasHomeGesture()` and
`wasHomeKeyHold()` discard every press.
- **No power latch** — ESP32 IO2 is RTC_INT (the PCF8563's open-drain alarm,
pulled up through 10K), not a soft power latch; power sequencing is the
BQ25896's job. A stale `power.latch0 = GPIO2` entry had `holdPowerRails()`
fighting the RTC's output.
- **Battery** — `BatteryMonitor`'s I²C fuel-gauge backend
(`FREEINK_BATTERY_I2C_GAUGE`) reads SoC/voltage from the BQ27220 and charge
status from the BQ25896, with addresses/pins from `BoardProfile.batteryGauge`. It
Expand All @@ -69,7 +99,11 @@ reference port at **`lib/Board_T5S3/FreeInkLgfxConfig.cpp`**.
direct RTC-capable GPIO); the profile sets `input.power = GPIO0`. `InputManager`
reads it, and `PowerManager::armPowerButtonWakeup()` arms deep-sleep wake on it
with the per-SoC source (`ext1` on the S3). This matches the reference port,
which wakes on the same BOOT button.
which wakes on the same BOOT button. Nothing gates the GT911's power here, so
the profile opts into `TouchConfig::holdResetInSleep` and deep sleep parks the
digitizer in reset on T_RST (GPIO9) instead — a GT911 left scanning costs
several mA, which on its own separates a milliamp-class sleep from a
microamp-class one.
- **PCA9535 user button** — a second button behind the I²C expander.
`InputManager::setButtonHook()` takes a board callback that reads the PCA9535 and
returns a `BTN_*` bitmask, so `InputManager` carries no expander code. This
Expand All @@ -80,6 +114,4 @@ reference port at **`lib/Board_T5S3/FreeInkLgfxConfig.cpp`**.
- **PCA9535 expander and TPS65185 PMIC** — the EPD power sequence (via
`LgfxEpdConfig::power`) and the user-button read (via `setButtonHook`) both drive
the same PCA9535, so the board owns the expander and feeds both seams.
- **GT911 home key** — the GT911 backend surfaces the capacitive home-key bit
(status `0x10`) directly via `InputManager::wasHomeKeyPressed()`.
- **PCF85063 RTC, LoRa, GPS** — board peripherals the SDK does not cover.
- **LoRa and GPS** — board peripherals the SDK does not cover.
6 changes: 6 additions & 0 deletions libs/display/FreeInkDisplay/include/FreeInkDisplay.h
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,12 @@ class FreeInkDisplay {
// EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h, bool turnOffScreen = false);
void displayGrayBuffer(bool turnOffScreen = false, const unsigned char* lut = nullptr, bool factoryMode = false);
// Single-push grayscale: composes the framebuffer (B/W) with the staged
// LSB/MSB planes and displays them as one waveform. Falls back to a plain
// B/W displayBuffer() when the driver cannot (or the output is inverted --
// the planes' meaning does not survive inversion).
bool supportsGrayFrame() const;
void displayGrayscaleFrame(RefreshMode mode, bool turnOffScreen = false);
void displayGrayCalibration(uint16_t customX, uint16_t customY, uint16_t customW, uint16_t customH);

void refreshDisplay(RefreshMode mode = FAST_REFRESH, bool turnOffScreen = false);
Expand Down
33 changes: 33 additions & 0 deletions libs/display/FreeInkDisplay/include/LgfxEpdConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,39 @@ struct LgfxEpdConfig {
size_t lutFastStep = 0;
const uint32_t* lutFastest = nullptr;
size_t lutFastestStep = 0;

// The 8-bit canvas values the driver writes for the two anti-aliasing greys.
// Panel_EPD quantises a canvas byte to a 4-bit level as (v + bayer - 8) >> 4,
// so only v == (level << 4) | 8 lands on one level for every cell of the Bayer
// matrix; anything else alternates between two levels and shows up as speckle
// along glyph edges. grayLevelByte() builds a safe value from a level.
//
// Which level to ask for is a property of the board's waveform: the LUT drives
// grey destinations by column, so a canvas level the LUT has no column for
// simply stays at whatever the B/W base left it on.
//
// The defaults are the even thirds this driver has always written. They are not
// Bayer-exact, which does not matter on a board that leaves
// grayNudgeInFastBank false: that path dithers the canvas anyway, and these are
// the densities it has been dithering since the driver was written.
uint8_t grayDark = 0x55;
uint8_t grayLight = 0xAA;

// True when the board's epd_fast LUT carries grey columns as well as the two
// B/W rails, so the grayscale push can go out through the differential bank:
// no lut_eraser flash, and the same bank the B/W base used, which is what lets
// Panel_EPD's per-pixel diff keep skipping everything that did not change.
//
// Left false for a board on LovyanGFX's stock LUTs, whose fast bank drives only
// the rails. Those boards keep the plain epd_fast push they have always had --
// the greys still come out dithered there, which is a waveform gap on that
// board, not something this driver can paper over.
bool grayNudgeInFastBank = false;
};

// Canvas byte that quantises to exactly `level` for every Bayer cell.
constexpr uint8_t grayLevelByte(uint8_t level) {
return static_cast<uint8_t>((level << 4) | 8);
}

} // namespace freeink
12 changes: 12 additions & 0 deletions libs/display/FreeInkDisplay/src/FreeInkDisplay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,18 @@ void FreeInkDisplay::displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t
#endif
}

bool FreeInkDisplay::supportsGrayFrame() const { return !_inverted && _driver && _driver->supportsGrayFrame(); }

void FreeInkDisplay::displayGrayscaleFrame(RefreshMode mode, bool turnOffScreen) {
if (_inverted || _inversionDirty || !_driver || !_driver->supportsGrayFrame()) {
displayBuffer(mode, turnOffScreen);
return;
}
syncPendingAsync();
_shadowValid = false;
_driver->displayGrayFrame(_bus, frameBuffer, toInternal(mode), turnOffScreen);
}

void FreeInkDisplay::displayGrayBuffer(bool turnOffScreen, const unsigned char* lut, bool factoryMode) {
#if defined(SSD1677_PROBE_DEBUG) && SSD1677_PROBE_DEBUG
Serial.printf("[EPD] displayGrayBuffer\n");
Expand Down
116 changes: 108 additions & 8 deletions libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ uint8_t* g_lsb = nullptr;
uint8_t* g_msb = nullptr;
uint16_t g_w = 0, g_h = 0, g_wb = 0;

constexpr uint8_t kGrayBlack = 0x00, kGrayDark = 0x55, kGrayLight = 0xAA, kGrayWhite = 0xFF;
// 0x00 and 0xFF survive Panel_EPD's quantiser at both rails whatever the Bayer
// cell (it clamps), so the two rails need no board input. The greys do -- see
// LgfxEpdConfig::grayDark.
constexpr uint8_t kGrayBlack = 0x00, kGrayWhite = 0xFF;
uint8_t g_grayDark = 0, g_grayLight = 0;

void allocCanvas(uint16_t w, uint16_t h) {
g_w = w;
Expand Down Expand Up @@ -184,7 +188,7 @@ void overlayCanvasGray() {
const uint8_t mask = 0x80 >> bit;
const bool lb = (l & mask) != 0, mb = (m & mask) != 0;
if (!lb && !mb) continue;
drow[bx * 8 + bit] = mb && !lb ? kGrayLight : kGrayDark;
drow[bx * 8 + bit] = (mb && !lb) ? g_grayLight : g_grayDark;
}
}
}
Expand All @@ -197,12 +201,62 @@ void overlayCanvasGray() {
// page refreshed with those modes flashed when its AA pass ran.
lgfx::epd_mode::epd_mode_t g_lastBaseEpdMode = lgfx::epd_mode::epd_fast;

// Wait out a refresh this driver just queued.
//
// waitDisplay() alone can return before the refresh has begun: Panel_EPD's
// 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, then blocking on a queue the job
// has not reached. 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 straight into the panel task's diff copy and tears it. Torn step state
// is how a pixel ends up with a step index that never terminates, `remain`
// never clears, and the next waitDisplay() blocks forever -- the reader frozen
// with input still alive.
//
// Yielding first lets the panel task ingest the job and re-raise the flag; the
// wait after it then means what it says. (1.5.16 shipped this, 1.5.17 reverted
// it on a ghosting suspicion; the ghosting survived the revert, which clears
// this guard of that charge.)
void settleDisplay() {
vTaskDelay(pdMS_TO_TICKS(2));
g_dev.waitDisplay();
}

void pushCanvas(lgfx::epd_mode::epd_mode_t epdMode) {
if (!g_canvas) return;
g_dev.waitDisplay();
g_dev.setEpdMode(epdMode);
g_canvas->pushSprite(0, 0); // commits to the panel; Panel_EPD runs the refresh
settleDisplay();
}

// Push the canvas keeping its grey levels, then refresh through the differential
// bank.
//
// Panel_EPD reads the epd_mode twice, at two different moments, and they do not
// have to agree. _draw_pixels() reads it while the sprite is being copied into
// the panel's 4bpp buffer, and in epd_fast/epd_fastest it Bayer-dithers every
// pixel to one of the two rails there and then -- that is what turned the AA
// greys into hard black speckle along glyph edges. task_update() reads it again
// when the refresh is queued, and only the fast modes skip lut_eraser, the
// preliminary pass that drives everything toward mid grey and shows as a flash.
//
// Splitting auto-display lets each read see the mode it should: quality while the
// pixels land (16 levels, no dither), fast when the refresh goes out (no eraser,
// and the same LUT bank the B/W base used, so Panel_EPD's per-pixel diff still
// skips everything that did not change).
void pushCanvasGraded(lgfx::epd_mode::epd_mode_t refreshMode) {
if (!g_canvas) return;
g_dev.waitDisplay();
g_dev.setEpdMode(lgfx::epd_mode::epd_quality);
g_dev.setAutoDisplay(false);
g_canvas->pushSprite(0, 0); // writes the panel buffer, queues no refresh
g_dev.setAutoDisplay(true);
g_dev.setEpdMode(refreshMode);
g_dev.display(); // covers the rect pushSprite accumulated
settleDisplay();
}

} // namespace
Expand All @@ -225,6 +279,8 @@ void LgfxEpdDriver::begin(EpdBus& bus) {
g_dev.init();
g_dev.setRotation(_cfg.rotation);
g_dev.setEpdMode(lgfx::epd_mode::epd_fast);
g_grayDark = _cfg.grayDark;
g_grayLight = _cfg.grayLight;
allocCanvas(BoardConfig::ACTIVE.displayWidth, BoardConfig::ACTIVE.displayHeight);
#endif
}
Expand All @@ -244,6 +300,42 @@ void LgfxEpdDriver::display(EpdBus& bus, const uint8_t* fb, const uint8_t* prev,
#endif
}

// One render, one push: the whole page -- text and its anti-aliasing greys --
// reaches the panel as a single waveform.
//
// The two-push flow this replaces (B/W base, then a grey overlay push) existed
// to normalize fringe pixels to black before a from-black grey nudge, because a
// destination-indexed LUT cannot see where a pixel came from. The fast bank's
// grey columns are now self-normalizing (saturate at the white rail, then walk
// down to the level), so the base pass has nothing left to do and the page has
// no intermediate state to show: it arrives finished, or it has not arrived.
//
// The charge story rides on the same property. Under the old flow every fringe
// pixel swung black-to-grey through two pushes on every page turn, with a net
// drive imbalance each time; under one push, Panel_EPD's diff drives a pixel
// only when its target changes, and every grey drive begins with a saturating
// rail visit that erases accumulated bias.
void LgfxEpdDriver::displayGrayFrame(EpdBus& bus, const uint8_t* fb, RefreshMode mode, bool turnOff) {
(void)bus;
#if FREEINK_DRIVER_LGFX_EPD
if (!fb) return;
g_dev.waitDisplay(); // never write the canvas while a refresh may be in flight
fillCanvasBW(fb);
overlayCanvasGray();
// HALF/FULL map to the GC16-style clean bank, whose columns land every level
// exactly, so the periodic scrub page carries its greys too. FAST takes the
// differential bank. Either way the write itself must be graded -- a fast-mode
// write Bayer-dithers the greys to the rails before any LUT is consulted.
g_lastBaseEpdMode = epdModeFor(mode);
pushCanvasGraded(g_lastBaseEpdMode);
if (turnOff) g_dev.sleep();
#else
(void)fb;
(void)mode;
(void)turnOff;
#endif
}

void LgfxEpdDriver::copyGrayscaleLsb(EpdBus& bus, const uint8_t* lsb) {
(void)bus;
#if FREEINK_DRIVER_LGFX_EPD
Expand Down Expand Up @@ -286,12 +378,20 @@ void LgfxEpdDriver::displayGray(EpdBus& bus, const uint8_t* fb, bool turnOff, co
#if FREEINK_DRIVER_LGFX_EPD
(void)fb; // the canvas from the base push IS the base; see overlayCanvasGray()
overlayCanvasGray(); // darken only the pixels the planes select
// Same mode as the B/W base push, and now actually the same: Panel_EPD's
// per-pixel diff keys on the epd_mode LUT offset, so switching modes here
// re-drives every pixel (full-screen flash). This was hardcoded to epd_fast
// while display() maps HALF/FULL to epd_text, so a page refreshed with either
// of those flashed when its AA pass ran.
pushCanvas(g_lastBaseEpdMode);
// Refresh under the mode the base push used. Panel_EPD's per-pixel diff keys
// on the epd_mode LUT offset, so switching modes here re-drives every pixel --
// a full-screen flash on any page the host refreshed with HALF or FULL.
//
// The pixel write is a separate question from the refresh, and on a board
// whose fast bank carries grey columns it must not go out under a fast mode:
// _draw_pixels() Bayer-dithers to the two rails there, which is what turned
// the greys into black speckle. pushCanvasGraded() writes under a graded mode
// and refreshes under this one.
if (_cfg.grayNudgeInFastBank) {
pushCanvasGraded(g_lastBaseEpdMode);
} else {
pushCanvas(g_lastBaseEpdMode);
}
if (turnOff) g_dev.sleep();
#else
(void)fb;
Expand Down
6 changes: 6 additions & 0 deletions libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ class LgfxEpdDriver : public PanelDriver {
// 16-gray path: the facade streams LSB/MSB 1-bpp planes (whole or in strips);
// displayGray combines them with the B/W base into the panel's 8-bit gray canvas.
bool supportsStripGrayscale() const override { return true; }
// Single-push grayscale: the page and its AA greys go out as one waveform.
// Valid because this board's fast bank carries self-normalizing grey columns
// (see ED047TC2Waveform.cpp); the generic two-push flow stays available for
// callers that do not use it.
bool supportsGrayFrame() const override { return true; }
void displayGrayFrame(EpdBus& bus, const uint8_t* fb, RefreshMode mode, bool turnOff) override;
void copyGrayscaleLsb(EpdBus& bus, const uint8_t* lsb) override;
void copyGrayscaleMsb(EpdBus& bus, const uint8_t* msb) override;
void writeGrayscalePlaneStrip(EpdBus& bus, GrayPlane plane, const uint8_t* rows, uint16_t yStart,
Expand Down
14 changes: 14 additions & 0 deletions libs/display/FreeInkDisplay/src/driver/PanelDriver.h
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,20 @@ class PanelDriver {
(void)bus; (void)plane; (void)rows; (void)yStart; (void)numRows;
}
virtual void prepareGrayscaleTarget(const uint8_t* bw) { (void)bw; }
// Single-push grayscale: `fb` is the intact 1-bpp B/W frame and the LSB/MSB
// planes were staged beforehand (copyGrayscale*/writeGrayscalePlaneStrip);
// the driver composes and displays base + greys as ONE waveform in `mode`.
// Only meaningful on a panel whose LUTs can land a grey from any source
// state -- a driver advertises that with supportsGrayFrame(). Everyone else
// keeps the two-push base + displayGray() flow.
virtual bool supportsGrayFrame() const { return false; }
virtual void displayGrayFrame(EpdBus& bus, const uint8_t* fb, RefreshMode mode, bool turnOff) {
(void)bus;
(void)fb;
(void)mode;
(void)turnOff;
}

virtual void displayGray(EpdBus& bus, const uint8_t* fb, bool turnOff, const unsigned char* lut, bool factoryMode) {
(void)lut;
(void)factoryMode;
Expand Down
Loading