From dce08f45d1ed2c57db85a64ea7bc3cd2e3b2f70b Mon Sep 17 00:00:00 2001 From: AdaSzi115026 <81822538+AdaSzi@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:48:00 +0200 Subject: [PATCH 1/4] Add LILYGO T5 4.7 Inch E-Paper S3 ESP32-S3 e-paper board (V2.3/V2.4, ED047TC1 960x540) with GT911 touch, PCF8563 RTC, SD card and battery sense. The display driver is derived from the PaperS3 one and uses epdiy's LILYGO T5 4.7 S3 board with the row-by-row render engine on the S3. That engine is not in upstream epdiy, so the epdiy dependency points at AdaSzi's fork of 2.1.3 (tag 2.1.3-t5s3.1). The new option is disabled by default and other boards build the same as before. --- Devices/lilygo-t5-epd47-s3/CMakeLists.txt | 7 + .../lilygo-t5-epd47-s3/LICENSE-Apache-2.0.md | 195 ++++++++++ .../bindings/lilygo,t5s3-display.yaml | 25 ++ Devices/lilygo-t5-epd47-s3/device.properties | 28 ++ .../lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts | 94 +++++ Devices/lilygo-t5-epd47-s3/module.yaml | 6 + .../source/bindings/t5s3_display.h | 14 + .../source/drivers/t5s3_display.cpp | 354 ++++++++++++++++++ .../source/drivers/t5s3_display.h | 19 + Devices/lilygo-t5-epd47-s3/source/module.cpp | 22 ++ Tactility/idf_component.yml | 5 +- 11 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 Devices/lilygo-t5-epd47-s3/CMakeLists.txt create mode 100644 Devices/lilygo-t5-epd47-s3/LICENSE-Apache-2.0.md create mode 100644 Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml create mode 100644 Devices/lilygo-t5-epd47-s3/device.properties create mode 100644 Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts create mode 100644 Devices/lilygo-t5-epd47-s3/module.yaml create mode 100644 Devices/lilygo-t5-epd47-s3/source/bindings/t5s3_display.h create mode 100644 Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp create mode 100644 Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h create mode 100644 Devices/lilygo-t5-epd47-s3/source/module.cpp diff --git a/Devices/lilygo-t5-epd47-s3/CMakeLists.txt b/Devices/lilygo-t5-epd47-s3/CMakeLists.txt new file mode 100644 index 000000000..1f19c644b --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/CMakeLists.txt @@ -0,0 +1,7 @@ +file(GLOB_RECURSE SOURCE_FILES source/*.c*) + +idf_component_register( + SRCS ${SOURCE_FILES} + INCLUDE_DIRS "source" + REQUIRES TactilityKernel epdiy +) diff --git a/Devices/lilygo-t5-epd47-s3/LICENSE-Apache-2.0.md b/Devices/lilygo-t5-epd47-s3/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml b/Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml new file mode 100644 index 000000000..1cdb4510c --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml @@ -0,0 +1,25 @@ +description: > + LILYGO T5-4.7" E-Paper S3 (V2.3/V2.4) display (EPDiy library, ED047TC1 panel). The panel is driven + through a fixed parallel bus and a 74HCT4094 shift register that epdiy's + epd_board_lilygo_t5_47_s3 hardcodes, so this node takes no pin properties of its own. + The shift register also carries the supply of the GT911 touch controller, so this node must + be listed before the I2C bus in the devicetree. + +compatible: "lilygo,t5s3-display" + +properties: + temperature-celsius: + type: int + default: 20 + description: Ambient temperature in °C, used for waveform timing compensation + quality-draw-mode: + type: int + default: MODE_GC16 + description: > + EpdDrawMode waveform used for full-quality refreshes (e.g. MODE_GC16, MODE_GL16). + Fast partial updates always use MODE_DU internally and are not configurable - see + driver comments. + rotation: + type: int + default: EPD_ROT_LANDSCAPE + description: Fixed EpdRotation applied at start - not changeable at runtime. The touch axis flags must match it. diff --git a/Devices/lilygo-t5-epd47-s3/device.properties b/Devices/lilygo-t5-epd47-s3/device.properties new file mode 100644 index 000000000..7bb5b3654 --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/device.properties @@ -0,0 +1,28 @@ +general.vendor=LilyGO +general.name=T5 4.7 Inch E-Paper S3 +general.incubating=true + +apps.launcherAppId=tactility.launcher + +hardware.target=ESP32S3 +hardware.flashSize=16MB +hardware.spiRam=true +hardware.spiRamMode=OCT +hardware.spiRamSpeed=80M +hardware.esptoolFlashFreq=80M +hardware.bluetooth=true + +storage.userDataLocation=Internal + +display.size=4.7" +display.shape=rectangle +display.dpi=235 + +lvgl.colorDepth=8 +lvgl.fontSize=24 +lvgl.theme=Mono +lvgl.statusbarColorsInverted=true + +# Row-by-row render engine of epdiy, needed for the shift register controlled panel +sdkconfig.CONFIG_EPD_ESP32S3_I80_ROW_OUTPUT=y +sdkconfig.CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=0 diff --git a/Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts b/Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts new file mode 100644 index 000000000..172195200 --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts @@ -0,0 +1,94 @@ +/dts-v1/; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/ { + compatible = "root"; + model = "LILYGO T5 4.7 Inch E-Paper S3"; + + wifi0 { + compatible = "espressif,esp32-wifi-pinned"; + }; + + ble0 { + compatible = "espressif,esp32-ble"; + }; + + gpio0 { + compatible = "espressif,esp32-gpio"; + gpio-count = <49>; + }; + + // Must be listed before the I2C bus. The touch controller is powered through the same + // shift register as the panel, and only the display start-up switches that power on. + display { + compatible = "lilygo,t5s3-display"; + temperature-celsius = <20>; + }; + + i2c_internal { + compatible = "espressif,esp32-i2c-master"; + port = ; + clock-frequency = <400000>; + // The board relies on the internal pull-ups, as the Arduino Wire library enables them + pin-sda = <&gpio0 18 GPIO_FLAG_PULL_UP>; + pin-scl = <&gpio0 17 GPIO_FLAG_PULL_UP>; + + // PCF8563, register-compatible with the BM8563 + bm8563 { + compatible = "belling,bm8563"; + reg = <0x51>; + }; + + // The controller reports 540x960 (portrait) and the panel runs in landscape + touch { + compatible = "goodix,gt911"; + reg = <0x5D>; + x-max = <540>; + y-max = <960>; + swap-xy; + mirror-x; + pin-interrupt = <&gpio0 47 GPIO_FLAG_NONE>; + }; + }; + + spi0 { + compatible = "espressif,esp32-spi"; + host = ; + cs-gpios = <&gpio0 42 GPIO_FLAG_NONE>; + pin-mosi = <&gpio0 15 GPIO_FLAG_NONE>; + pin-miso = <&gpio0 16 GPIO_FLAG_NONE>; + pin-sclk = <&gpio0 11 GPIO_FLAG_NONE>; + max-transfer-size = <4096>; + + sdcard@0 { + compatible = "espressif,esp32-sdspi"; + frequency-khz = <20000>; + }; + }; + + // Battery voltage behind a 1:2 divider on GPIO14 (ADC2 channel 3), not yet calibrated + adc0 { + compatible = "espressif,esp32-adc-oneshot"; + unit-id = ; + channels = ; + }; + + battery-sense { + compatible = "battery-sense"; + io-channel = <&adc0 0>; + reference-voltage-mv = <3300>; + multiplier = <2000>; + }; +}; diff --git a/Devices/lilygo-t5-epd47-s3/module.yaml b/Devices/lilygo-t5-epd47-s3/module.yaml new file mode 100644 index 000000000..1bc7e6cbf --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/module.yaml @@ -0,0 +1,6 @@ +dependencies: +- Platforms/platform-esp32 +- Drivers/bm8563-module +- Drivers/gt911-module +bindings: bindings +dts: lilygo,t5-epd47-s3.dts diff --git a/Devices/lilygo-t5-epd47-s3/source/bindings/t5s3_display.h b/Devices/lilygo-t5-epd47-s3/source/bindings/t5s3_display.h new file mode 100644 index 000000000..1b33dc5ba --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/bindings/t5s3_display.h @@ -0,0 +1,14 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +DEFINE_DEVICETREE(t5s3_display, struct T5s3DisplayConfig) + +#ifdef __cplusplus +} +#endif diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp new file mode 100644 index 000000000..75860e10f --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "t5s3_display.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#define TAG "T5s3Display" +#define GET_CONFIG(device) (static_cast((device)->config)) + +// Fast partial updates use MODE_DU, config->quality_draw_mode is used for quality refreshes +static constexpr EpdDrawMode FAST_DRAW_MODE = MODE_DU; + +// An update covering at least this fraction of the panel is a full-screen change and gets a quality refresh +static constexpr float FULL_AREA_QUALITY_THRESHOLD = 0.6f; + +// Fast updates allowed before a quality refresh limits ghosting. A full-screen redraw takes about 10 updates, so this must be higher +static constexpr uint32_t QUALITY_REFRESH_PARTIAL_COUNT = 20; + +// Updates within this time after a quality update also use quality mode, so the tiles of one redraw stay consistent. +// It must stay well below the duration of a GC16 update. +static constexpr uint32_t QUALITY_HOLD_MS = 50; + +// Maximum time that consecutive quality updates keep extending the hold window +static constexpr uint32_t QUALITY_HOLD_SESSION_MAX_MS = 200; + +// 4x4 ordered (Bayer) dither thresholds, spread evenly across a 0-15 nibble range. +static constexpr uint8_t BAYER_4X4[4][4] = { + { 0, 8, 2, 10 }, + { 12, 4, 14, 6 }, + { 3, 11, 1, 9 }, + { 15, 7, 13, 5 }, +}; + +// Dithers an 8-bit luminance (0x00 black to 0xFF white) to a 4-bit level (0x0 black to 0xF white) +static inline uint8_t dither_to_nibble(uint8_t luminance, int32_t x, int32_t y) { + // Scaling by 17 spreads the thresholds over one full quantization step + const uint32_t threshold = BAYER_4X4[y & 3][x & 3] * 17U; + const uint32_t level = (static_cast(luminance) * 15U + threshold) / 255U; + return static_cast(level > 15U ? 15U : level); +} + +// Binary variant for MODE_DU, which only draws black and white +static inline uint8_t dither_to_bw_nibble(uint8_t luminance, int32_t x, int32_t y) { + // Scaling by 16 keeps the highest threshold at 240, so pure white (0xFF) is never classified as black + const uint32_t threshold = BAYER_4X4[y & 3][x & 3] * 16U; + return luminance > threshold ? 0xF : 0x0; +} + +extern "C" { + +extern Module lilygo_t5_epd47_s3_module; + +// epd_hl_init() cannot be undone, so the highlevel state is kept across stop() and start() +static bool s_hl_initialized = false; +static EpdiyHighlevelState s_hl_state = {}; + +struct T5s3DisplayInternal { + EpdiyHighlevelState hl_state; + uint8_t* framebuffer; + bool powered; + uint32_t panel_pixel_count; + // Fast updates since the last quality refresh + uint32_t partial_count_since_quality; + // Until this tick every update uses quality mode + TickType_t quality_hold_until_tick; + // Tick at which the current hold window started + TickType_t quality_hold_session_start_tick; +}; + +static void power_on(T5s3DisplayInternal* internal) { + if (!internal->powered) { + epd_poweron(); + internal->powered = true; + } +} + +// region DisplayApi + +static error_t t5s3_display_reset(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + // The panel has no reset line, so a power cycle is the closest equivalent + epd_poweroff(); + internal->powered = false; + power_on(internal); + return ERROR_NONE; +} + +/** Initialization is done in start() */ +static error_t t5s3_display_init(Device*) { + return ERROR_NONE; +} + +/** Fills the panel white with one GC16 update, without the flashing cycles of epd_fullclear() */ +static error_t t5s3_display_clear(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + const auto* config = GET_CONFIG(device); + power_on(internal); + epd_hl_set_all_white(&internal->hl_state); + auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius); + if (result == EPD_DRAW_SUCCESS) { + internal->partial_count_since_quality = 0; + internal->quality_hold_until_tick = 0; + } + return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; +} + +/** + * Redraws the current content with a GC16 update to remove ghosting. + * The update skips pixels that did not change, so back_fb is inverted first to make every pixel differ. + */ +static error_t t5s3_display_refresh(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + const auto* config = GET_CONFIG(device); + power_on(internal); + const size_t fb_size = static_cast(epd_width()) / 2 * epd_height(); + uint8_t* back_fb = internal->hl_state.back_fb; + for (size_t i = 0; i < fb_size; i++) { + back_fb[i] = static_cast(~back_fb[i]); + } + auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius); + if (result == EPD_DRAW_SUCCESS) { + internal->partial_count_since_quality = 0; + internal->quality_hold_until_tick = 0; + } + return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; +} + +// Decides between a quality refresh and a fast MODE_DU update, *out_within_hold tells if the hold window applied +static bool should_use_quality_mode(T5s3DisplayInternal* internal, int32_t width, int32_t height, bool* out_within_hold) { + const TickType_t now = get_ticks(); + + const uint32_t area = static_cast(width) * static_cast(height); + const bool is_full_screen_change = area >= static_cast( + static_cast(internal->panel_pixel_count) * FULL_AREA_QUALITY_THRESHOLD + ); + const bool partial_count_exceeded = internal->partial_count_since_quality >= QUALITY_REFRESH_PARTIAL_COUNT; + const bool within_hold = now < internal->quality_hold_until_tick; + *out_within_hold = within_hold; + + return is_full_screen_change || partial_count_exceeded || within_hold; +} + +// Updates the counters after an update. A failed quality update leaves them unchanged and a failed fast update still counts. +// A quality update starts or extends the hold window, up to QUALITY_HOLD_SESSION_MAX_MS. +static void commit_quality_mode_decision(T5s3DisplayInternal* internal, bool used_quality, bool draw_succeeded, bool was_within_hold) { + if (used_quality) { + if (draw_succeeded) { + internal->partial_count_since_quality = 0; + const TickType_t now = get_ticks(); + if (!was_within_hold) { + internal->quality_hold_session_start_tick = now; + } + const TickType_t session_elapsed = now - internal->quality_hold_session_start_tick; + if (session_elapsed < millis_to_ticks(QUALITY_HOLD_SESSION_MAX_MS)) { + internal->quality_hold_until_tick = now + millis_to_ticks(QUALITY_HOLD_MS); + } + } + } else { + internal->partial_count_since_quality++; + } +} + +// Reports GRAYSCALE8 so that draw_bitmap() is called per changed tile +static error_t t5s3_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { + auto* internal = static_cast(device_get_driver_data(device)); + const auto* config = GET_CONFIG(device); + + const int32_t width = x_end - x_start; + const int32_t height = y_end - y_start; + bool within_hold = false; + const bool use_quality = should_use_quality_mode(internal, width, height, &within_hold); + + // color_data is 8-bit luminance (0x00 black to 0xFF white) and epdiy wants 4-bit levels (0x0 black to 0xF white) + // Dithering uses 16 levels for quality updates and 2 levels for MODE_DU + // epd_draw_pixel() applies the configured rotation + const auto* src = static_cast(color_data); + const size_t src_stride = static_cast(width); + + for (int32_t row = 0; row < height; row++) { + const uint8_t* src_row = src + static_cast(row) * src_stride; + const int32_t display_y = y_start + row; + + for (int32_t col = 0; col < width; col++) { + const int32_t display_x = x_start + col; + const uint8_t nibble = use_quality + ? dither_to_nibble(src_row[col], display_x, display_y) + : dither_to_bw_nibble(src_row[col], display_x, display_y); + epd_draw_pixel(display_x, display_y, static_cast(nibble << 4), internal->framebuffer); + } + } + + const EpdRect update_area = { + .x = x_start, + .y = y_start, + .width = static_cast(width), + .height = static_cast(height) + }; + + power_on(internal); + const auto draw_mode = use_quality ? config->quality_draw_mode : FAST_DRAW_MODE; + auto draw_result = epd_hl_update_area( + &internal->hl_state, + static_cast(draw_mode | MODE_PACKING_2PPB), + config->temperature_celsius, + update_area + ); + + commit_quality_mode_decision(internal, use_quality, draw_result == EPD_DRAW_SUCCESS, within_hold); + return draw_result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; +} + +static error_t t5s3_display_disp_on_off(Device* device, bool on_off) { + auto* internal = static_cast(device_get_driver_data(device)); + if (on_off) { + power_on(internal); + } else if (internal->powered) { + epd_poweroff(); + internal->powered = false; + } + return ERROR_NONE; +} + +static DisplayColorFormat t5s3_display_get_color_format(Device*) { + return DISPLAY_COLOR_FORMAT_GRAYSCALE8; +} + +// LVGL and draw_bitmap() use the rotated resolution, epd_draw_pixel() maps it to the native panel +static uint16_t t5s3_display_get_resolution_x(Device*) { + return static_cast(epd_rotated_display_width()); +} + +static uint16_t t5s3_display_get_resolution_y(Device*) { + return static_cast(epd_rotated_display_height()); +} + +// endregion + +static const DisplayApi t5s3_display_api = { + // The driver converts into its own framebuffer, so the LVGL draw buffers may live in external RAM + .capabilities = DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_SLOW_REFRESH | DISPLAY_CAPABILITY_PREFER_EXTERNAL_RAM, + .reset = t5s3_display_reset, + .init = t5s3_display_init, + .draw_bitmap = t5s3_display_draw_bitmap, + .clear = t5s3_display_clear, + .refresh = t5s3_display_refresh, + .mirror = nullptr, + .swap_xy = nullptr, + .get_swap_xy = nullptr, + .get_mirror_x = nullptr, + .get_mirror_y = nullptr, + .set_gap = nullptr, + .get_gap_x = nullptr, + .get_gap_y = nullptr, + .invert_color = nullptr, + .disp_on_off = t5s3_display_disp_on_off, + .disp_sleep = nullptr, + .get_color_format = t5s3_display_get_color_format, + .get_resolution_x = t5s3_display_get_resolution_x, + .get_resolution_y = t5s3_display_get_resolution_y, + // The epdiy framebuffer is 4bpp and not in the reported GRAYSCALE8 format + .get_frame_buffer = nullptr, + .get_frame_buffer_count = nullptr, + .get_backlight = nullptr, + .has_capability = nullptr, +}; + +// region Driver lifecycle + +static error_t start(Device* device) { + const auto* config = GET_CONFIG(device); + + auto* internal = static_cast(malloc(sizeof(T5s3DisplayInternal))); + if (internal == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + internal->powered = false; + + // The row-by-row render engine requires the 64K LUT. A short feed queue saves internal RAM. + epd_init(&epd_board_lilygo_t5_47_s3, &ED047TC1, static_cast(EPD_LUT_64K | EPD_FEED_QUEUE_8)); + epd_set_rotation(config->rotation); + + if (!s_hl_initialized) { + s_hl_state = epd_hl_init(EPD_BUILTIN_WAVEFORM); + if (s_hl_state.front_fb == nullptr) { + LOG_E(TAG, "Failed to initialize EPDiy highlevel state"); + epd_deinit(); + free(internal); + return ERROR_RESOURCE; + } + s_hl_initialized = true; + } else { + LOG_I(TAG, "Reusing existing EPDiy highlevel state"); + } + + internal->hl_state = s_hl_state; + internal->framebuffer = epd_hl_get_framebuffer(&internal->hl_state); + + internal->panel_pixel_count = static_cast(epd_rotated_display_width()) * static_cast(epd_rotated_display_height()); + internal->partial_count_since_quality = 0; + internal->quality_hold_until_tick = 0; + internal->quality_hold_session_start_tick = 0; + + device_set_driver_data(device, internal); + + // The boot splash leaves ghosting, so clear the panel with the full flash cycle before LVGL draws + power_on(internal); + epd_fullclear(&internal->hl_state, config->temperature_celsius); + + LOG_I(TAG, "EPDiy initialized (%dx%d native, %dx%d rotated)", epd_width(), epd_height(), epd_rotated_display_width(), epd_rotated_display_height()); + return ERROR_NONE; +} + +static error_t stop(Device* device) { + auto* internal = static_cast(device_get_driver_data(device)); + + if (internal->powered) { + epd_poweroff(); + internal->powered = false; + } + + epd_deinit(); + + free(internal); + device_set_driver_data(device, nullptr); + return ERROR_NONE; +} + +// endregion + +Driver t5s3_display_driver = { + .name = "t5s3-display", + .compatible = (const char*[]) { "lilygo,t5s3-display", nullptr }, + .start_device = start, + .stop_device = stop, + .probe = nullptr, + .api = &t5s3_display_api, + .device_type = &DISPLAY_TYPE, + .owner = &lilygo_t5_epd47_s3_module, + .internal = nullptr +}; + +} diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h new file mode 100644 index 000000000..1348fa0dc --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct T5s3DisplayConfig { + int temperature_celsius; + enum EpdDrawMode quality_draw_mode; + enum EpdRotation rotation; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Devices/lilygo-t5-epd47-s3/source/module.cpp b/Devices/lilygo-t5-epd47-s3/source/module.cpp new file mode 100644 index 000000000..24f4a1854 --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/module.cpp @@ -0,0 +1,22 @@ +#include +#include + +extern "C" { + +extern Driver t5s3_display_driver; + +static Driver* const t5s3_drivers[] = { + &t5s3_display_driver, + nullptr +}; + +Module lilygo_t5_epd47_s3_module = { + .name = "lilygo-t5-epd47-s3", + .start = nullptr, + .stop = nullptr, + .drivers = t5s3_drivers, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Tactility/idf_component.yml b/Tactility/idf_component.yml index ea8299b45..22502aa49 100644 --- a/Tactility/idf_component.yml +++ b/Tactility/idf_component.yml @@ -83,8 +83,9 @@ dependencies: espressif/esp_lvgl_port: "2.7.2" lvgl/lvgl: "9.3.0" epdiy: - git: https://github.com/vroland/epdiy.git - version: 2.1.3 + # Fork of epdiy 2.1.3 that adds the LILYGO T5 4.7 Inch E-Paper S3, disabled by default + git: https://github.com/AdaSzi/epdiy.git + version: 2.1.3-t5s3.1 rules: # More hardware might be supported - enable as needed - if: "target in [esp32s3]" From 0ffd8074561e17a1e2bc2d478327abbecf9c5240 Mon Sep 17 00:00:00 2001 From: AdaSzi115026 <81822538+AdaSzi@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:20:14 +0200 Subject: [PATCH 2/4] Fix tick wrap in the quality hold of the T5 4.7 Inch E-Paper S3 display The hold deadline was compared with a plain less-than, so a deadline left from just before the 32 bit tick counter wraps kept every update in quality mode. Compare the signed difference, treat 0 as no hold and clear the deadline when the session cap is reached. --- Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp index 75860e10f..0024884af 100644 --- a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp @@ -146,7 +146,9 @@ static bool should_use_quality_mode(T5s3DisplayInternal* internal, int32_t width static_cast(internal->panel_pixel_count) * FULL_AREA_QUALITY_THRESHOLD ); const bool partial_count_exceeded = internal->partial_count_since_quality >= QUALITY_REFRESH_PARTIAL_COUNT; - const bool within_hold = now < internal->quality_hold_until_tick; + // Signed difference stays correct across the tick counter wrap, 0 means no hold + const TickType_t hold_until = internal->quality_hold_until_tick; + const bool within_hold = hold_until != 0 && static_cast(hold_until - now) > 0; *out_within_hold = within_hold; return is_full_screen_change || partial_count_exceeded || within_hold; @@ -165,6 +167,8 @@ static void commit_quality_mode_decision(T5s3DisplayInternal* internal, bool use const TickType_t session_elapsed = now - internal->quality_hold_session_start_tick; if (session_elapsed < millis_to_ticks(QUALITY_HOLD_SESSION_MAX_MS)) { internal->quality_hold_until_tick = now + millis_to_ticks(QUALITY_HOLD_MS); + } else { + internal->quality_hold_until_tick = 0; } } } else { From 6453487b9d3ebb4a1a0a8b16f72701fc2d501323 Mon Sep 17 00:00:00 2001 From: AdaSzi115026 <81822538+AdaSzi@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:24:00 +0200 Subject: [PATCH 3/4] Use a built-in e-paper engine in the T5 4.7 Inch E-Paper S3 device Use a built-in e-paper engine in the T5 4.7 Inch E-Paper S3 device Replaces the epdiy dependency with a minimal engine in the device folder. It only has what the display driver uses: the shift register control, the CKV pulses (RMT), the row transfer (LCD peripheral in i80 mode), the row by row update loop and the clear sequence. There are three update modes. Fast draws black and white only. Quality draws 16 levels and leaves white pixels that stay white alone, which is used for partial updates. Full also flashes those white pixels, which is used for the clear and the refresh. Without that split, partial quality updates left faint vertical lines on the screen. A fast update of a tile takes about 30 to 60 ms and a full quality refresh about 1.2 s. The two driver files are built with -O2, rows are converted 8 pixels at a time and rows without changes are skipped with one train of pulses. The row conversion has no hardware access. The pixel clock is a devicetree property (default 10 MHz). The engine and the quality waveform are derived from epdiy and are marked LGPL v3.0 or later, with a notice in THIRD-PARTY-NOTICES.md. --- Devices/lilygo-t5-epd47-s3/CMakeLists.txt | 9 +- .../bindings/lilygo,t5s3-display.yaml | 24 +- Devices/lilygo-t5-epd47-s3/device.properties | 2 - .../lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts | 4 +- .../source/drivers/t5s3_display.cpp | 120 ++-- .../source/drivers/t5s3_display.h | 6 +- .../source/drivers/t5s3_epd.cpp | 589 ++++++++++++++++++ .../source/drivers/t5s3_epd.h | 54 ++ .../source/drivers/t5s3_epd_rows.h | 106 ++++ .../source/drivers/t5s3_epd_waveform.h | 201 ++++++ THIRD-PARTY-NOTICES.md | 8 + Tactility/idf_component.yml | 5 +- 12 files changed, 1011 insertions(+), 117 deletions(-) create mode 100644 Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp create mode 100644 Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.h create mode 100644 Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_rows.h create mode 100644 Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_waveform.h diff --git a/Devices/lilygo-t5-epd47-s3/CMakeLists.txt b/Devices/lilygo-t5-epd47-s3/CMakeLists.txt index 1f19c644b..18f961d5d 100644 --- a/Devices/lilygo-t5-epd47-s3/CMakeLists.txt +++ b/Devices/lilygo-t5-epd47-s3/CMakeLists.txt @@ -3,5 +3,12 @@ file(GLOB_RECURSE SOURCE_FILES source/*.c*) idf_component_register( SRCS ${SOURCE_FILES} INCLUDE_DIRS "source" - REQUIRES TactilityKernel epdiy + REQUIRES TactilityKernel esp_lcd esp_driver_gpio esp_driver_rmt esp_timer esp_rom hal soc heap freertos +) + +# The pixel loops of the display are too slow with the debug optimization level of the project +set_source_files_properties( + source/drivers/t5s3_epd.cpp + source/drivers/t5s3_display.cpp + PROPERTIES COMPILE_OPTIONS -O2 ) diff --git a/Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml b/Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml index 1cdb4510c..47f6ac120 100644 --- a/Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml +++ b/Devices/lilygo-t5-epd47-s3/bindings/lilygo,t5s3-display.yaml @@ -1,25 +1,11 @@ description: > - LILYGO T5-4.7" E-Paper S3 (V2.3/V2.4) display (EPDiy library, ED047TC1 panel). The panel is driven - through a fixed parallel bus and a 74HCT4094 shift register that epdiy's - epd_board_lilygo_t5_47_s3 hardcodes, so this node takes no pin properties of its own. - The shift register also carries the supply of the GT911 touch controller, so this node must - be listed before the I2C bus in the devicetree. + LILYGO T5 4.7 Inch E-Paper S3 (V2.3/V2.4) display with an ED047TC1 panel. The pins of the panel + are fixed in the driver, so this node only has the pixel clock as property. compatible: "lilygo,t5s3-display" properties: - temperature-celsius: + pixel-clock-hz: type: int - default: 20 - description: Ambient temperature in °C, used for waveform timing compensation - quality-draw-mode: - type: int - default: MODE_GC16 - description: > - EpdDrawMode waveform used for full-quality refreshes (e.g. MODE_GC16, MODE_GL16). - Fast partial updates always use MODE_DU internally and are not configurable - see - driver comments. - rotation: - type: int - default: EPD_ROT_LANDSCAPE - description: Fixed EpdRotation applied at start - not changeable at runtime. The touch axis flags must match it. + default: 10000000 + description: Clock of the bus that sends the pixel data of a row. Faster speeds up updates if the panel accepts it. diff --git a/Devices/lilygo-t5-epd47-s3/device.properties b/Devices/lilygo-t5-epd47-s3/device.properties index 7bb5b3654..f518283d2 100644 --- a/Devices/lilygo-t5-epd47-s3/device.properties +++ b/Devices/lilygo-t5-epd47-s3/device.properties @@ -23,6 +23,4 @@ lvgl.fontSize=24 lvgl.theme=Mono lvgl.statusbarColorsInverted=true -# Row-by-row render engine of epdiy, needed for the shift register controlled panel -sdkconfig.CONFIG_EPD_ESP32S3_I80_ROW_OUTPUT=y sdkconfig.CONFIG_LV_THEME_DEFAULT_TRANSITION_TIME=0 diff --git a/Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts b/Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts index 172195200..c38777838 100644 --- a/Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts +++ b/Devices/lilygo-t5-epd47-s3/lilygo,t5-epd47-s3.dts @@ -30,11 +30,9 @@ gpio-count = <49>; }; - // Must be listed before the I2C bus. The touch controller is powered through the same - // shift register as the panel, and only the display start-up switches that power on. + // Listed before the I2C bus on purpose, the touch controller was only tested with the display started first display { compatible = "lilygo,t5s3-display"; - temperature-celsius = <20>; }; i2c_internal { diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp index 0024884af..91fbcbef3 100644 --- a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.cpp @@ -9,9 +9,7 @@ #include #include -#include - -#include +#include "t5s3_epd.h" #include #include @@ -19,9 +17,6 @@ #define TAG "T5s3Display" #define GET_CONFIG(device) (static_cast((device)->config)) -// Fast partial updates use MODE_DU, config->quality_draw_mode is used for quality refreshes -static constexpr EpdDrawMode FAST_DRAW_MODE = MODE_DU; - // An update covering at least this fraction of the panel is a full-screen change and gets a quality refresh static constexpr float FULL_AREA_QUALITY_THRESHOLD = 0.6f; @@ -29,7 +24,7 @@ static constexpr float FULL_AREA_QUALITY_THRESHOLD = 0.6f; static constexpr uint32_t QUALITY_REFRESH_PARTIAL_COUNT = 20; // Updates within this time after a quality update also use quality mode, so the tiles of one redraw stay consistent. -// It must stay well below the duration of a GC16 update. +// It must stay well below the duration of a quality update. static constexpr uint32_t QUALITY_HOLD_MS = 50; // Maximum time that consecutive quality updates keep extending the hold window @@ -51,7 +46,7 @@ static inline uint8_t dither_to_nibble(uint8_t luminance, int32_t x, int32_t y) return static_cast(level > 15U ? 15U : level); } -// Binary variant for MODE_DU, which only draws black and white +// Binary variant for fast updates, which only draw black and white static inline uint8_t dither_to_bw_nibble(uint8_t luminance, int32_t x, int32_t y) { // Scaling by 16 keeps the highest threshold at 240, so pure white (0xFF) is never classified as black const uint32_t threshold = BAYER_4X4[y & 3][x & 3] * 16U; @@ -62,15 +57,9 @@ extern "C" { extern Module lilygo_t5_epd47_s3_module; -// epd_hl_init() cannot be undone, so the highlevel state is kept across stop() and start() -static bool s_hl_initialized = false; -static EpdiyHighlevelState s_hl_state = {}; - struct T5s3DisplayInternal { - EpdiyHighlevelState hl_state; uint8_t* framebuffer; bool powered; - uint32_t panel_pixel_count; // Fast updates since the last quality refresh uint32_t partial_count_since_quality; // Until this tick every update uses quality mode @@ -81,7 +70,7 @@ struct T5s3DisplayInternal { static void power_on(T5s3DisplayInternal* internal) { if (!internal->powered) { - epd_poweron(); + t5s3_epd_power_on(); internal->powered = true; } } @@ -91,7 +80,7 @@ static void power_on(T5s3DisplayInternal* internal) { static error_t t5s3_display_reset(Device* device) { auto* internal = static_cast(device_get_driver_data(device)); // The panel has no reset line, so a power cycle is the closest equivalent - epd_poweroff(); + t5s3_epd_power_off(); internal->powered = false; power_on(internal); return ERROR_NONE; @@ -102,48 +91,42 @@ static error_t t5s3_display_init(Device*) { return ERROR_NONE; } -/** Fills the panel white with one GC16 update, without the flashing cycles of epd_fullclear() */ +/** Fills the panel white with one full update, without the flashing cycles of the initial clear */ static error_t t5s3_display_clear(Device* device) { auto* internal = static_cast(device_get_driver_data(device)); - const auto* config = GET_CONFIG(device); power_on(internal); - epd_hl_set_all_white(&internal->hl_state); - auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius); - if (result == EPD_DRAW_SUCCESS) { + t5s3_epd_fill_white(); + const bool success = t5s3_epd_update(T5s3EpdMode::Full, 0, T5S3_EPD_HEIGHT); + if (success) { internal->partial_count_since_quality = 0; internal->quality_hold_until_tick = 0; } - return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; + return success ? ERROR_NONE : ERROR_RESOURCE; } /** - * Redraws the current content with a GC16 update to remove ghosting. - * The update skips pixels that did not change, so back_fb is inverted first to make every pixel differ. + * Redraws the current content with a quality update to remove ghosting. + * The update skips pixels that did not change, so every pixel is invalidated first. */ static error_t t5s3_display_refresh(Device* device) { auto* internal = static_cast(device_get_driver_data(device)); - const auto* config = GET_CONFIG(device); power_on(internal); - const size_t fb_size = static_cast(epd_width()) / 2 * epd_height(); - uint8_t* back_fb = internal->hl_state.back_fb; - for (size_t i = 0; i < fb_size; i++) { - back_fb[i] = static_cast(~back_fb[i]); - } - auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius); - if (result == EPD_DRAW_SUCCESS) { + t5s3_epd_invalidate(); + const bool success = t5s3_epd_update(T5s3EpdMode::Full, 0, T5S3_EPD_HEIGHT); + if (success) { internal->partial_count_since_quality = 0; internal->quality_hold_until_tick = 0; } - return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; + return success ? ERROR_NONE : ERROR_RESOURCE; } -// Decides between a quality refresh and a fast MODE_DU update, *out_within_hold tells if the hold window applied +// Decides between a quality refresh and a fast update, *out_within_hold tells if the hold window applied static bool should_use_quality_mode(T5s3DisplayInternal* internal, int32_t width, int32_t height, bool* out_within_hold) { const TickType_t now = get_ticks(); const uint32_t area = static_cast(width) * static_cast(height); const bool is_full_screen_change = area >= static_cast( - static_cast(internal->panel_pixel_count) * FULL_AREA_QUALITY_THRESHOLD + static_cast(T5S3_EPD_WIDTH * T5S3_EPD_HEIGHT) * FULL_AREA_QUALITY_THRESHOLD ); const bool partial_count_exceeded = internal->partial_count_since_quality >= QUALITY_REFRESH_PARTIAL_COUNT; // Signed difference stays correct across the tick counter wrap, 0 means no hold @@ -179,16 +162,14 @@ static void commit_quality_mode_decision(T5s3DisplayInternal* internal, bool use // Reports GRAYSCALE8 so that draw_bitmap() is called per changed tile static error_t t5s3_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { auto* internal = static_cast(device_get_driver_data(device)); - const auto* config = GET_CONFIG(device); const int32_t width = x_end - x_start; const int32_t height = y_end - y_start; bool within_hold = false; const bool use_quality = should_use_quality_mode(internal, width, height, &within_hold); - // color_data is 8-bit luminance (0x00 black to 0xFF white) and epdiy wants 4-bit levels (0x0 black to 0xF white) - // Dithering uses 16 levels for quality updates and 2 levels for MODE_DU - // epd_draw_pixel() applies the configured rotation + // color_data is 8-bit luminance (0x00 black to 0xFF white) and the panel wants 4-bit levels (0x0 black to 0xF white) + // Dithering uses 16 levels for quality updates and 2 levels for fast updates const auto* src = static_cast(color_data); const size_t src_stride = static_cast(width); @@ -201,28 +182,15 @@ static error_t t5s3_display_draw_bitmap(Device* device, int32_t x_start, int32_t const uint8_t nibble = use_quality ? dither_to_nibble(src_row[col], display_x, display_y) : dither_to_bw_nibble(src_row[col], display_x, display_y); - epd_draw_pixel(display_x, display_y, static_cast(nibble << 4), internal->framebuffer); + t5s3_epd_set_pixel(internal->framebuffer, display_x, display_y, nibble); } } - const EpdRect update_area = { - .x = x_start, - .y = y_start, - .width = static_cast(width), - .height = static_cast(height) - }; - power_on(internal); - const auto draw_mode = use_quality ? config->quality_draw_mode : FAST_DRAW_MODE; - auto draw_result = epd_hl_update_area( - &internal->hl_state, - static_cast(draw_mode | MODE_PACKING_2PPB), - config->temperature_celsius, - update_area - ); + const bool success = t5s3_epd_update(use_quality ? T5s3EpdMode::Quality : T5s3EpdMode::Fast, y_start, y_end); - commit_quality_mode_decision(internal, use_quality, draw_result == EPD_DRAW_SUCCESS, within_hold); - return draw_result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE; + commit_quality_mode_decision(internal, use_quality, success, within_hold); + return success ? ERROR_NONE : ERROR_RESOURCE; } static error_t t5s3_display_disp_on_off(Device* device, bool on_off) { @@ -230,7 +198,7 @@ static error_t t5s3_display_disp_on_off(Device* device, bool on_off) { if (on_off) { power_on(internal); } else if (internal->powered) { - epd_poweroff(); + t5s3_epd_power_off(); internal->powered = false; } return ERROR_NONE; @@ -240,13 +208,12 @@ static DisplayColorFormat t5s3_display_get_color_format(Device*) { return DISPLAY_COLOR_FORMAT_GRAYSCALE8; } -// LVGL and draw_bitmap() use the rotated resolution, epd_draw_pixel() maps it to the native panel static uint16_t t5s3_display_get_resolution_x(Device*) { - return static_cast(epd_rotated_display_width()); + return T5S3_EPD_WIDTH; } static uint16_t t5s3_display_get_resolution_y(Device*) { - return static_cast(epd_rotated_display_height()); + return T5S3_EPD_HEIGHT; } // endregion @@ -273,7 +240,7 @@ static const DisplayApi t5s3_display_api = { .get_color_format = t5s3_display_get_color_format, .get_resolution_x = t5s3_display_get_resolution_x, .get_resolution_y = t5s3_display_get_resolution_y, - // The epdiy framebuffer is 4bpp and not in the reported GRAYSCALE8 format + // The framebuffer is 4bpp and not in the reported GRAYSCALE8 format .get_frame_buffer = nullptr, .get_frame_buffer_count = nullptr, .get_backlight = nullptr, @@ -291,27 +258,12 @@ static error_t start(Device* device) { } internal->powered = false; - // The row-by-row render engine requires the 64K LUT. A short feed queue saves internal RAM. - epd_init(&epd_board_lilygo_t5_47_s3, &ED047TC1, static_cast(EPD_LUT_64K | EPD_FEED_QUEUE_8)); - epd_set_rotation(config->rotation); - - if (!s_hl_initialized) { - s_hl_state = epd_hl_init(EPD_BUILTIN_WAVEFORM); - if (s_hl_state.front_fb == nullptr) { - LOG_E(TAG, "Failed to initialize EPDiy highlevel state"); - epd_deinit(); - free(internal); - return ERROR_RESOURCE; - } - s_hl_initialized = true; - } else { - LOG_I(TAG, "Reusing existing EPDiy highlevel state"); + if (!t5s3_epd_init(config->pixel_clock_hz)) { + free(internal); + return ERROR_RESOURCE; } + internal->framebuffer = t5s3_epd_framebuffer(); - internal->hl_state = s_hl_state; - internal->framebuffer = epd_hl_get_framebuffer(&internal->hl_state); - - internal->panel_pixel_count = static_cast(epd_rotated_display_width()) * static_cast(epd_rotated_display_height()); internal->partial_count_since_quality = 0; internal->quality_hold_until_tick = 0; internal->quality_hold_session_start_tick = 0; @@ -320,9 +272,9 @@ static error_t start(Device* device) { // The boot splash leaves ghosting, so clear the panel with the full flash cycle before LVGL draws power_on(internal); - epd_fullclear(&internal->hl_state, config->temperature_celsius); + t5s3_epd_clear(); - LOG_I(TAG, "EPDiy initialized (%dx%d native, %dx%d rotated)", epd_width(), epd_height(), epd_rotated_display_width(), epd_rotated_display_height()); + LOG_I(TAG, "Initialized (%dx%d)", T5S3_EPD_WIDTH, T5S3_EPD_HEIGHT); return ERROR_NONE; } @@ -330,11 +282,11 @@ static error_t stop(Device* device) { auto* internal = static_cast(device_get_driver_data(device)); if (internal->powered) { - epd_poweroff(); + t5s3_epd_power_off(); internal->powered = false; } - epd_deinit(); + t5s3_epd_deinit(); free(internal); device_set_driver_data(device, nullptr); diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h index 1348fa0dc..b6f145827 100644 --- a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_display.h @@ -2,16 +2,12 @@ #include -#include - #ifdef __cplusplus extern "C" { #endif struct T5s3DisplayConfig { - int temperature_celsius; - enum EpdDrawMode quality_draw_mode; - enum EpdRotation rotation; + uint32_t pixel_clock_hz; }; #ifdef __cplusplus diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp new file mode 100644 index 000000000..328ff3942 --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp @@ -0,0 +1,589 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// +// Minimal driver for the ED047TC1 panel of the LILYGO T5 4.7 Inch E-Paper S3. +// The sequences and timings follow the epdiy project (https://github.com/vroland/epdiy). +// +// The panel is controlled by a 74HCT4094 shift register (latch, output enable, mode, start of frame and the +// panel supplies), the CKV gate clock (RMT peripheral) and an 8 bit bus (LCD peripheral in i80 mode) that +// carries the 2 bit actions of 4 pixels per byte. Rows are sent one at a time. The data of a row is applied by the +// CKV pulse after the one that was running during its transfer. + +#include "t5s3_epd.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define TAG "T5s3Epd" + +static constexpr gpio_num_t PIN_REGISTER_DATA = GPIO_NUM_13; +static constexpr gpio_num_t PIN_REGISTER_CLOCK = GPIO_NUM_12; +static constexpr gpio_num_t PIN_REGISTER_STROBE = GPIO_NUM_0; +static constexpr gpio_num_t PIN_CKV = GPIO_NUM_38; +// Start pulse of a row, driven as the data/command line of the bus +static constexpr gpio_num_t PIN_STH = GPIO_NUM_40; +// Pixel clock, driven as the write line of the bus +static constexpr gpio_num_t PIN_CKH = GPIO_NUM_41; +static constexpr gpio_num_t BUS_PINS[8] = {GPIO_NUM_6, GPIO_NUM_7, GPIO_NUM_4, GPIO_NUM_5, GPIO_NUM_2, GPIO_NUM_3, GPIO_NUM_8, GPIO_NUM_1}; + +// A row has headroom after the pixel data for the timing of the source driver +static constexpr int BUS_TRANSFER_BYTES = ((T5S3_EPD_WIDTH + 32) / 4 + 3) & ~3; + +// Time base of the CKV pulses, 10 MHz +static constexpr uint32_t PULSE_RESOLUTION_HZ = 10000000; +// Low time after the pulse of a row in ticks +static constexpr uint32_t ROW_LOW_TICKS = 50; +// More phases drive the pixels further into black or white, which leaves less ghosting but makes fast updates slower +static constexpr uint32_t FAST_PHASES = 10; +// Hold time of a phase of the fast update in ticks +static constexpr uint32_t FAST_HOLD_TICKS = 1000; +// Hold time of the frames that flash the panel to white +static constexpr uint32_t CLEAR_HOLD_TICKS = 120; +static constexpr int CLEAR_CYCLES = 3; +static constexpr int CLEAR_DARK_FRAMES = 10; +static constexpr int CLEAR_LIGHT_FRAMES = 10; +static constexpr int CLEAR_REST_FRAMES = 2; +static constexpr uint8_t ALL_DARK = 0x55; +static constexpr uint8_t ALL_LIGHT = 0xAA; + +// Limit for waiting on a peripheral, so that a lost interrupt cannot hang the system +static constexpr uint32_t WAIT_SPIN_LIMIT = 20000000; + +struct Engine { + uint8_t* front = nullptr; + uint8_t* back = nullptr; + uint8_t* row_buffer[2] = {nullptr, nullptr}; + int current = 0; + esp_lcd_i80_bus_handle_t bus = nullptr; + esp_lcd_panel_io_handle_t io = nullptr; + rmt_channel_handle_t pulse_channel = nullptr; + rmt_encoder_handle_t pulse_encoder = nullptr; + volatile bool transfer_done = true; + volatile bool pulse_done = true; + bool failed = false; + // Shift register + bool latch = false; + bool output_enable = false; + bool mode = false; + bool start_of_frame = true; + bool power_disable = true; + bool positive_power = false; + bool negative_power = false; + // Rows written since the last row that carried data + int rows_skipped = 0; + bool row_dirty[T5S3_EPD_HEIGHT]; + alignas(4) uint8_t changes[T5S3_EPD_FB_ROW_BYTES]; + alignas(4) uint8_t line_mask[T5S3_EPD_BUS_ROW_BYTES]; +}; + +static Engine engine; +static rmt_symbol_word_t pulse_symbol; + +static inline void IRAM_ATTR pin_set(gpio_num_t pin, uint32_t level) { + gpio_ll_set_level(GPIO_LL_GET_HW(GPIO_PORT_0), pin, level); +} + +static bool IRAM_ATTR on_pulse_done(rmt_channel_handle_t, const rmt_tx_done_event_data_t*, void*) { + engine.pulse_done = true; + return false; +} + +static bool IRAM_ATTR on_transfer_done(esp_lcd_panel_io_handle_t, esp_lcd_panel_io_event_data_t*, void*) { + engine.transfer_done = true; + return false; +} + +static void IRAM_ATTR wait_for(volatile bool& flag) { + uint32_t spins = 0; + while (!flag) { + if (++spins > WAIT_SPIN_LIMIT) { + engine.failed = true; + flag = true; + } + } +} + +// region Shift register + +static void IRAM_ATTR push_register_bit(bool bit) { + pin_set(PIN_REGISTER_CLOCK, 0); + pin_set(PIN_REGISTER_DATA, bit ? 1 : 0); + pin_set(PIN_REGISTER_CLOCK, 1); +} + +static void IRAM_ATTR push_register() { + pin_set(PIN_REGISTER_STROBE, 0); + push_register_bit(engine.output_enable); + push_register_bit(engine.mode); + // Always set + push_register_bit(true); + push_register_bit(engine.start_of_frame); + push_register_bit(engine.negative_power); + push_register_bit(engine.positive_power); + push_register_bit(engine.power_disable); + push_register_bit(engine.latch); + pin_set(PIN_REGISTER_STROBE, 1); +} + +// endregion + +// region CKV pulses and row transfer + +// Pulses the gate clock for high_ticks and keeps it low for low_ticks, repeated for the given number of pulses. +// Without a high time the line is high for low_ticks. +static void IRAM_ATTR pulse_ckv(uint32_t high_ticks, uint32_t low_ticks, bool wait, int count = 1) { + wait_for(engine.pulse_done); + if (high_ticks > 0) { + pulse_symbol.duration0 = high_ticks; + pulse_symbol.level0 = 1; + pulse_symbol.duration1 = low_ticks; + pulse_symbol.level1 = 0; + } else { + pulse_symbol.duration0 = low_ticks; + pulse_symbol.level0 = 1; + pulse_symbol.duration1 = 0; + pulse_symbol.level1 = 0; + } + engine.pulse_done = false; + rmt_transmit_config_t config = {}; + config.loop_count = count; + if (rmt_transmit(engine.pulse_channel, engine.pulse_encoder, &pulse_symbol, sizeof(pulse_symbol), &config) != ESP_OK) { + engine.pulse_done = true; + engine.failed = true; + return; + } + if (wait) { + wait_for(engine.pulse_done); + } +} + +static void IRAM_ATTR pulse_ckv_us(uint32_t high_us, uint32_t low_us, bool wait) { + pulse_ckv(high_us * 10, low_us * 10, wait); +} + +static void IRAM_ATTR start_transfer() { + engine.transfer_done = false; + if (esp_lcd_panel_io_tx_color(engine.io, 0, engine.row_buffer[engine.current], BUS_TRANSFER_BYTES) != ESP_OK) { + engine.transfer_done = true; + engine.failed = true; + } +} + +// Applies the data that was sent last and starts the transfer of the current row buffer while the CKV pulse runs +static void IRAM_ATTR output_row(uint32_t hold_ticks) { + wait_for(engine.transfer_done); + wait_for(engine.pulse_done); + engine.latch = true; + push_register(); + engine.latch = false; + push_register(); + pulse_ckv(hold_ticks, ROW_LOW_TICKS, false); + start_transfer(); + engine.current ^= 1; +} + +static void IRAM_ATTR write_row(uint32_t hold_ticks) { + output_row(hold_ticks); + engine.rows_skipped = 0; +} + +// Skips a row after rows with data. The first two skipped rows push blank data through the pipeline. +static void IRAM_ATTR skip_row(uint32_t hold_ticks) { + memset(engine.row_buffer[engine.current], 0, T5S3_EPD_BUS_ROW_BYTES); + output_row(hold_ticks); + engine.rows_skipped++; +} + +// Clocks the gate driver through rows that carry no data with one train of pulses +static void IRAM_ATTR skip_rows(int count) { + pulse_ckv(45, 5, false, count); + engine.rows_skipped += count; +} + +static void IRAM_ATTR start_frame() { + wait_for(engine.transfer_done); + wait_for(engine.pulse_done); + engine.rows_skipped = 0; + + engine.mode = true; + push_register(); + pulse_ckv_us(1, 1, true); + + // The start of frame line must go low and high again while the long pulse runs + engine.start_of_frame = false; + push_register(); + pulse_ckv_us(1000, 100, false); + engine.start_of_frame = true; + push_register(); + for (int i = 0; i < 4; i++) { + pulse_ckv_us(1, 1, true); + } + + engine.output_enable = true; + push_register(); +} + +static void IRAM_ATTR end_frame() { + engine.start_of_frame = false; + push_register(); + for (int i = 0; i < 5; i++) { + pulse_ckv_us(1, 1, true); + } + engine.mode = false; + push_register(); + pulse_ckv_us(0, 10, true); + engine.output_enable = false; + push_register(); + for (int i = 0; i < 3; i++) { + pulse_ckv_us(1, 1, true); + } +} + +// endregion + +// region Frames + +static void IRAM_ATTR draw_frame(const uint8_t* table, uint32_t hold_ticks) { + start_frame(); + int y = 0; + bool ends_with_train = false; + while (y < T5S3_EPD_HEIGHT) { + ends_with_train = false; + if (engine.row_dirty[y]) { + t5s3_epd_prepare_row( + engine.row_buffer[engine.current], + engine.front + y * T5S3_EPD_FB_ROW_BYTES, + engine.back + y * T5S3_EPD_FB_ROW_BYTES, + table, + engine.line_mask + ); + write_row(hold_ticks); + y++; + } else if (engine.rows_skipped < 2) { + skip_row(hold_ticks); + y++; + } else { + int run = 1; + while (y + run < T5S3_EPD_HEIGHT && !engine.row_dirty[y + run]) { + run++; + } + skip_rows(run); + ends_with_train = true; + y += run; + } + } + // The last row is applied by one more pulse + if (engine.rows_skipped == 0) { + write_row(hold_ticks); + } else if (ends_with_train) { + // The end of the frame must not overlap the train of pulses of the skipped rows + wait_for(engine.pulse_done); + } + end_frame(); +} + +static void IRAM_ATTR draw_uniform_frame(uint8_t pattern, uint32_t hold_ticks) { + memset(engine.row_buffer[0], pattern, T5S3_EPD_BUS_ROW_BYTES); + memset(engine.row_buffer[1], pattern, T5S3_EPD_BUS_ROW_BYTES); + start_frame(); + for (int y = 0; y < T5S3_EPD_HEIGHT; y++) { + write_row(hold_ticks); + } + write_row(hold_ticks); + end_frame(); +} + +// endregion + +// region Setup + +static bool setup_register_pins() { + gpio_config_t config = {}; + config.pin_bit_mask = (1ULL << PIN_REGISTER_DATA) | (1ULL << PIN_REGISTER_CLOCK) | (1ULL << PIN_REGISTER_STROBE); + config.mode = GPIO_MODE_OUTPUT; + if (gpio_config(&config) != ESP_OK) { + return false; + } + pin_set(PIN_REGISTER_STROBE, 0); + return true; +} + +static bool setup_bus(uint32_t pixel_clock_hz) { + esp_lcd_i80_bus_config_t bus_config = {}; + bus_config.dc_gpio_num = PIN_STH; + bus_config.wr_gpio_num = PIN_CKH; + bus_config.clk_src = LCD_CLK_SRC_DEFAULT; + for (int i = 0; i < 8; i++) { + bus_config.data_gpio_nums[i] = BUS_PINS[i]; + } + bus_config.bus_width = 8; + bus_config.max_transfer_bytes = BUS_TRANSFER_BYTES; + if (esp_lcd_new_i80_bus(&bus_config, &engine.bus) != ESP_OK) { + return false; + } + + // The data/command line is low when idle, high during a dummy command phase and low during the data of a row + esp_lcd_panel_io_i80_config_t io_config = {}; + io_config.cs_gpio_num = GPIO_NUM_NC; + io_config.pclk_hz = pixel_clock_hz; + io_config.trans_queue_depth = 4; + io_config.on_color_trans_done = on_transfer_done; + io_config.lcd_cmd_bits = 10; + io_config.lcd_param_bits = 0; + io_config.dc_levels.dc_idle_level = 0; + io_config.dc_levels.dc_cmd_level = 1; + io_config.dc_levels.dc_dummy_level = 0; + io_config.dc_levels.dc_data_level = 0; + return esp_lcd_new_panel_io_i80(engine.bus, &io_config, &engine.io) == ESP_OK; +} + +static bool setup_pulse() { + rmt_tx_channel_config_t channel_config = {}; + channel_config.gpio_num = PIN_CKV; + channel_config.clk_src = RMT_CLK_SRC_DEFAULT; + channel_config.resolution_hz = PULSE_RESOLUTION_HZ; + channel_config.mem_block_symbols = 48; + channel_config.trans_queue_depth = 1; + if (rmt_new_tx_channel(&channel_config, &engine.pulse_channel) != ESP_OK) { + return false; + } + rmt_copy_encoder_config_t encoder_config = {}; + if (rmt_new_copy_encoder(&encoder_config, &engine.pulse_encoder) != ESP_OK) { + return false; + } + rmt_tx_event_callbacks_t callbacks = {}; + callbacks.on_trans_done = on_pulse_done; + if (rmt_tx_register_event_callbacks(engine.pulse_channel, &callbacks, nullptr) != ESP_OK) { + return false; + } + return rmt_enable(engine.pulse_channel) == ESP_OK; +} + +bool t5s3_epd_init(uint32_t pixel_clock_hz) { + if (engine.front != nullptr) { + return true; + } + + engine.front = static_cast(heap_caps_aligned_alloc(16, T5S3_EPD_FB_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + engine.back = static_cast(heap_caps_aligned_alloc(16, T5S3_EPD_FB_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + for (auto& buffer: engine.row_buffer) { + buffer = static_cast(heap_caps_aligned_calloc(16, 1, BUS_TRANSFER_BYTES, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + } + if (engine.front == nullptr || engine.back == nullptr || engine.row_buffer[0] == nullptr || engine.row_buffer[1] == nullptr) { + LOG_E(TAG, "Out of memory"); + t5s3_epd_deinit(); + return false; + } + memset(engine.front, 0xFF, T5S3_EPD_FB_BYTES); + memset(engine.back, 0xFF, T5S3_EPD_FB_BYTES); + + if (!setup_register_pins() || !setup_bus(pixel_clock_hz) || !setup_pulse()) { + LOG_E(TAG, "Failed to set up the pins and peripherals"); + t5s3_epd_deinit(); + return false; + } + + engine.latch = false; + engine.output_enable = false; + engine.mode = false; + engine.start_of_frame = true; + engine.power_disable = true; + engine.positive_power = false; + engine.negative_power = false; + engine.current = 0; + engine.transfer_done = true; + engine.pulse_done = true; + push_register(); + return true; +} + +void t5s3_epd_deinit() { + if (engine.pulse_channel != nullptr) { + // Both calls fail harmlessly when the channel was not enabled + rmt_disable(engine.pulse_channel); + rmt_del_channel(engine.pulse_channel); + engine.pulse_channel = nullptr; + } + if (engine.pulse_encoder != nullptr) { + rmt_del_encoder(engine.pulse_encoder); + engine.pulse_encoder = nullptr; + } + if (engine.io != nullptr) { + esp_lcd_panel_io_del(engine.io); + engine.io = nullptr; + } + if (engine.bus != nullptr) { + esp_lcd_del_i80_bus(engine.bus); + engine.bus = nullptr; + } + heap_caps_free(engine.front); + heap_caps_free(engine.back); + heap_caps_free(engine.row_buffer[0]); + heap_caps_free(engine.row_buffer[1]); + engine.front = nullptr; + engine.back = nullptr; + engine.row_buffer[0] = nullptr; + engine.row_buffer[1] = nullptr; +} + +// endregion + +// region Power + +void t5s3_epd_power_on() { + engine.power_disable = false; + push_register(); + esp_rom_delay_us(100); + engine.negative_power = true; + push_register(); + esp_rom_delay_us(500); + engine.positive_power = true; + push_register(); + esp_rom_delay_us(100); + engine.start_of_frame = true; + push_register(); +} + +void t5s3_epd_power_off() { + engine.positive_power = false; + push_register(); + esp_rom_delay_us(10); + engine.negative_power = false; + push_register(); + esp_rom_delay_us(100); + engine.start_of_frame = false; + engine.output_enable = false; + engine.mode = false; + engine.power_disable = true; + push_register(); +} + +// endregion + +// region Updates + +uint8_t* t5s3_epd_framebuffer() { + return engine.front; +} + +void t5s3_epd_fill_white() { + memset(engine.front, 0xFF, T5S3_EPD_FB_BYTES); +} + +void t5s3_epd_invalidate() { + auto* words = reinterpret_cast(engine.back); + for (int i = 0; i < T5S3_EPD_FB_BYTES / 4; i++) { + words[i] = ~words[i]; + } +} + +bool t5s3_epd_update(T5s3EpdMode mode, int32_t y_start, int32_t y_end) { + if (y_start < 0) { + y_start = 0; + } + if (y_end > T5S3_EPD_HEIGHT) { + y_end = T5S3_EPD_HEIGHT; + } + if (y_start >= y_end) { + return true; + } + const int64_t started = esp_timer_get_time(); + + // Find the rows and columns that changed + memset(engine.row_dirty, 0, sizeof(engine.row_dirty)); + memset(engine.changes, 0, sizeof(engine.changes)); + auto* changes = reinterpret_cast(engine.changes); + bool any_changes = false; + for (int y = y_start; y < y_end; y++) { + const auto* to_row = reinterpret_cast(engine.front + y * T5S3_EPD_FB_ROW_BYTES); + const auto* from_row = reinterpret_cast(engine.back + y * T5S3_EPD_FB_ROW_BYTES); + uint32_t row_changes = 0; + for (int i = 0; i < T5S3_EPD_FB_ROW_BYTES / 4; i++) { + const uint32_t difference = to_row[i] ^ from_row[i]; + changes[i] |= difference; + row_changes |= difference; + } + engine.row_dirty[y] = row_changes != 0; + any_changes = any_changes || row_changes != 0; + } + if (!any_changes) { + return true; + } + t5s3_epd_build_line_mask(engine.line_mask, engine.changes); + + engine.failed = false; + uint8_t table[256]; + if (mode == T5s3EpdMode::Fast) { + t5s3_epd_build_fast_table(table); + for (uint32_t phase = 0; phase < FAST_PHASES; phase++) { + draw_frame(table, FAST_HOLD_TICKS); + taskYIELD(); + } + } else { + // Long updates sleep between frames so that the idle task can feed the watchdog + for (int phase = 0; phase < T5S3_EPD_QUALITY_PHASES; phase++) { + t5s3_epd_build_quality_table(table, phase, mode == T5s3EpdMode::Full); + draw_frame(table, T5S3_EPD_QUALITY_HOLD[phase]); + vTaskDelay(1); + } + } + + for (int y = y_start; y < y_end; y++) { + if (engine.row_dirty[y]) { + memcpy(engine.back + y * T5S3_EPD_FB_ROW_BYTES, engine.front + y * T5S3_EPD_FB_ROW_BYTES, T5S3_EPD_FB_ROW_BYTES); + } + } + + if (engine.failed) { + LOG_E(TAG, "Update failed, a peripheral did not respond"); + return false; + } + const int elapsed_ms = static_cast((esp_timer_get_time() - started) / 1000); + // Quality and slow updates are logged at info level, frequent fast updates would flood the log + if (mode != T5s3EpdMode::Fast || elapsed_ms > 150) { + LOG_I(TAG, "Updated rows %d to %d in %d ms", static_cast(y_start), static_cast(y_end), elapsed_ms); + } else { + LOG_D(TAG, "Updated rows %d to %d in %d ms", static_cast(y_start), static_cast(y_end), elapsed_ms); + } + return true; +} + +bool t5s3_epd_clear() { + const int64_t started = esp_timer_get_time(); + t5s3_epd_fill_white(); + if (!t5s3_epd_update(T5s3EpdMode::Full, 0, T5S3_EPD_HEIGHT)) { + return false; + } + engine.failed = false; + for (int cycle = 0; cycle < CLEAR_CYCLES; cycle++) { + for (int i = 0; i < CLEAR_DARK_FRAMES; i++) { + draw_uniform_frame(ALL_DARK, CLEAR_HOLD_TICKS); + vTaskDelay(1); + } + for (int i = 0; i < CLEAR_LIGHT_FRAMES; i++) { + draw_uniform_frame(ALL_LIGHT, CLEAR_HOLD_TICKS); + vTaskDelay(1); + } + for (int i = 0; i < CLEAR_REST_FRAMES; i++) { + draw_uniform_frame(0, CLEAR_HOLD_TICKS); + vTaskDelay(1); + } + } + LOG_I(TAG, "Cleared in %d ms", static_cast((esp_timer_get_time() - started) / 1000)); + return !engine.failed; +} + +// endregion diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.h b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.h new file mode 100644 index 000000000..f6dd6b898 --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// +// Minimal driver for the ED047TC1 panel of the LILYGO T5 4.7 Inch E-Paper S3. +// The calls must be serialized by the user, the driver has no locking. + +#pragma once + +#include "t5s3_epd_rows.h" + +#include + +enum class T5s3EpdMode { + // Black and white only + Fast, + // 16 levels in 30 phases, white pixels that stay white are not touched + Quality, + // 16 levels in 30 phases, white pixels that stay white are flashed too, which clears ghosting + Full +}; + +// Allocates the frame buffers and sets up the pins and peripherals. The panel stays unpowered. +bool t5s3_epd_init(uint32_t pixel_clock_hz); + +void t5s3_epd_deinit(); + +// Frame buffer to draw in, 4 bits per pixel. The panel shows it after t5s3_epd_update(). +uint8_t* t5s3_epd_framebuffer(); + +void t5s3_epd_power_on(); + +void t5s3_epd_power_off(); + +// Drives all pixels that changed in the rows [y_start, y_end) since the last update. Needs the panel to be powered. +bool t5s3_epd_update(T5s3EpdMode mode, int32_t y_start, int32_t y_end); + +void t5s3_epd_fill_white(); + +// Makes the next update drive every pixel +void t5s3_epd_invalidate(); + +// Flashes the whole panel to white. Needs the panel to be powered. +bool t5s3_epd_clear(); + +inline void t5s3_epd_set_pixel(uint8_t* framebuffer, int32_t x, int32_t y, uint8_t level) { + if (x < 0 || x >= T5S3_EPD_WIDTH || y < 0 || y >= T5S3_EPD_HEIGHT) { + return; + } + uint8_t* byte = &framebuffer[y * T5S3_EPD_FB_ROW_BYTES + x / 2]; + if (x % 2) { + *byte = (*byte & 0x0F) | static_cast(level << 4); + } else { + *byte = (*byte & 0xF0) | (level & 0x0F); + } +} diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_rows.h b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_rows.h new file mode 100644 index 000000000..34f36befc --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_rows.h @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// +// Conversion of framebuffer rows into the data the source driver of the ED047TC1 panel expects. +// This code has no hardware access. + +#pragma once + +#include "t5s3_epd_waveform.h" + +#include + +#ifdef ESP_PLATFORM +#include +#define T5S3_EPD_IRAM IRAM_ATTR +#else +#define T5S3_EPD_IRAM +#endif + +constexpr int T5S3_EPD_WIDTH = 960; +constexpr int T5S3_EPD_HEIGHT = 540; + +// Framebuffer rows hold 4 bits per pixel with the even pixel of a pair in the low nibble, 0x0 is black and 0xF is white +constexpr int T5S3_EPD_FB_ROW_BYTES = T5S3_EPD_WIDTH / 2; +constexpr int T5S3_EPD_FB_BYTES = T5S3_EPD_FB_ROW_BYTES * T5S3_EPD_HEIGHT; + +// Rows on the bus hold 2 bits per pixel with the first of four pixels in the lowest bits +constexpr int T5S3_EPD_BUS_ROW_BYTES = T5S3_EPD_WIDTH / 4; + +constexpr uint8_t T5S3_EPD_ACTION_HOLD = 0; +constexpr uint8_t T5S3_EPD_ACTION_DARK = 1; +constexpr uint8_t T5S3_EPD_ACTION_LIGHT = 2; + +// Tables map (target level << 4 | source level) to the action for one phase of an update. + +// Fast update: pixels that end up black are driven dark and pixels that end up white are driven light +inline void t5s3_epd_build_fast_table(uint8_t* table) { + for (int to = 0; to < 16; to++) { + for (int from = 0; from < 16; from++) { + uint8_t action = T5S3_EPD_ACTION_HOLD; + if (to == 0 && from != 0) { + action = T5S3_EPD_ACTION_DARK; + } else if (to == 15 && from != 15) { + action = T5S3_EPD_ACTION_LIGHT; + } + table[(to << 4) | from] = action; + } + } +} + +// The waveform drives white pixels that stay white with a dark and a light pulse, which flashes them. +// Holding them instead leaves the rest of an area alone, so an update of a part of the screen does not leave visible columns. +inline void t5s3_epd_build_quality_table(uint8_t* table, int phase, bool flash_white) { + const uint8_t* packed = T5S3_EPD_QUALITY_LUT[phase]; + for (int to = 0; to < 16; to++) { + for (int group = 0; group < 4; group++) { + const uint8_t actions = *packed++; + const int index = (to << 4) | (group * 4); + table[index] = (actions >> 6) & 3; + table[index + 1] = (actions >> 4) & 3; + table[index + 2] = (actions >> 2) & 3; + table[index + 3] = actions & 3; + } + } + if (!flash_white) { + table[(15 << 4) | 15] = T5S3_EPD_ACTION_HOLD; + } +} + +// Keeps the pixels of changed columns and holds all others. A framebuffer byte of the changes holds the XOR of two pixels. +inline void t5s3_epd_build_line_mask(uint8_t* mask, const uint8_t* changes) { + for (int i = 0; i < T5S3_EPD_BUS_ROW_BYTES; i++) { + const uint8_t first = changes[2 * i]; + const uint8_t second = changes[2 * i + 1]; + uint8_t value = 0; + value |= (first & 0x0F) != 0 ? 0x03 : 0x00; + value |= (first & 0xF0) != 0 ? 0x0C : 0x00; + value |= (second & 0x0F) != 0 ? 0x30 : 0x00; + value |= (second & 0xF0) != 0 ? 0xC0 : 0x00; + mask[i] = value; + } +} + +// Converts one row of target levels and one row of source levels into bus data. +// All pointers must be 4-byte aligned. Four framebuffer bytes (8 pixels) become two bus bytes per step. +inline void T5S3_EPD_IRAM t5s3_epd_prepare_row( + uint8_t* out, + const uint8_t* to_row, + const uint8_t* from_row, + const uint8_t* table, + const uint8_t* mask +) { + const auto* to_words = reinterpret_cast(to_row); + const auto* from_words = reinterpret_cast(from_row); + const auto* mask_words = reinterpret_cast(mask); + auto* out_words = reinterpret_cast(out); + for (int i = 0; i < T5S3_EPD_FB_ROW_BYTES / 4; i++) { + const uint32_t to = to_words[i]; + const uint32_t from = from_words[i]; + // Table index of a pixel is (target level << 4) | source level, the even pixel of a byte is in its low nibble + const uint32_t even = ((to & 0x0F0F0F0F) << 4) | (from & 0x0F0F0F0F); + const uint32_t odd = (to & 0xF0F0F0F0) | ((from >> 4) & 0x0F0F0F0F); + const uint32_t first = table[even & 0xFF] | (table[odd & 0xFF] << 2) | (table[(even >> 8) & 0xFF] << 4) | (table[(odd >> 8) & 0xFF] << 6); + const uint32_t second = table[(even >> 16) & 0xFF] | (table[(odd >> 16) & 0xFF] << 2) | (table[even >> 24] << 4) | (table[odd >> 24] << 6); + out_words[i] = static_cast((first | (second << 8)) & mask_words[i]); + } +} diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_waveform.h b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_waveform.h new file mode 100644 index 000000000..464540911 --- /dev/null +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd_waveform.h @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// +// Drive data of the ED047TC1 panel for the 16 level update, taken from the epdiy project +// (https://github.com/vroland/epdiy, built-in waveform epdiy_ED047TC1, mode GC16). +// This file is under the LGPL v3.0 or later like epdiy itself. + +#pragma once + +#include + +constexpr int T5S3_EPD_QUALITY_PHASES = 30; + +// Hold time of each phase in units of 0.1 microsecond +constexpr uint16_t T5S3_EPD_QUALITY_HOLD[T5S3_EPD_QUALITY_PHASES] = { + 30, 30, 20, 20, 30, 30, 30, 40, 40, 50, 50, 50, 100, 200, 300, 10, 10, 8, 8, 8, 8, 8, 10, 10, 15, 15, 20, 20, 100, 300 +}; + +// Per phase: 16 target levels with 4 bytes each. A byte holds the 2 bit actions of 4 source levels, +// the lowest source level in the top bits. Action 0 holds the pixel, 1 drives it dark and 2 light. +constexpr uint8_t T5S3_EPD_QUALITY_LUT[T5S3_EPD_QUALITY_PHASES][64] = { + { + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + }, + { + 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, + 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x05, + }, + { + 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, + 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, + 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, + 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, + }, + { + 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, + 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, + 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, + 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, 0x00, 0x00, 0x00, 0x55, + }, + { + 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, + 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, + 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, + 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, 0x00, 0x00, 0x01, 0x55, + }, + { + 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, + 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, + 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, + 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, 0x00, 0x00, 0x05, 0x55, + }, + { + 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, + 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, + 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, + 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, 0x00, 0x00, 0x15, 0x55, + }, + { + 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, + 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, + 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, + 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, 0x00, 0x00, 0x55, 0x55, + }, + { + 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, + 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, + 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, + 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, 0x00, 0x01, 0x55, 0x55, + }, + { + 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, + 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, + 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, + 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, 0x00, 0x05, 0x55, 0x55, + }, + { + 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, + 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, + 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, + 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, 0x00, 0x15, 0x55, 0x55, + }, + { + 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, + 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, + 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, + 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, 0x00, 0x55, 0x55, 0x55, + }, + { + 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, + 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, + 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, + 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, 0x01, 0x55, 0x55, 0x55, + }, + { + 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, + 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, + 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, + 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, 0x05, 0x55, 0x55, 0x55, + }, + { + 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, + 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, + 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, + 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, 0x15, 0x55, 0x55, 0x55, + }, + { + 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + }, + { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xAA, 0xAA, 0xAA, 0xAA, + }, +}; diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 49df9c427..dd427a030 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -8,6 +8,14 @@ Website: https://github.com/valdanylchuk/breezybox License: [MIT License](https://github.com/valdanylchuk/breezybox/blob/main/LICENSE) +### epdiy + +The e-paper driver of the LILYGO T5 4.7 Inch E-Paper S3 (`Devices/lilygo-t5-epd47-s3`) is derived from epdiy and contains its ED047TC1 waveform data. + +Website: https://github.com/vroland/epdiy + +License: [LGPL v3.0 or later](https://github.com/vroland/epdiy/blob/main/LICENSE) + ### ESP-IDF This project uses ESP-IDF to compile the ESP32 firmware. diff --git a/Tactility/idf_component.yml b/Tactility/idf_component.yml index 22502aa49..ea8299b45 100644 --- a/Tactility/idf_component.yml +++ b/Tactility/idf_component.yml @@ -83,9 +83,8 @@ dependencies: espressif/esp_lvgl_port: "2.7.2" lvgl/lvgl: "9.3.0" epdiy: - # Fork of epdiy 2.1.3 that adds the LILYGO T5 4.7 Inch E-Paper S3, disabled by default - git: https://github.com/AdaSzi/epdiy.git - version: 2.1.3-t5s3.1 + git: https://github.com/vroland/epdiy.git + version: 2.1.3 rules: # More hardware might be supported - enable as needed - if: "target in [esp32s3]" From e14e95b0823b92570f22aab1837c8fd9b30ac7d1 Mon Sep 17 00:00:00 2001 From: AdaSzi115026 <81822538+AdaSzi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:45:25 +0200 Subject: [PATCH 4/4] Stop the T5 4.7 Inch E-Paper S3 engine after a peripheral timeout A timeout while waiting for a transfer or a pulse only set a flag, so the rest of the update still waited for the full limit on every row. The row, phase and clear loops and the wait itself now stop once an update has failed, so a lost interrupt costs one timeout. A failed update no longer copies its rows to the back buffer, so the next update draws them again. --- .../source/drivers/t5s3_epd.cpp | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp index 328ff3942..39f9f83fb 100644 --- a/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp +++ b/Devices/lilygo-t5-epd47-s3/source/drivers/t5s3_epd.cpp @@ -59,7 +59,7 @@ static constexpr int CLEAR_REST_FRAMES = 2; static constexpr uint8_t ALL_DARK = 0x55; static constexpr uint8_t ALL_LIGHT = 0xAA; -// Limit for waiting on a peripheral, so that a lost interrupt cannot hang the system +// Limit for waiting on a flag that an interrupt handler sets, so that a lost interrupt fails the update instead of spinning forever static constexpr uint32_t WAIT_SPIN_LIMIT = 20000000; struct Engine { @@ -106,10 +106,11 @@ static bool IRAM_ATTR on_transfer_done(esp_lcd_panel_io_handle_t, esp_lcd_panel_ return false; } +// After a failure nothing is waited for anymore, so the rest of the update ends quickly static void IRAM_ATTR wait_for(volatile bool& flag) { uint32_t spins = 0; while (!flag) { - if (++spins > WAIT_SPIN_LIMIT) { + if (engine.failed || ++spins > WAIT_SPIN_LIMIT) { engine.failed = true; flag = true; } @@ -260,7 +261,7 @@ static void IRAM_ATTR draw_frame(const uint8_t* table, uint32_t hold_ticks) { start_frame(); int y = 0; bool ends_with_train = false; - while (y < T5S3_EPD_HEIGHT) { + while (y < T5S3_EPD_HEIGHT && !engine.failed) { ends_with_train = false; if (engine.row_dirty[y]) { t5s3_epd_prepare_row( @@ -286,7 +287,7 @@ static void IRAM_ATTR draw_frame(const uint8_t* table, uint32_t hold_ticks) { } } // The last row is applied by one more pulse - if (engine.rows_skipped == 0) { + if (engine.rows_skipped == 0 && !engine.failed) { write_row(hold_ticks); } else if (ends_with_train) { // The end of the frame must not overlap the train of pulses of the skipped rows @@ -299,10 +300,12 @@ static void IRAM_ATTR draw_uniform_frame(uint8_t pattern, uint32_t hold_ticks) { memset(engine.row_buffer[0], pattern, T5S3_EPD_BUS_ROW_BYTES); memset(engine.row_buffer[1], pattern, T5S3_EPD_BUS_ROW_BYTES); start_frame(); - for (int y = 0; y < T5S3_EPD_HEIGHT; y++) { + for (int y = 0; y < T5S3_EPD_HEIGHT && !engine.failed; y++) { + write_row(hold_ticks); + } + if (!engine.failed) { write_row(hold_ticks); } - write_row(hold_ticks); end_frame(); } @@ -528,29 +531,31 @@ bool t5s3_epd_update(T5s3EpdMode mode, int32_t y_start, int32_t y_end) { uint8_t table[256]; if (mode == T5s3EpdMode::Fast) { t5s3_epd_build_fast_table(table); - for (uint32_t phase = 0; phase < FAST_PHASES; phase++) { + for (uint32_t phase = 0; phase < FAST_PHASES && !engine.failed; phase++) { draw_frame(table, FAST_HOLD_TICKS); taskYIELD(); } } else { // Long updates sleep between frames so that the idle task can feed the watchdog - for (int phase = 0; phase < T5S3_EPD_QUALITY_PHASES; phase++) { + for (int phase = 0; phase < T5S3_EPD_QUALITY_PHASES && !engine.failed; phase++) { t5s3_epd_build_quality_table(table, phase, mode == T5s3EpdMode::Full); draw_frame(table, T5S3_EPD_QUALITY_HOLD[phase]); vTaskDelay(1); } } + // A failed update leaves the back buffer alone, so the next update draws the same rows again + if (engine.failed) { + LOG_E(TAG, "Update failed, a peripheral did not respond"); + return false; + } + for (int y = y_start; y < y_end; y++) { if (engine.row_dirty[y]) { memcpy(engine.back + y * T5S3_EPD_FB_ROW_BYTES, engine.front + y * T5S3_EPD_FB_ROW_BYTES, T5S3_EPD_FB_ROW_BYTES); } } - if (engine.failed) { - LOG_E(TAG, "Update failed, a peripheral did not respond"); - return false; - } const int elapsed_ms = static_cast((esp_timer_get_time() - started) / 1000); // Quality and slow updates are logged at info level, frequent fast updates would flood the log if (mode != T5s3EpdMode::Fast || elapsed_ms > 150) { @@ -561,6 +566,13 @@ bool t5s3_epd_update(T5s3EpdMode mode, int32_t y_start, int32_t y_end) { return true; } +static void draw_clear_frames(uint8_t pattern, int count) { + for (int i = 0; i < count && !engine.failed; i++) { + draw_uniform_frame(pattern, CLEAR_HOLD_TICKS); + vTaskDelay(1); + } +} + bool t5s3_epd_clear() { const int64_t started = esp_timer_get_time(); t5s3_epd_fill_white(); @@ -569,21 +581,16 @@ bool t5s3_epd_clear() { } engine.failed = false; for (int cycle = 0; cycle < CLEAR_CYCLES; cycle++) { - for (int i = 0; i < CLEAR_DARK_FRAMES; i++) { - draw_uniform_frame(ALL_DARK, CLEAR_HOLD_TICKS); - vTaskDelay(1); - } - for (int i = 0; i < CLEAR_LIGHT_FRAMES; i++) { - draw_uniform_frame(ALL_LIGHT, CLEAR_HOLD_TICKS); - vTaskDelay(1); - } - for (int i = 0; i < CLEAR_REST_FRAMES; i++) { - draw_uniform_frame(0, CLEAR_HOLD_TICKS); - vTaskDelay(1); - } + draw_clear_frames(ALL_DARK, CLEAR_DARK_FRAMES); + draw_clear_frames(ALL_LIGHT, CLEAR_LIGHT_FRAMES); + draw_clear_frames(0, CLEAR_REST_FRAMES); + } + if (engine.failed) { + LOG_E(TAG, "Clear failed, a peripheral did not respond"); + return false; } LOG_I(TAG, "Cleared in %d ms", static_cast((esp_timer_get_time() - started) / 1000)); - return !engine.failed; + return true; } // endregion