Files
wadamesh/variants/tdisplay_p4/HI8561Display.cpp
T
Kaj SchittecatandClaude Opus 4.8 7aaff80742 touch: P4 — move the flush upscale scratch out of PSRAM (#167 teal flash on RX)
Both P4 SKUs render at half resolution and nearest-neighbour-upscale each LVGL band
into a scratch buffer before handing it to the panel. That scratch was allocated with
MALLOC_CAP_SPIRAM, which put it on the worst possible bus.

These are DPI/DSI panels with num_fbs = 1: the ~1.4 MB framebuffer can only live in
PSRAM, and the DSI DMA streams it to the glass CONTINUOUSLY. Every draw_bitmap is a
copy INTO that framebuffer, so with the scratch also in PSRAM a single flush put three
streams on one bus at once — the scratch read, the framebuffer write, and the DSI's own
read. Anything else wanting PSRAM in that window can starve the DSI read, and a DPI
underrun presents as a whole-screen colour flash for one frame.

That is the reported "screen flashes teal on every RX" (#167 flicker): it happens on
ANY screen and was there from the start of the port, because it is a display-bus
problem and has nothing to do with what is being drawn. The RX correlation is the
priority-10 "lora_rx" drain task, which wakes on every received packet and is ON by
default (rx_queue defaults to 1).

Allocating the scratch in internal DMA RAM removes the scratch read from the PSRAM bus
entirely. The cost is bounded and small: one LVGL band is LV_DRAW_BUF_LINES = 24 rows,
so AMOLED 284x24 -> 568*48*2 = ~53 KB and LCD 270x24 -> 540*48*2 = ~51 KB, against a
348 KiB internal region. PSRAM stays as the fallback so a fragmented heap degrades to
the previous behaviour rather than dropping to the unscaled draw path.

Not done here: lowering the drain task's priority. It is created in the vendored core
(RadioLibWrappers.cpp), which is not tracked in this repo, so a change there cannot
ship from wadamesh — it needs a monorepo core change plus a core-* tag bump. It is also
the weaker lever: the core pins that task to core 0 while LVGL and loop run on core 1,
so it is not preempting the UI thread, and the contention this fixes is the memory bus.

Builds clean and boot-verified on the device (T-Display P4, MAC ...e1:c2:a7); no
allocation failures, only rst:0x1 (POWERON).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 15:35:09 +02:00

237 lines
12 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
//
// HI8561 TFT-LCD DisplayDriver for the LilyGo T-Display P4 (LCD SKU). Mirrors RM69A10Display.cpp
// almost line-for-line — the ESP32-P4 MIPI-DSI bring-up is identical; only the vendor panel driver,
// the 540x1168 resolution and the DPI timing differ. The vendor panel driver lives in the vendored
// esp_lcd_hi8561.{h,cpp} (Espressif's Apache-2.0 esp_lcd_hi8561, from Waveshare-ESP32-components,
// unmodified except the .c->.cpp rename for our variants/ glob). The 540x1168 @ 48 MHz DPI timing is
// the vendor's HI8561_540_1168_PANEL_60HZ_DPI_CONFIG macro (hand-inlined here so num_fbs / pixel
// format stay explicit and this file diffs cleanly against RM69A10Display.cpp). The DSI-PHY LDO is
// kept at the tested-on-the-board 1.83 V (a deprecated LilyGo P4 bin notes "changed MIPI voltage
// domain to 1.8V" — same rail for both panels), NOT the 2.5 V a generic HI8561 eval board uses.
#if defined(HAS_TDISPLAY_P4)
#include "HI8561Display.h"
#include <Arduino.h>
#include "esp_heap_caps.h"
#include "esp_ldo_regulator.h"
#include "Xl9535.h" // SCREEN_RST lives on the expander (reset_gpio_num = -1)
#include "esp_lcd_mipi_dsi.h"
#include "esp_lcd_panel_io.h" // esp_lcd_panel_io_tx_param (runtime brightness 0x51)
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_hi8561.h"
#include "driver/ledc.h" // GPIO 51 PWM backlight — the TFT-LCD has a real LED backlight
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
// The HI8561 TFT-LCD backlight is a hardware LED driven by PWM on GPIO 51 (LilyGo's
// hi8561::kScreenBacklight). Unlike the AMOLED (RM69A10, self-emissive, brightness via DCS 0x51),
// the LCD panel renders but stays dark until this PWM is driven — the "no backlight, works under a
// flashlight" report on the P4 LCD. Set up LEDC and drive it from setBrightness().
static constexpr int kBacklightGpio = 51;
static constexpr ledc_timer_t kBlTimer = LEDC_TIMER_0;
static constexpr ledc_channel_t kBlChan = LEDC_CHANNEL_0;
static bool s_hi8561_bl_ready = false;
// The DPI panel copies each draw_bitmap band into the internal frame buffer ASYNCHRONOUSLY
// (DMA2D) and fires on_color_trans_done when the copy is complete. writePixelsRGB565 reuses a
// single upscale scratch buffer (and LVGL reuses its draw buffer) on the very next flush, so
// without waiting for that copy the next band overwrites the source mid-transfer — corrupting
// the frame buffer as horizontal black/garbage bands. This binary semaphore makes each flush
// synchronous: draw_bitmap -> wait for done. (Same contract as RM69A10Display.)
static SemaphoreHandle_t s_flush_sem = nullptr;
static bool IRAM_ATTR hi8561TransDone(esp_lcd_panel_handle_t, esp_lcd_dpi_panel_event_data_t*, void*) {
BaseType_t hpw = pdFALSE;
if (s_flush_sem) xSemaphoreGiveFromISR(s_flush_sem, &hpw);
return hpw == pdTRUE;
}
// HI8561 TFT-LCD, 540x1168 portrait. DPI timing = vendor HI8561_540_1168_PANEL_60HZ_DPI_CONFIG.
#define HI_W 540
#define HI_H 1168
// UI scale: the panel is high-DPI, so LVGL renders at 1/HI_UI_SCALE and writePixelsRGB565
// nearest-neighbour-upscales each flush to fill the native panel — whole UI HI_UI_SCALE x bigger,
// uniformly. UITask's HAS_TDP4_LCD LVGL res + the HI8561-touch scale must match (HI_W/HI_UI_SCALE).
#ifndef HI_UI_SCALE
#define HI_UI_SCALE 2
#endif
#define HI_DPI_MHZ 48 // vendor HI8561_540_1168 DPI clock
#define HI_LANES 2
#define HI_BITRATE 1000 // Mbps per lane (vendor HI8561_PANEL_BUS_DSI_2CH_CONFIG)
#define HI_HSYNC 20 // hsync_pulse_width
#define HI_HBP 40 // hsync_back_porch
#define HI_HFP 20 // hsync_front_porch
#define HI_VSYNC 2 // vsync_pulse_width
#define HI_VBP 12 // vsync_back_porch
#define HI_VFP 200 // vsync_front_porch
// ESP32-P4 MIPI-DSI PHY internal LDO: channel 3 @ 1.83V — the tested LilyGo T-Display P4 value
// (same rail the RM69A10 SKU uses; the board's MIPI voltage domain is 1.8V, per LilyGo).
#define DSI_LDO_CHAN 3
#define DSI_LDO_MV 1830
static esp_ldo_channel_handle_t s_ldo = nullptr;
bool HI8561Display::begin() {
// 1. Power the DSI PHY via the P4 internal LDO (BEFORE creating the DSI bus).
esp_ldo_channel_config_t ldo_cfg = { .chan_id = DSI_LDO_CHAN, .voltage_mv = DSI_LDO_MV };
if (esp_ldo_acquire_channel(&ldo_cfg, &s_ldo) != ESP_OK) {
Serial.println("[HI8561] LDO acquire fail"); return false;
}
delay(100);
// 1b. Panel hardware reset via the XL9535 (reset_gpio_num = -1 so esp_lcd won't do it). Same
// HIGH -> LOW -> HIGH / 200 ms order as the RM69A10 SKU, done AFTER the LDO and BEFORE the DSI bus.
xl9535.write(Xl9535::IO_SCREEN_RST, true); delay(200);
xl9535.write(Xl9535::IO_SCREEN_RST, false); delay(200);
xl9535.write(Xl9535::IO_SCREEN_RST, true); delay(200);
// 2. DSI bus (initialises the DSI PHY).
esp_lcd_dsi_bus_handle_t dsi_bus = nullptr;
esp_lcd_dsi_bus_config_t bus_cfg = {
.bus_id = 0, .num_data_lanes = HI_LANES,
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, .lane_bit_rate_mbps = HI_BITRATE,
};
if (esp_lcd_new_dsi_bus(&bus_cfg, &dsi_bus) != ESP_OK) { Serial.println("[HI8561] dsi_bus fail"); return false; }
// 3. DBI IO (LCD command channel).
esp_lcd_panel_io_handle_t dbi_io = nullptr;
esp_lcd_dbi_io_config_t dbi_cfg = { .virtual_channel = 0, .lcd_cmd_bits = 8, .lcd_param_bits = 8 };
if (esp_lcd_new_panel_io_dbi(dsi_bus, &dbi_cfg, &dbi_io) != ESP_OK) { Serial.println("[HI8561] dbi_io fail"); return false; }
// 4. DPI (video) config — vendor HI8561_540_1168_PANEL_60HZ_DPI_CONFIG values, inlined.
esp_lcd_dpi_panel_config_t dpi_cfg = {
.virtual_channel = 0,
.dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT,
.dpi_clock_freq_mhz = HI_DPI_MHZ,
.pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565,
.num_fbs = 1,
.video_timing = {
.h_size = HI_W, .v_size = HI_H,
.hsync_pulse_width = HI_HSYNC, .hsync_back_porch = HI_HBP, .hsync_front_porch = HI_HFP,
.vsync_pulse_width = HI_VSYNC, .vsync_back_porch = HI_VBP, .vsync_front_porch = HI_VFP,
},
.flags = { .use_dma2d = true },
};
// 5. HI8561 vendor panel (reset via the XL9535 -> reset_gpio_num=-1; use the driver's default init).
hi8561_vendor_config_t vendor = {
.init_cmds = nullptr, .init_cmds_size = 0,
.mipi_config = { .dsi_bus = dsi_bus, .dpi_config = &dpi_cfg },
};
esp_lcd_panel_dev_config_t dev = {
.reset_gpio_num = -1,
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB,
.bits_per_pixel = 16,
.vendor_config = &vendor,
};
if (esp_lcd_new_panel_hi8561(dbi_io, &dev, &_panel) != ESP_OK) { Serial.println("[HI8561] new_panel fail"); return false; }
esp_lcd_panel_reset(_panel);
if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[HI8561] panel_init fail"); return false; }
esp_lcd_panel_disp_on_off(_panel, true);
// Backlight: bring up the LEDC PWM on GPIO 51 (the LCD's real LED backlight). Without this the
// panel renders but is dark. 20 kHz avoids audible/visible flicker; 8-bit duty maps 0..255 directly.
{
ledc_timer_config_t bl_tmr = {};
bl_tmr.speed_mode = LEDC_LOW_SPEED_MODE;
bl_tmr.timer_num = kBlTimer;
bl_tmr.duty_resolution = LEDC_TIMER_8_BIT;
bl_tmr.freq_hz = 20000;
bl_tmr.clk_cfg = LEDC_AUTO_CLK;
ledc_channel_config_t bl_ch = {};
bl_ch.gpio_num = kBacklightGpio;
bl_ch.speed_mode = LEDC_LOW_SPEED_MODE;
bl_ch.channel = kBlChan;
bl_ch.timer_sel = kBlTimer;
bl_ch.duty = 0;
bl_ch.hpoint = 0;
s_hi8561_bl_ready = (ledc_timer_config(&bl_tmr) == ESP_OK) &&
(ledc_channel_config(&bl_ch) == ESP_OK);
if (!s_hi8561_bl_ready) Serial.println("[HI8561] backlight PWM init FAILED");
}
// Keep the DBI IO handle so setBrightness() can also send DCS 0x51 (harmless if the panel
// ignores it); the real light is the PWM above. Start at max so the LCD actually lights.
_dbi_io = dbi_io;
setBrightness(255);
_on = true;
// Signal each draw_bitmap's frame-buffer copy completion so writePixelsRGB565 can wait for it.
s_flush_sem = xSemaphoreCreateBinary();
esp_lcd_dpi_panel_event_callbacks_t cbs = {};
cbs.on_color_trans_done = hi8561TransDone;
esp_lcd_dpi_panel_register_event_callbacks(_panel, &cbs, nullptr);
// Clear the panel framebuffer to black once, so nothing stale shows before LVGL's first flush.
{
const int SH = 32;
uint16_t* strip = (uint16_t*)heap_caps_calloc((size_t)HI_W * SH, 2, MALLOC_CAP_SPIRAM);
if (strip) {
for (int y0 = 0; y0 < HI_H; y0 += SH) {
int hh = (y0 + SH <= HI_H) ? SH : (HI_H - y0);
esp_lcd_panel_draw_bitmap(_panel, 0, y0, HI_W, y0 + hh, strip);
if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // let the copy finish before reusing/freeing strip
}
free(strip);
}
}
Serial.printf("[HI8561] up %dx%d\n", HI_W, HI_H);
return true;
}
void HI8561Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) {
if (!_panel || !pixels || w <= 0 || h <= 0) return;
#if HI_UI_SCALE > 1
// Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes
// an HI_UI_SCALE x HI_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords).
const int S = HI_UI_SCALE, dw = w * S, dh = h * S;
// INTERNAL DMA RAM, not PSRAM — same reasoning as the AMOLED SKU (long note in
// RM69A10Display::writePixelsRGB565). This is a DPI/DSI panel whose single framebuffer must
// live in PSRAM and is streamed to the glass continuously, so keeping the upscale scratch in
// PSRAM too put three streams on one bus: the scratch read, the framebuffer write, and the
// DSI's own read. Any other PSRAM consumer — the priority-10 "lora_rx" drain task fires on
// every received packet — could then starve the DSI, and a DPI underrun shows as a
// whole-screen colour flash for one frame (#167). One band is 270x24 upscaled 2x =
// 540*48*2 = ~51 KB out of 768 KB SRAM. PSRAM stays the fallback.
static uint16_t* s_up = nullptr; static size_t s_up_px = 0;
size_t need = (size_t)dw * dh;
if (need > s_up_px) {
if (s_up) heap_caps_free(s_up);
s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t),
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
if (!s_up) s_up = (uint16_t*)heap_caps_malloc(need * sizeof(uint16_t), MALLOC_CAP_SPIRAM);
s_up_px = s_up ? need : 0;
}
if (s_up) {
for (int row = 0; row < h; row++) {
const uint16_t* src = pixels + (size_t)row * w;
uint16_t* d0 = s_up + (size_t)(row * S) * dw; // first of S output rows
for (int i = 0; i < w; i++) { uint16_t c = src[i]; uint16_t* p = d0 + i * S; for (int sx = 0; sx < S; sx++) p[sx] = c; }
for (int sy = 1; sy < S; sy++) memcpy(d0 + (size_t)sy * dw, d0, (size_t)dw * sizeof(uint16_t));
}
esp_lcd_panel_draw_bitmap(_panel, x * S, y * S, x * S + dw, y * S + dh, s_up);
if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // wait for the FB copy before the next flush reuses s_up
return;
}
// fall through to an unscaled draw if the scratch alloc failed (tiny UI, but visible)
#endif
// exclusive end coords (esp_lcd_panel_draw_bitmap contract).
esp_lcd_panel_draw_bitmap(_panel, x, y, x + w, y + h, pixels);
if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100)); // wait for the FB copy before LVGL reuses this buffer
}
void HI8561Display::setBrightness(uint8_t b) {
// The real backlight is the LEDC PWM on GPIO 51 (drives the LCD's LED); the DCS 0x51 is a
// best-effort no-op the AMOLED path used. Drive both so the CC slider dims the LCD.
if (s_hi8561_bl_ready) {
ledc_set_duty(LEDC_LOW_SPEED_MODE, kBlChan, b);
ledc_update_duty(LEDC_LOW_SPEED_MODE, kBlChan);
}
if (_dbi_io) esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS
}
void HI8561Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; }
void HI8561Display::turnOff() { if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; }
#endif // HAS_TDISPLAY_P4