Files
rotaryThermo/main/drivers/display/display.c
T
mattandClaude Opus 5 d2e4c974dd Implement rotary thermostat firmware from PRD
ESP-IDF v5.3.2 firmware for the CrowPanel 2.1" rotary display. Builds clean;
nothing has been run on hardware yet.

Hardware verified against Elecrow's own source for this board and
cross-checked against their ESPHome config, per PRD 8.1. Three corrections to
the PRD's pinout, all documented in docs/HARDWARE.md:

  - touch is CST826 at 0x15, not CST816 (chip ID reads 0x11, not 0xB4)
  - the ST7701S needs a 3-wire SPI bus on GPIO 16/2/1 that the PRD omitted;
    without it the init sequence cannot be sent and the panel stays black
  - the backlight is GPIO6

Two further departures from the PRD, both forced by facts on the ground:

  - Transport is MQTT, not HTTP. zigbee2mqtt has no REST endpoint for live
    sensor state. Subscribing to the broker also gets retained topics, so
    every room's last reading arrives on connect rather than after a poll.
  - The furnace is 2-wire R/W with no C, not 3-wire. The board needs its own
    5V supply. The thermostat's 5 CPH setting is enforced as a minimum
    12-minute period between heat-call starts, which a minimum off-time alone
    does not achieve.

All of PRD section 5 is implemented in the relay layer itself: fail-open,
watchdog-gated output, max on-time, min off-time, cycle-rate limit, startup
lockout, and the stale-data fallback ladder down to open-loop freeze
protection. The one gap that cannot be closed in software — the PCF8574 not
resetting when the ESP32 does — is documented rather than papered over.

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

327 lines
10 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;
/* ------------------------------------------------------------ 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;
}
/* --------------------------------------------------------------- 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");
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];
}
}
esp_lcd_panel_draw_bitmap(s_panel, 0, 0, DISPLAY_H_RES, DISPLAY_V_RES, fb);
ESP_LOGI(TAG, "colour bars drawn — this is the PRD phase-2 check");
}