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>
396 lines
12 KiB
C
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");
|
|
}
|