Files
planar-EL512.256-H/components/el512/el512_panel.cpp
T
mangavalkandClaude Opus 5.5 f3212ea969 el512: refresh health counters and quiet warnings
The panel driver counts ring refills, the largest gap between refills,
frame swaps, swap timeouts and I2S TX FIFO underruns (takeStats()). The
ESPHome component checks them every 10 s and logs a warning only when the
ESP32 side of the signal had a problem, plus a debug summary per minute.

Measured with Wi-Fi on: ~4600 refills/s, max gap < 0.4 ms (lead 1.7 ms),
no underruns or timeouts, so remaining blinks come from the signal being
disturbed on the wires (Wi-Fi transmit bursts), not from the refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:52:10 +02:00

359 lines
12 KiB
C++

#include "el512_panel.h"
#include <driver/gpio.h>
#include <esp_attr.h>
#include <esp_cpu.h>
#include <esp_heap_caps.h>
#include <esp_intr_alloc.h>
#include <esp_rom_gpio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <soc/gpio_sig_map.h>
#include <soc/i2s_reg.h>
#include <soc/i2s_struct.h>
#include <soc/periph_defs.h>
#include <string.h>
#if __has_include(<esp_private/periph_ctrl.h>)
#include <esp_private/periph_ctrl.h>
#else
#include <driver/periph_ctrl.h>
#endif
#if __has_include(<esp32/rom/lldesc.h>)
#include <esp32/rom/lldesc.h>
#else
#include <rom/lldesc.h>
#endif
namespace {
// I2S0 clock source when clka_en = 0: PLL_D2_CLK, 160 MHz whatever the CPU clock.
constexpr uint32_t I2S_SOURCE_CLOCK_HZ = 160000000;
constexpr uint32_t I2S_BYTE_RATE_HZ = EL512Panel::PIXEL_CLOCK_HZ * EL512Panel::BYTES_PER_CLOCK;
constexpr uint32_t I2S_CLKM_DIV = I2S_SOURCE_CLOCK_HZ / I2S_BYTE_RATE_HZ;
static_assert(I2S_SOURCE_CLOCK_HZ % I2S_BYTE_RATE_HZ == 0,
"byte rate must divide 160 MHz exactly");
static_assert(I2S_CLKM_DIV >= 2 && I2S_CLKM_DIV <= 255, "clkm_div_num out of range");
// In 8-bit LCD mode I2S0 puts its byte on DATA_OUT16..23 (bitluni ESP32Lib).
constexpr uint32_t I2S0_DATA_SIGNAL_BASE = I2S0O_DATA_OUT16_IDX;
// BCK runs at half the byte rate (measured: 2.5 MHz BCK at 5 MHz bytes), so each
// VCLK period spans two identical bytes. Uninverted, the rising edge landed on the
// byte-pair boundary and the image jittered; inverted, it sits mid-pair (verified
// on the panel).
constexpr bool INVERT_VCLK = true;
static_assert(EL512Panel::LINES_PER_CHUNK * EL512Panel::ROW_BYTES <= 4092, "descriptor too long");
static_assert(EL512Panel::ROW_BYTES % 4 == 0, "rows must be whole DMA words");
static_assert(EL512Panel::RING_LINES < EL512Panel::LINES_PER_FRAME, "ring longer than a frame");
constexpr uint16_t FB_ROW_BYTES = EL512Panel::WIDTH / 8;
constexpr uint16_t DATA_BYTES = EL512Panel::DATA_CLOCKS_PER_ROW * EL512Panel::BYTES_PER_CLOCK;
// One framebuffer byte (8 pixels = 4 clocks) -> 8 stream bytes as two DMA words,
// already in I2S byte order. Built once in begin(); read from the ISR.
DRAM_ATTR uint32_t s_lut[256][2];
void buildLut() {
for (int b = 0; b < 256; b++) {
uint8_t bytes[8];
for (int c = 0; c < 4; c++) {
// Clock c carries x = 2c and 2c+1. The datasheet numbers columns from 1, so
// x = 2c is column 2c+1 (odd -> TVID) and x = 2c+1 is column 2c+2 (even -> VID).
const uint8_t bits = (uint8_t)(b << (c * 2));
uint8_t v = EL512Panel::BIT_HS;
if (bits & 0x80) v |= EL512Panel::BIT_TVID;
if (bits & 0x40) v |= EL512Panel::BIT_VID;
bytes[(2 * c) ^ 2] = v;
bytes[(2 * c + 1) ^ 2] = v;
}
memcpy(s_lut[b], bytes, sizeof(bytes));
}
}
inline uint32_t IRAM_ATTR repeat4(uint8_t v) { return v * 0x01010101u; }
void resetI2S0() {
const uint32_t lcReset = I2S_IN_RST_M | I2S_OUT_RST_M | I2S_AHBM_RST_M | I2S_AHBM_FIFO_RST_M;
I2S0.lc_conf.val |= lcReset;
I2S0.lc_conf.val &= ~lcReset;
const uint32_t confReset =
I2S_RX_RESET_M | I2S_RX_FIFO_RESET_M | I2S_TX_RESET_M | I2S_TX_FIFO_RESET_M;
I2S0.conf.val |= confReset;
I2S0.conf.val &= ~confReset;
}
void routeOutput(int8_t pin, uint32_t signal, bool invert) {
if (pin < 0) return;
esp_rom_gpio_pad_select_gpio(pin);
gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT);
esp_rom_gpio_connect_out_signal(pin, signal, invert, false);
}
void setupGpioOutput(int8_t pin, bool level) {
if (pin < 0) return;
esp_rom_gpio_pad_select_gpio(pin);
gpio_set_direction((gpio_num_t)pin, GPIO_MODE_OUTPUT);
gpio_set_level((gpio_num_t)pin, level);
}
} // namespace
EL512Panel::EL512Panel(const EL512Pins& pins) : _pins(pins) {}
EL512Panel::~EL512Panel() { end(); }
bool EL512Panel::begin() {
if (_running) return true;
setupGpioOutput(_pins.enable, false);
setupGpioOutput(_pins.lowpwr_n, true);
_enabled = false;
_lowPower = false;
buildLut();
// Both framebuffers are read from the ISR, so they must be internal RAM.
_fb = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
_front = (uint8_t*)heap_caps_calloc(1, FRAMEBUFFER_BYTES, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
_ring = (uint8_t*)heap_caps_malloc(RING_BYTES, MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
if (!_fb || !_front || !_ring || !startI2S()) {
end();
return false;
}
_running = true;
setEnabled(true);
return true;
}
void EL512Panel::end() {
if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, 0);
_enabled = false;
stopI2S();
_running = false;
heap_caps_free(_fb);
_fb = nullptr;
heap_caps_free(_front);
_front = nullptr;
heap_caps_free(_ring);
_ring = nullptr;
}
void EL512Panel::setEnabled(bool on) {
_enabled = on;
if (_pins.enable >= 0) gpio_set_level((gpio_num_t)_pins.enable, on);
}
void EL512Panel::setLowPower(bool on) {
_lowPower = on;
if (_pins.lowpwr_n >= 0) gpio_set_level((gpio_num_t)_pins.lowpwr_n, !on);
}
void EL512Panel::setPixel(int16_t x, int16_t y, bool on) {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return;
uint8_t& b = _fb[(y * WIDTH + x) >> 3];
const uint8_t mask = 0x80 >> (x & 7);
if (on)
b |= mask;
else
b &= ~mask;
}
bool EL512Panel::getPixel(int16_t x, int16_t y) const {
if (!_fb || x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT) return false;
return _fb[(y * WIDTH + x) >> 3] & (0x80 >> (x & 7));
}
void EL512Panel::clear(bool on) {
if (_fb) memset(_fb, on ? 0xFF : 0x00, FRAMEBUFFER_BYTES);
}
void EL512Panel::show() {
if (!_fb || !_front) return;
if (!_running) {
memcpy(_front, _fb, FRAMEBUFFER_BYTES);
return;
}
// The ISR copies _fb to _front just before it encodes line 0 of a frame.
_swapPending = true;
for (int i = 0; i < 40 && _swapPending; i++) vTaskDelay(1);
if (_swapPending) _showTimeouts++;
}
EL512Panel::Stats EL512Panel::takeStats() {
Stats s{_refills, _maxGap, _swaps, _showTimeouts, _underruns};
_underruns = 0;
_refills = 0;
_maxGap = 0;
_swaps = 0;
_showTimeouts = 0;
return s;
}
void IRAM_ATTR EL512Panel::encodeRow(uint16_t line, uint8_t* out) const {
// VS is held high during the last blanking line so that the first HS high
// time ending after its rising edge is row 0 (datasheet: Video Data Input).
const uint32_t vs = repeat4(line == LINES_PER_FRAME - 1 ? BIT_VS : 0);
uint32_t* o = (uint32_t*)out;
if (line >= HEIGHT) {
for (uint16_t i = 0; i < ROW_BYTES / 4; i++) o[i] = vs;
return;
}
const uint8_t* src = _front + (size_t)line * FB_ROW_BYTES;
for (uint16_t k = 0; k < FB_ROW_BYTES; k++) {
o[2 * k] = s_lut[src[k]][0];
o[2 * k + 1] = s_lut[src[k]][1];
}
// Blanking clocks: HS low. Uniform and word-aligned, so no byte swap needed.
for (uint16_t i = DATA_BYTES / 4; i < ROW_BYTES / 4; i++) o[i] = vs;
}
void IRAM_ATTR EL512Panel::fillChunk(uint16_t chunk) {
uint16_t line = _chunkLine[chunk];
uint8_t* out = _ring + (size_t)chunk * LINES_PER_CHUNK * ROW_BYTES;
for (uint16_t i = 0; i < LINES_PER_CHUNK; i++) {
if (line == 0 && _swapPending) {
const uint32_t* s = (const uint32_t*)_fb;
uint32_t* d = (uint32_t*)_front;
for (size_t w = 0; w < FRAMEBUFFER_BYTES / 4; w++) d[w] = s[w];
_swapPending = false;
_swaps = _swaps + 1;
}
encodeRow(line, out + (size_t)i * ROW_BYTES);
line = (line + 1 == LINES_PER_FRAME) ? 0 : line + 1;
}
}
void IRAM_ATTR EL512Panel::isr(void* arg) {
auto* self = static_cast<EL512Panel*>(arg);
const uint32_t now = esp_cpu_get_cycle_count();
if (self->_lastIsrCycle != 0 && now - self->_lastIsrCycle > self->_maxGap) self->_maxGap = now - self->_lastIsrCycle;
self->_lastIsrCycle = now;
self->_refills = self->_refills + 1;
// Health counters (see takeStats): TX FIFO ran empty / transmitter hung.
const uint32_t raw = I2S0.int_raw.val;
if (raw & (I2S_TX_REMPTY_INT_RAW_M | I2S_TX_HUNG_INT_RAW_M)) {
self->_underruns = self->_underruns + 1;
I2S0.int_clr.val = I2S_TX_REMPTY_INT_CLR_M | I2S_TX_HUNG_INT_CLR_M;
}
const uint32_t status = I2S0.int_st.val;
I2S0.int_clr.val = status;
if (!(status & I2S_OUT_EOF_INT_ST_M)) return;
// The descriptor that just finished plays again RING_LINES lines later.
const lldesc_t* done = (const lldesc_t*)I2S0.out_eof_des_addr;
const uint16_t chunk = done - (const lldesc_t*)self->_desc;
if (chunk >= CHUNK_COUNT) return;
uint16_t next = self->_chunkLine[chunk] + RING_LINES;
if (next >= LINES_PER_FRAME) next -= LINES_PER_FRAME;
self->_chunkLine[chunk] = next;
self->fillChunk(chunk);
}
bool EL512Panel::startI2S() {
_desc = (lldesc_t*)heap_caps_calloc(CHUNK_COUNT, sizeof(lldesc_t), MALLOC_CAP_DMA);
if (!_desc) return false;
for (uint16_t i = 0; i < CHUNK_COUNT; i++) {
_chunkLine[i] = i * LINES_PER_CHUNK;
fillChunk(i);
lldesc_t& d = _desc[i];
d.size = LINES_PER_CHUNK * ROW_BYTES;
d.length = d.size;
d.owner = 1;
d.eof = 1; // EOF interrupt after every chunk
d.buf = _ring + (size_t)i * LINES_PER_CHUNK * ROW_BYTES;
d.qe.stqe_next = &_desc[(i + 1) % CHUNK_COUNT];
}
periph_module_enable(PERIPH_I2S0_MODULE);
resetI2S0();
// 8-bit parallel LCD mode (bitluni ESP32Lib setup; the i ^ 2 byte order in
// the lookup table depends on these FIFO/channel modes).
I2S0.conf2.val = 0;
I2S0.conf2.lcd_en = 1;
I2S0.conf2.lcd_tx_wrx2_en = 1;
I2S0.conf2.lcd_tx_sdx2_en = 0;
I2S0.sample_rate_conf.val = 0;
I2S0.sample_rate_conf.tx_bits_mod = 8;
I2S0.sample_rate_conf.tx_bck_div_num = 1;
// PLL_D2_CLK / (N + b/a) with b = 0: 160 MHz / 16 = 10 MHz bytes -> 5 MHz BCK.
I2S0.clkm_conf.val = 0;
I2S0.clkm_conf.clka_en = 0;
I2S0.clkm_conf.clkm_div_num = I2S_CLKM_DIV;
I2S0.clkm_conf.clkm_div_a = 1;
I2S0.clkm_conf.clkm_div_b = 0;
I2S0.fifo_conf.val = 0;
I2S0.fifo_conf.tx_fifo_mod_force_en = 1;
I2S0.fifo_conf.tx_fifo_mod = 1;
I2S0.fifo_conf.tx_data_num = 32;
I2S0.fifo_conf.dscr_en = 1;
I2S0.conf1.val = 0;
I2S0.conf1.tx_stop_en = 0;
I2S0.conf1.tx_pcm_bypass = 1;
I2S0.conf_chan.val = 0;
I2S0.conf_chan.tx_chan_mod = 1;
I2S0.conf.tx_right_first = 1;
I2S0.conf.tx_msb_right = 0;
I2S0.conf.tx_msb_shift = 0;
I2S0.conf.tx_mono = 0;
I2S0.conf.tx_short_sync = 0;
I2S0.timing.val = 0;
// Stream bit n -> DATA_OUT(16 + n); BCK -> VCLK.
const int8_t dataPins[] = {_pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (uint32_t bit = 0; bit < sizeof(dataPins); bit++) {
routeOutput(dataPins[bit], I2S0_DATA_SIGNAL_BASE + bit, false);
}
routeOutput(_pins.vclk, I2S0O_BCK_OUT_IDX, INVERT_VCLK);
// EOF interrupt refills the ring. IRAM so it keeps running during flash writes.
I2S0.int_ena.val = 0;
I2S0.int_clr.val = I2S0.int_raw.val;
if (esp_intr_alloc(ETS_I2S0_INTR_SOURCE, ESP_INTR_FLAG_IRAM, &EL512Panel::isr, this,
&_intr) != ESP_OK) {
_intr = nullptr;
return false;
}
I2S0.int_ena.out_eof = 1;
// Start the DMA; it loops over the chain until stopI2S().
I2S0.lc_conf.val = I2S_OUT_DATA_BURST_EN_M | I2S_OUTDSCR_BURST_EN_M;
I2S0.out_link.addr = (uint32_t)&_desc[0];
I2S0.out_link.start = 1;
I2S0.conf.tx_start = 1;
return true;
}
void EL512Panel::stopI2S() {
if (!_desc) return; // never started
I2S0.int_ena.val = 0;
if (_intr) {
esp_intr_free(_intr);
_intr = nullptr;
}
I2S0.conf.tx_start = 0;
I2S0.out_link.stop = 1;
resetI2S0();
periph_module_disable(PERIPH_I2S0_MODULE);
// Hand the pins back to GPIO and park them low.
const int8_t pins[] = {_pins.vclk, _pins.vid, _pins.tvid, _pins.hs, _pins.vs};
for (int8_t pin : pins) {
if (pin < 0) continue;
esp_rom_gpio_connect_out_signal(pin, SIG_GPIO_OUT_IDX, false, false);
gpio_set_level((gpio_num_t)pin, 0);
}
heap_caps_free(_desc);
_desc = nullptr;
}