Files
rotaryThermo/main/drivers/display/display.c
T
mattandClaude Opus 5 4c7b120473 Fix TWDT starvation, vsync race, and UI responsiveness from review
Three bugs and two UX defects found on review, plus hardening:

- The control loop fed the task watchdog then slept exactly the watchdog
  period (both 10s), tripping the TWDT fractionally late on every single
  iteration. Sleep is now sliced 1s with a reset per slice. Also set
  CONFIG_ESP_TASK_WDT_PANIC=y - the sdkconfig comment claimed a hung task
  panics the chip, but nothing actually made that true.

- The LVGL flush called draw_bitmap (which only queues the framebuffer
  switch) and released LVGL immediately, letting it render the next frame
  into the buffer still being scanned out - the exact PRD 8.2 race the
  double-buffer design exists to prevent. display_present_frame() now
  blocks on a vsync handshake until the swap has latched.

- WiFi reconnect slept up to 30s inside the system event handler, stalling
  all queued WiFi/IP events. Moved to an esp_timer.

- The setpoint label, arc, and mode/room lines rendered the control loop's
  copy of state, which refreshes every 10s - every knob detent looked dead
  for up to 10 seconds. The UI now reads settings live; when the fallback
  ladder clamps the setpoint it shows both values. Room-select focus now
  shows which room is being selected.

- z2m ISO_8601_local timestamps carry a UTC offset the parser ignored,
  which would shift apparent reading age by the local offset (5-6h in the
  US) and permanently trip the fallback ladder. Offset is now honoured.

- Suppressed the cosmetic "control loop stopped" fault in the window
  between relay_init and the control task's first call-in at boot.

- Explicit pullups on the encoder A/B lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:05:51 -07:00

396 lines
12 KiB
C

#include "display.h"
#include <string.h>
#include "driver/ledc.h"
#include "esp_check.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_io_additions.h"
#include "esp_lcd_panel_rgb.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "pcf8574.h"
#include "st7701_init.h"
static const char *TAG = "display";
/* ---- Pinout: docs/HARDWARE.md, verified against Elecrow's own example ---- */
#define PIN_LCD_CS 16
#define PIN_LCD_SCK 2
#define PIN_LCD_SDA 1
#define PIN_LCD_DE 40
#define PIN_LCD_HSYNC 15
#define PIN_LCD_VSYNC 7
#define PIN_LCD_PCLK 41
#define PIN_BACKLIGHT 6
/*
* SPI mode 0. Arduino_GFX's Arduino_SWSPI::begin() drives SCK low at init and
* shifts on the rising edge. Elecrow's ESPHome config says MODE3, but that
* file also documents its own colours as wrong.
*
* If the panel is black on first bring-up, this is the first thing to try
* changing — before touching the init sequence, and long before rewriting
* anything else.
*/
#define ST7701_SPI_MODE 0
/*
* Timings from the vendor's Arduino example. The ESPHome example uses
* 20/10/10 and 8/10/10 instead; either set should work. Swap if the image
* rolls or shears.
*/
#define LCD_PCLK_HZ (18 * 1000 * 1000)
#define LCD_HSYNC_FRONT 10
#define LCD_HSYNC_PULSE 4
#define LCD_HSYNC_BACK 20
#define LCD_VSYNC_FRONT 10
#define LCD_VSYNC_PULSE 4
#define LCD_VSYNC_BACK 20
#define BACKLIGHT_LEDC_TIMER LEDC_TIMER_0
#define BACKLIGHT_LEDC_CHANNEL LEDC_CHANNEL_0
#define BACKLIGHT_LEDC_MODE LEDC_LOW_SPEED_MODE
#define BACKLIGHT_RES LEDC_TIMER_10_BIT
static esp_lcd_panel_handle_t s_panel;
static void *s_fb0, *s_fb1;
/*
* Vsync handshake for tear-free buffer swaps (the pattern from Espressif's
* own RGB-panel LVGL example). present_frame gives s_sem_present_pending after
* queueing the swap; the vsync ISR converts that into s_sem_swap_done only
* when a present is actually waiting, so a stale vsync can never satisfy a
* later wait.
*/
static SemaphoreHandle_t s_sem_present_pending;
static SemaphoreHandle_t s_sem_swap_done;
/* ------------------------------------------------------------ backlight -- */
static esp_err_t backlight_init(void)
{
ledc_timer_config_t timer = {
.speed_mode = BACKLIGHT_LEDC_MODE,
.timer_num = BACKLIGHT_LEDC_TIMER,
.duty_resolution = BACKLIGHT_RES,
.freq_hz = 5000,
.clk_cfg = LEDC_AUTO_CLK,
};
ESP_RETURN_ON_ERROR(ledc_timer_config(&timer), TAG, "ledc timer");
ledc_channel_config_t ch = {
.gpio_num = PIN_BACKLIGHT,
.speed_mode = BACKLIGHT_LEDC_MODE,
.channel = BACKLIGHT_LEDC_CHANNEL,
.timer_sel = BACKLIGHT_LEDC_TIMER,
.duty = 0,
.hpoint = 0,
};
return ledc_channel_config(&ch);
}
void display_set_backlight(uint8_t percent)
{
if (percent > 100) {
percent = 100;
}
uint32_t max = (1u << BACKLIGHT_RES) - 1;
uint32_t duty = (max * percent) / 100;
ledc_set_duty(BACKLIGHT_LEDC_MODE, BACKLIGHT_LEDC_CHANNEL, duty);
ledc_update_duty(BACKLIGHT_LEDC_MODE, BACKLIGHT_LEDC_CHANNEL);
}
/* --------------------------------------------------------- reset dance --- */
/*
* Reproduces the vendor example's setup() ordering exactly, including the P3
* ("LCD power") pulse. Despite the label, P3 goes high before the reset and
* low again once the backlight is running, which only makes sense as blanking
* to hide init garbage. Copied rather than reasoned about — see
* docs/HARDWARE.md.
*
* P1 is deliberately untouched throughout: it is the relay.
*/
static esp_err_t panel_power_and_reset(void)
{
ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_POWER, true), TAG, "p3");
vTaskDelay(pdMS_TO_TICKS(100));
ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_RST, true), TAG, "p4 hi");
vTaskDelay(pdMS_TO_TICKS(100));
ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_RST, false), TAG, "p4 lo");
vTaskDelay(pdMS_TO_TICKS(120));
ESP_RETURN_ON_ERROR(pcf8574_write(PCF_PIN_LCD_RST, true), TAG, "p4 hi2");
vTaskDelay(pdMS_TO_TICKS(120));
return ESP_OK;
}
/*
* Runs from the RGB panel's frame ISR. CONFIG_LCD_RGB_ISR_IRAM_SAFE=y requires
* this to live in IRAM.
*/
IRAM_ATTR static bool on_vsync_cb(esp_lcd_panel_handle_t panel,
const esp_lcd_rgb_panel_event_data_t *edata,
void *user_ctx)
{
(void)panel;
(void)edata;
(void)user_ctx;
BaseType_t hpw = pdFALSE;
if (xSemaphoreTakeFromISR(s_sem_present_pending, &hpw) == pdTRUE) {
xSemaphoreGiveFromISR(s_sem_swap_done, &hpw);
}
return hpw == pdTRUE;
}
esp_err_t display_present_frame(void *fb)
{
if (!s_panel || !fb) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_lcd_panel_draw_bitmap(s_panel, 0, 0,
DISPLAY_H_RES, DISPLAY_V_RES, fb);
if (err != ESP_OK) {
return err;
}
/* Wait for the vsync at which the queued switch latches. The timeout is a
* guard so a stalled panel degrades to tearing instead of wedging the
* LVGL task; one frame at these timings is ~15ms. */
xSemaphoreGive(s_sem_present_pending);
if (xSemaphoreTake(s_sem_swap_done, pdMS_TO_TICKS(100)) != pdTRUE) {
static bool warned;
if (!warned) {
warned = true;
ESP_LOGW(TAG, "vsync wait timed out — panel not producing frames?");
}
}
return ESP_OK;
}
/* --------------------------------------------------------------- init ---- */
static esp_err_t send_init_sequence(void)
{
esp_lcd_panel_io_handle_t io = NULL;
esp_lcd_panel_io_3wire_spi_config_t io_cfg = {
.line_config = {
.cs_io_type = IO_TYPE_GPIO,
.cs_gpio_num = PIN_LCD_CS,
.scl_io_type = IO_TYPE_GPIO,
.scl_gpio_num = PIN_LCD_SCK,
.sda_io_type = IO_TYPE_GPIO,
.sda_gpio_num = PIN_LCD_SDA,
.io_expander = NULL,
},
.expect_clk_speed = PANEL_IO_3WIRE_SPI_CLK_MAX,
.spi_mode = ST7701_SPI_MODE,
.lcd_cmd_bytes = 1,
.lcd_param_bytes = 1,
.flags = {
.use_dc_bit = 1, /* 9-bit frames: DC bit then 8 data bits */
.dc_zero_on_data = 0, /* DC=0 command, DC=1 data (ST7701 order) */
.lsb_first = 0,
.cs_high_active = 0,
/*
* Keep CS inactive after we delete this IO. The RGB bus takes over
* the panel from here and must not see CS wander.
*/
.del_keep_cs_inactive = 1,
},
};
ESP_RETURN_ON_ERROR(esp_lcd_new_panel_io_3wire_spi(&io_cfg, &io),
TAG, "3wire spi io");
esp_err_t err = ESP_OK;
for (size_t i = 0; i < ST7701_INIT_SEQ_LEN; i++) {
const st7701_cmd_t *c = &st7701_init_seq[i];
err = esp_lcd_panel_io_tx_param(io, c->cmd,
c->data_len ? c->data : NULL,
c->data_len);
if (err != ESP_OK) {
ESP_LOGE(TAG, "init cmd %u (0x%02X) failed: %s",
(unsigned)i, c->cmd, esp_err_to_name(err));
break;
}
if (c->delay_ms) {
vTaskDelay(pdMS_TO_TICKS(c->delay_ms));
}
}
/* The init bus is single-use; the RGB peripheral owns the panel now. */
esp_lcd_panel_io_del(io);
if (err == ESP_OK) {
ESP_LOGI(TAG, "ST7701S init sequence sent (%u commands)",
(unsigned)ST7701_INIT_SEQ_LEN);
}
return err;
}
static esp_err_t rgb_panel_init(void)
{
esp_lcd_rgb_panel_config_t cfg = {
.clk_src = LCD_CLK_SRC_PLL160M,
.timings = {
.pclk_hz = LCD_PCLK_HZ,
.h_res = DISPLAY_H_RES,
.v_res = DISPLAY_V_RES,
.hsync_pulse_width = LCD_HSYNC_PULSE,
.hsync_back_porch = LCD_HSYNC_BACK,
.hsync_front_porch = LCD_HSYNC_FRONT,
.vsync_pulse_width = LCD_VSYNC_PULSE,
.vsync_back_porch = LCD_VSYNC_BACK,
.vsync_front_porch = LCD_VSYNC_FRONT,
.flags = {
/* Elecrow's ESPHome config sets pclk_inverted, i.e. data is
* latched on the falling edge. */
.pclk_active_neg = 1,
},
},
.data_width = 16,
.bits_per_pixel = 16,
.num_fbs = 2,
/*
* No bounce buffers — this is what makes runtime NVS writes safe.
* See the header comment. Do not "optimise" this to a non-zero value
* without re-reading PRD 8.2.
*/
.bounce_buffer_size_px = 0,
.psram_trans_align = 64,
.hsync_gpio_num = PIN_LCD_HSYNC,
.vsync_gpio_num = PIN_LCD_VSYNC,
.de_gpio_num = PIN_LCD_DE,
.pclk_gpio_num = PIN_LCD_PCLK,
.disp_gpio_num = -1,
/*
* RGB565 bit order: data[0..4] = blue, [5..10] = green, [11..15] = red.
* If red and blue come out swapped, swap these two blocks — the panel's
* own MADCTL is already 0x00 (RGB) from the init sequence.
*/
.data_gpio_nums = {
5, 45, 48, 47, 21, /* B0..B4 */
14, 13, 12, 11, 10, 9, /* G0..G5 */
46, 3, 8, 18, 17, /* R0..R4 */
},
.flags = {
.fb_in_psram = 1,
},
};
ESP_RETURN_ON_ERROR(esp_lcd_new_rgb_panel(&cfg, &s_panel), TAG, "rgb panel");
ESP_RETURN_ON_ERROR(esp_lcd_panel_reset(s_panel), TAG, "panel reset");
ESP_RETURN_ON_ERROR(esp_lcd_panel_init(s_panel), TAG, "panel init");
ESP_RETURN_ON_ERROR(
esp_lcd_rgb_panel_get_frame_buffer(s_panel, 2, &s_fb0, &s_fb1),
TAG, "get framebuffers");
s_sem_present_pending = xSemaphoreCreateBinary();
s_sem_swap_done = xSemaphoreCreateBinary();
if (!s_sem_present_pending || !s_sem_swap_done) {
return ESP_ERR_NO_MEM;
}
esp_lcd_rgb_panel_event_callbacks_t cbs = {
.on_vsync = on_vsync_cb,
};
ESP_RETURN_ON_ERROR(
esp_lcd_rgb_panel_register_event_callbacks(s_panel, &cbs, NULL),
TAG, "vsync callback");
ESP_LOGI(TAG, "RGB panel up: %dx%d @ %d MHz, fb0=%p fb1=%p",
DISPLAY_H_RES, DISPLAY_V_RES, LCD_PCLK_HZ / 1000000, s_fb0, s_fb1);
return ESP_OK;
}
esp_err_t display_init(void)
{
if (!pcf8574_is_ready()) {
ESP_LOGE(TAG, "expander not ready — cannot sequence panel power");
return ESP_ERR_INVALID_STATE;
}
ESP_RETURN_ON_ERROR(panel_power_and_reset(), TAG, "power/reset");
ESP_RETURN_ON_ERROR(send_init_sequence(), TAG, "init sequence");
ESP_RETURN_ON_ERROR(rgb_panel_init(), TAG, "rgb");
ESP_RETURN_ON_ERROR(backlight_init(), TAG, "backlight");
/* Clear both buffers before the backlight comes up, so the first thing on
* screen is black rather than whatever was in PSRAM. */
size_t fb_bytes = (size_t)DISPLAY_H_RES * DISPLAY_V_RES * 2;
memset(s_fb0, 0, fb_bytes);
memset(s_fb1, 0, fb_bytes);
display_set_backlight(80);
/* Vendor ordering: P3 low once the backlight is running. */
pcf8574_write(PCF_PIN_LCD_POWER, false);
return ESP_OK;
}
esp_lcd_panel_handle_t display_panel(void)
{
return s_panel;
}
esp_err_t display_get_framebuffers(void **fb0, void **fb1)
{
if (!s_panel) {
return ESP_ERR_INVALID_STATE;
}
if (fb0) {
*fb0 = s_fb0;
}
if (fb1) {
*fb1 = s_fb1;
}
return ESP_OK;
}
void display_test_pattern(void)
{
static const uint16_t bars[] = {
0xF800, /* red */
0x07E0, /* green */
0x001F, /* blue */
0xFFFF, /* white */
0xFFE0, /* yellow */
0x07FF, /* cyan */
0xF81F, /* magenta*/
0x0000, /* black */
};
const int n = sizeof(bars) / sizeof(bars[0]);
const int bar_w = DISPLAY_H_RES / n;
uint16_t *fb = (uint16_t *)s_fb0;
if (!fb) {
ESP_LOGE(TAG, "no framebuffer");
return;
}
for (int y = 0; y < DISPLAY_V_RES; y++) {
for (int x = 0; x < DISPLAY_H_RES; x++) {
int i = x / bar_w;
fb[y * DISPLAY_H_RES + x] = bars[i < n ? i : n - 1];
}
}
display_present_frame(fb);
ESP_LOGI(TAG, "colour bars drawn — this is the PRD phase-2 check");
}