Files
wadamesh/variants/tdisplay_p4/RM69A10Display.cpp
T
Michael A. CojocariandClaude Opus 5.5 4d86f66c39 fix(tdisplay-p4): draw the Remote UI placeholder on the physical panel
In Remote UI mode the UI renders off-screen for the browser and the
physical panel shows a status placeholder drawn through the
DisplayDriver API. On the P4 every fill/text call was an empty stub, and
writePixelsRGB565 takes half-resolution LVGL coordinates and upscales
2x, so only the logo appeared, twice as big and pushed towards the top
right, over whatever was on screen before.

Add P4PanelPainter: native-resolution fills, rectangles and text for
both P4 panels (RM69A10 AMOLED, HI8561 LCD), rasterising LVGL's
Montserrat fonts at the UI's 2x scale, written through a new
non-upscaling writeNativeRGB565. Give the placeholder a layout for the
tall portrait panel: logo, title, browser address or Wi-Fi status, and
the touch-and-hold exit hint, matching the other builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:28:04 -04:00

284 lines
15 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#if defined(HAS_TDISPLAY_P4)
#include "RM69A10Display.h"
#include <Arduino.h>
#include "esp_heap_caps.h"
#include "esp_cache.h" // esp_cache_msync — flush the memset framebuffer out to the DSI DMA
#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_rm69a10.h"
#if defined(TDP4_POKE_TRACE)
#include "soc/mipi_dsi_host_struct.h" // MIPI_DSI_HOST — raw host error status (poke-trace poller)
#include "freertos/task.h"
#endif
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
// 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, worst during transitions (many rapid
// flushes). This binary semaphore makes each flush synchronous: draw_bitmap → wait for done.
static SemaphoreHandle_t s_flush_sem = nullptr;
#if defined(TDP4_POKE_TRACE)
// #167 hunt, round 3: the esp_lcd driver watches ONLY the bridge-FIFO underrun (proven silent
// during visible flashes). The DSI HOST has its own never-read error latches: int_st0 = the
// panel acking packets WITH ERRORS, int_st1 = ECC/CRC/timeout/payload-write errors INCLUDING
// dpi_buff_pld_under (the host payload buffer underflowing -- a different FIFO than the bridge).
// Both are read-clear, so a 25 ms poll gives per-event capture. If these latch when the screen
// flashes, we have a hardware flash-detector and can bisect subsystems without eyes on the panel.
static void dsiErrPollTask(void*) {
for (;;) {
const uint32_t st0 = MIPI_DSI_HOST.int_st0.val; // RC
const uint32_t st1 = MIPI_DSI_HOST.int_st1.val; // RC
if (st0 || st1) printf("[DSIERR] %lu st0=%08X st1=%08X\n", (unsigned long)millis(), (unsigned)st0, (unsigned)st1);
vTaskDelay(pdMS_TO_TICKS(25));
}
}
#endif
static bool IRAM_ATTR rm69a10TransDone(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;
}
// RM69A10 DSI timing — LilyGo rm69a10_driver.h (values confirmed via t_display_p4_config.h).
#define RM_W 568
#define RM_H 1232
// UI scale: the 568x1232 AMOLED is very high-DPI, so LVGL renders at 1/RM_UI_SCALE and
// writePixelsRGB565 nearest-neighbour-upscales each flush to fill the native panel — the whole UI is
// RM_UI_SCALE x bigger, uniformly, with no per-element tuning. UITask's HAS_TDISPLAY_P4 LVGL res +
// the GT9895 touch scale must match (568/RM_UI_SCALE).
#ifndef RM_UI_SCALE
#define RM_UI_SCALE 2
#endif
#define RM_DPI_MHZ 60
#define RM_LANES 2
// Lane bit rate. LilyGo/Meck ship 1000 Mbps (confirmed against both sources 2026-07-31), and the
// panel needs only ~550 Mbps of it at DPI 60 MHz / RGB565 / 2 lanes -- the rest is eye margin.
// #167 made that margin matter: SD-card write bursts load the P4's on-chip LDO4 and disturb the
// sibling LDO3 that powers the DSI PHY, and at a 1 ns unit interval that wobble was enough to
// glitch the link -- the panel loses a frame to a whole-screen blue flash (proven by elimination:
// no flash with the card out or unwritten; framebuffer/underrun/host-error/DCS all measured clean;
// identical timings+init to the flash-free Meck build). A 750 Mbps derate was tried against #167 and changed nothing
// (the disturbance reaches the panel around the link, not through it), so this stays at the
// vendor value. Overridable per build: -DRM_BITRATE_MBPS=<n>.
#ifndef RM_BITRATE_MBPS
#define RM_BITRATE_MBPS 1000
#endif
#define RM_BITRATE RM_BITRATE_MBPS
#define RM_HSYNC 50
#define RM_HBP 150
#define RM_HFP 50
#define RM_VSYNC 40
#define RM_VBP 120
#define RM_VFP 80
// ESP32-P4 MIPI-DSI PHY internal LDO: channel 3 @ 1.83V. This is the tested LilyGo/Meck-P4 value —
// the "standard" 2500 mV left the RM69A10 dark (the panel's DSI rail wants 1.83 V here).
#define DSI_LDO_CHAN 3
#define DSI_LDO_MV 1830
static esp_ldo_channel_handle_t s_ldo = nullptr;
bool RM69A10Display::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("[RM69A10] 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). The
// tested Meck-P4 order is HIGH -> LOW -> HIGH with 200 ms settling, done AFTER the LDO and BEFORE
// the DSI bus. (An earlier attempt reset after the bus / with short delays kept the panel dark.)
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 = RM_LANES,
.phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, .lane_bit_rate_mbps = RM_BITRATE,
};
if (esp_lcd_new_dsi_bus(&bus_cfg, &dsi_bus) != ESP_OK) { Serial.println("[RM69A10] 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("[RM69A10] dbi_io fail"); return false; }
// 4. DPI (video) config.
esp_lcd_dpi_panel_config_t dpi_cfg = {
.virtual_channel = 0,
.dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT,
.dpi_clock_freq_mhz = RM_DPI_MHZ,
.pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565,
.num_fbs = 1,
.video_timing = {
.h_size = RM_W, .v_size = RM_H,
.hsync_pulse_width = RM_HSYNC, .hsync_back_porch = RM_HBP, .hsync_front_porch = RM_HFP,
.vsync_pulse_width = RM_VSYNC, .vsync_back_porch = RM_VBP, .vsync_front_porch = RM_VFP,
},
.flags = { .use_dma2d = true },
};
// 5. RM69A10 vendor panel (reset via the XL9535, done in powerOnSequence -> reset_gpio_num=-1).
rm69a10_vendor_config_t vendor = {
.init_cmds = nullptr, .init_cmds_size = 0, // use the driver's vendor_specific_init_default
.mipi_config = { .dsi_bus = dsi_bus, .dpi_config = &dpi_cfg, .lane_num = RM_LANES },
};
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_rm69a10(dbi_io, &dev, &_panel) != ESP_OK) { Serial.println("[RM69A10] new_panel fail"); return false; }
esp_lcd_panel_reset(_panel);
if (esp_lcd_panel_init(_panel) != ESP_OK) { Serial.println("[RM69A10] panel_init fail"); return false; }
// Blank the framebuffer BEFORE switching the panel on. It is freshly allocated PSRAM, so it holds
// garbage; turning the panel on first meant the DSI streamed that garbage to the screen until the
// clear below finished -- the colour flash testers see before the boot splash (#167). Write the
// buffer directly (memset + a cache flush to make it visible to the DSI's DMA) instead of pushing
// strips through draw_bitmap: it is one pass with the panel still dark, and it avoids a burst of
// 39 back-to-back DMA transfers into a buffer the panel is scanning out.
void* fb = nullptr;
if (esp_lcd_dpi_panel_get_frame_buffer(_panel, 1, &fb) == ESP_OK && fb) {
const size_t fb_bytes = (size_t)RM_W * RM_H * 2;
memset(fb, 0, fb_bytes);
esp_cache_msync(fb, fb_bytes, ESP_CACHE_MSYNC_FLAG_DIR_C2M);
}
esp_lcd_panel_disp_on_off(_panel, true);
// Brightness: the init array ships 0x51=0 (placeholder), and RM69A10 brightness is driven at
// runtime — Meck-P4 ramps set_rm69a10_brightness() after display-on. Keep the DBI IO handle so
// setBrightness() can drive DCS 0x51 live (CC slider); start at max so the AMOLED actually emits.
_dbi_io = dbi_io;
setBrightness(255);
_on = true;
// Signal each draw_bitmap's frame-buffer copy completion so writePixelsRGB565 can wait for it
// (single scratch/draw buffer reuse — see the note at s_flush_sem).
s_flush_sem = xSemaphoreCreateBinary();
esp_lcd_dpi_panel_event_callbacks_t cbs = {};
cbs.on_color_trans_done = rm69a10TransDone;
esp_lcd_dpi_panel_register_event_callbacks(_panel, &cbs, nullptr);
#if defined(TDP4_POKE_TRACE)
xTaskCreatePinnedToCore(dsiErrPollTask, "dsierr", 3072, nullptr, 3, nullptr, 1);
#endif
Serial.printf("[RM69A10] up %dx%d\n", RM_W, RM_H);
return true;
}
// Native-resolution blit for P4PanelPainter (Remote UI placeholder etc.): no LVGL upscale,
// exclusive end coords, and wait for the framebuffer copy before the caller reuses its buffer.
void RM69A10Display::writeNativeRGB565(int x, int y, int w, int h, const uint16_t* px) {
if (!_panel || !px || w <= 0 || h <= 0) return;
esp_lcd_panel_draw_bitmap(_panel, x, y, x + w, y + h, px);
if (s_flush_sem) xSemaphoreTake(s_flush_sem, pdMS_TO_TICKS(100));
}
void RM69A10Display::writePixelsRGB565(int x, int y, int w, int h, const uint16_t* pixels) {
if (!_panel || !pixels || w <= 0 || h <= 0) return;
#if defined(TDP4_FLUSH_TRACE)
// Opt-in flush tracer for whole-screen colour-flash reports (#167). OFF unless the build adds
// -DTDP4_FLUSH_TRACE, so it costs nothing normally. Logs any large, mostly-single-colour band
// with its RGB565 value: if the flash is LVGL painting something you see a burst carrying that
// colour (brand teal 0x15B6A6 -> 0x15B4); if the screen visibly flashes and nothing logs, the
// cause is below LVGL (panel/DSI) and hunting in the UI is wasted effort. Note when using it
// that the per-band uniformity scan slows the flush path, which can mask a timing-sensitive
// bug — always confirm a fix on a build WITHOUT this enabled.
{
const long px = (long)w * h;
if (px > 1500) { // one full LVGL band is 284*24 = 6816 px
const uint16_t c0 = pixels[0];
long same = 0;
for (long i = 0; i < px; i += 37) { if (pixels[i] == c0) ++same; } // sparse uniformity probe
const long probes = (px + 36) / 37;
// Log the colour whenever a large band is mostly ONE colour. A whole-screen flash shows
// up as a burst of these in the same millisecond range, all carrying the flash colour
// (brand teal 0x15B6A6 -> rgb565 0x15B4). Rate-limited so a normal repaint storm can't
// flood the console and perturb what we are measuring.
static uint32_t s_last_log = 0; static int s_burst = 0;
const uint32_t now_ms = (uint32_t)millis();
if (now_ms - s_last_log > 500) { s_burst = 0; s_last_log = now_ms; }
if (same * 10 >= probes * 9 && s_burst < 12) {
++s_burst;
Serial.printf("[FLUSHTRACE] %lu x=%d y=%d w=%d h=%d rgb565=0x%04X\n",
(unsigned long)now_ms, x, y, w, h, (unsigned)c0);
}
}
}
#endif
#if RM_UI_SCALE > 1
// Nearest-neighbour upscale the (half-res) LVGL band to the native panel: each source pixel becomes
// an RM_UI_SCALE x RM_UI_SCALE block. One expanded band -> one draw_bitmap (exclusive end coords).
const int S = RM_UI_SCALE, dw = w * S, dh = h * S;
// The scratch band lives in INTERNAL DMA RAM, not PSRAM. This panel is a DPI/DSI one with a
// single ~1.4 MB framebuffer that can only live in PSRAM, and the DSI DMA streams that
// framebuffer out to the glass CONTINUOUSLY. Every draw_bitmap below is a copy INTO that
// framebuffer, so with the scratch also in PSRAM a flush put three streams on the same bus at
// once: the scratch read, the framebuffer write, and the DSI's own read. Anything else that
// wants PSRAM at that moment — notably the priority-10 "lora_rx" drain task, which wakes on
// every received packet — can then starve the DSI read, and a DPI underrun shows up as a
// whole-screen colour flash for one frame. That is the P4 "flicker on every RX" report
// (issue #167): it appears on any screen, because it is a display-bus problem and nothing to
// do with what is being drawn. Internal RAM removes the scratch read from the PSRAM bus.
// Cost is bounded and small: one LVGL band is 284x24 (LV_DRAW_BUF_LINES), so upscaled 2x this
// is 568*48*2 = ~53 KB out of the P4's 768 KB SRAM. PSRAM stays as the fallback so a
// fragmented heap degrades to the old behaviour instead of dropping to the unscaled path.
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 RM69A10Display::setBrightness(uint8_t b) {
if (!_dbi_io) return;
#if defined(TDP4_POKE_TRACE)
// #167 hunt: DCS 0x51 is a low-power command injected into the LIVE video stream -- if the
// panel glitches a frame per command, these lines will pace the visible flashes exactly.
printf("[BRI] %lu val=%u\n", (unsigned long)millis(), b);
#endif
esp_lcd_panel_io_tx_param(_dbi_io, 0x51, &b, 1); // DCS SET_DISPLAY_BRIGHTNESS
}
#if defined(TDP4_POKE_TRACE)
void RM69A10Display::turnOn() { printf("[DSP] %lu on\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; }
void RM69A10Display::turnOff() { printf("[DSP] %lu off\n", (unsigned long)millis()); if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; }
#else
void RM69A10Display::turnOn() { if (_panel) esp_lcd_panel_disp_on_off(_panel, true); _on = true; }
void RM69A10Display::turnOff() { if (_panel) esp_lcd_panel_disp_on_off(_panel, false); _on = false; }
#endif
#endif // HAS_TDISPLAY_P4