commit 0ef2017d18fa5c210f3ccec63dc1c50248e1c7da Author: matt_ed Date: Mon Aug 3 20:31:28 2026 -0700 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83b4422 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# ESP-IDF build output +build/ +managed_components/ +dependencies.lock + +# Generated config -- sdkconfig.defaults IS tracked, sdkconfig is not +sdkconfig +sdkconfig.old + +# Local credentials: WiFi password + Ambient Weather API keys. +# Copy app_config.h.example to app_config.h and fill it in. +main/app_config.h + +.vscode/ +*.swp diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..30e9c4d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +project(thermostat-hw) diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd6052f --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Thermostat HMI + +ESP-IDF firmware for the **Elecrow CrowPanel 7" Advance** (ESP32-S3-WROOM-1-N16R8, +16MB flash + 8MB octal PSRAM, 800×480 IPS, GT911 capacitive touch). + +> **Board variant matters.** Elecrow sells a *Basic* CrowPanel 7" and an +> *Advance* CrowPanel 7". They share a form factor and almost nothing else: +> every RGB data pin, every sync pin, the pixel clock, the porches, the sync +> polarities and the touch I2C pins are different, and the Advance drives its +> backlight over I2C instead of from a GPIO. This firmware targets the +> **Advance**. Building it against Basic documentation yields a black screen +> and a silent I2C bus. + +Currently displays outdoor conditions from an Ambient Weather personal weather +station. Thermostat control (setpoint, HVAC relays, scheduling) is not +implemented yet. + +## Components + +| Path | Purpose | +|------|---------| +| `main/drivers/display` | RGB parallel panel via `esp_lcd`, double-buffered in PSRAM | +| `main/drivers/touch` | GT911 capacitive touch over I2C, registered as an LVGL input device | +| `main/ui/lvgl_port.c` | LVGL tick source, service task, and locking | +| `main/ui/ui_manager.c` | Screen layout and update entry points | +| `main/wifi` | WiFi station bring-up | +| `main/api` | Ambient Weather REST client | +| `main/utils` | Small helpers | + +## Setup + +```bash +cp main/app_config.h.example main/app_config.h +# edit main/app_config.h: WiFi credentials + Ambient Weather keys +``` + +`main/app_config.h` is gitignored — it holds your WiFi password and API keys. + +## Build & Flash + +```bash +idf.py set-target esp32s3 +idf.py build flash monitor +``` + +Requires ESP-IDF **v5.3 or newer** (the code uses the `i2c_master` driver API +and `dma_burst_size`). + +## Hardware (Advance) + +### RGB panel (16-bit RGB565) + +| Signal | GPIO | +|--------|------| +| DE | 42 | +| VSYNC | 41 | +| HSYNC | 40 | +| PCLK | 39 | +| R0–R4 | 7, 17, 18, 3, 46 | +| G0–G5 | 9, 10, 11, 12, 13, 14 | +| B0–B4 | 21, 47, 48, 45, 38 | + +Timings: 18 MHz pixel clock, HFP 8 / HPW 4 / HBP 8, VFP 8 / VPW 4 / VBP 8. +Both syncs idle high; PCLK is `pclk_active_neg`. + +### I2C bus — GPIO 15 (SDA) / 16 (SCL) + +| Address | Device | +|---------|--------| +| 0x30 | STC8H1K28 board controller — backlight **and GT911 reset** | +| 0x51 | RTC | +| 0x5D | GT911 touch | + +The GT911 has **no ESP32 reset or interrupt GPIO** on this board. The +STC8H1K28 holds it in reset, so `backlight_init()` must run before +`touch_init()` or the touch controller NACKs every address. + +STC8H1K28 protocol is single raw bytes, no register addressing: `0x19` wake, +then `0x10` and `0x18` to configure and release the GT911. Brightness is +`0x00`–`0xF5` **inverted** (`0x00` = full) on v1.3; v1.2 uses a narrower +`0x05`–`0x10` range instead. + +## Display architecture + +Two 800×480×2 framebuffers in PSRAM, LVGL in `RENDER_MODE_DIRECT`. LVGL draws +into whichever buffer the LCD is *not* scanning; the last flush of a refresh +hands that buffer to the panel, which switches to it. Nothing ever writes into +the buffer being displayed — copying into the live framebuffer races the LCD's +own reads, and the resulting corrupt pixels persist indefinitely because LVGL +does not know they are wrong and never reinvalidates them. + +A 16-scanline bounce buffer stages each chunk in internal RAM. 20 scanlines +would be better but needs 64KB of internal RAM and fails to allocate once +WiFi is up. + +### Known limitation: occasional left-edge artefacts + +Short stale lines sometimes appear at the left edge, most often right after a +weather fetch. The panel needs ~34 MB/s sustained from PSRAM at the 18MHz +pixel clock, and 80MHz octal PSRAM is marginal for that. When the bounce +refill ISR runs late — WiFi interrupt activity is enough — the panel scans out +whatever was left in the buffer. + +The pixel clock is not a usable lever: below roughly 40Hz refresh (about +15MHz) the panel stops locking and the image rolls vertically instead. + +The real fix is 120MHz PSRAM, which is what the vendor's Arduino build uses +(`-DCONFIG_SPIRAM_SPEED_120M=1`). On ESP-IDF 5.3 that is not safely reachable: +selecting it raises the shared MSPI core clock, and IDF only ships flash timing +tables pairing 120MHz PSRAM with 120MHz flash. Flash above 80MHz requires +High Performance Mode and a DC-aware bootloader. + +## Gotchas + +- **LVGL does not bubble events to parents.** An event handler on the screen + object never sees taps that land on a child widget, which looks exactly like + dead touch zones. The touch readout polls the input device from an + `lv_timer` instead. +- **`lv_label_set_text()` invalidates unconditionally**, even when the text is + identical — so setting an unchanged value still forces a repaint. All label + updates go through `label_set_if_changed()`. +- **WiFi must be brought up before `display_init()`.** The bounce refill ISR + reads PSRAM, which shares the cache with flash; an NVS write while the panel + is running faults it with *"Cache disabled but cached memory region + accessed"*. + +## Memory notes + +Two 800×480×2 framebuffers (750 KB each) live in PSRAM. The 8 MB **octal** +PSRAM at 80 MHz is required — QSPI cannot sustain the RGB refresh. + +Do **not** enable `CONFIG_SPIRAM_FETCH_INSTRUCTIONS` / `CONFIG_SPIRAM_RODATA`: +they contend with the panel for PSRAM bandwidth. Do **not** enable the RGB +bounce buffer either — its refill ISR reads PSRAM, which shares the cache with +flash on the S3, so any NVS write faults it with *"Cache disabled but cached +memory region accessed"*. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..d96861d --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,34 @@ +idf_component_register( + SRCS + "main.c" + "wifi/wifi_manager.c" + "drivers/i2c_bus.c" + "drivers/backlight/backlight.c" + "drivers/display/display_rgb.c" + "drivers/touch/touch_gt911.c" + "ui/lvgl_port.c" + "ui/ui_manager.c" + "api/ambient_weather.c" + "utils/utils.c" + INCLUDE_DIRS + "." + "wifi" + "drivers" + "drivers/backlight" + "drivers/display" + "drivers/touch" + "ui" + "api" + "utils" + REQUIRES + lvgl + esp_wifi + esp_http_client + esp-tls + json + esp_driver_i2c + esp_driver_gpio + esp_lcd + esp_timer + nvs_flash +) diff --git a/main/api/ambient_weather.c b/main/api/ambient_weather.c new file mode 100644 index 0000000..94bb9f5 --- /dev/null +++ b/main/api/ambient_weather.c @@ -0,0 +1,189 @@ +/* + * Ambient Weather REST API client. + * + * GET https://rt.ambientweather.net/v1/devices/{macAddress} + * ?apiKey=...&applicationKey=...&limit=1 + * + * Returns a JSON array of data records, newest first. + */ + +#include +#include +#include +#include + +#include "cJSON.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" + +#include "app_config.h" +#include "ambient_weather.h" + +static const char *TAG = "ambient_weather"; + +#define WEATHER_URL_MAX 320 +#define WEATHER_RESP_MAX 4096 +/* A first TLS handshake on this hardware can take well over 10s. */ +#define WEATHER_TIMEOUT_MS 30000 + +static char resp_buf[WEATHER_RESP_MAX]; +static size_t resp_len = 0; + +static esp_err_t http_event_handler(esp_http_client_event_t *evt) +{ + switch (evt->event_id) { + case HTTP_EVENT_ON_CONNECTED: + resp_len = 0; + break; + + case HTTP_EVENT_ON_DATA: + /* + * Accumulate into a fixed buffer. The response is chunked, so + * Content-Length is often -1 -- sizing an allocation from it (as this + * previously did) yields a zero-length buffer and a heap overflow. + */ + if (resp_len + evt->data_len < sizeof(resp_buf)) { + memcpy(resp_buf + resp_len, evt->data, evt->data_len); + resp_len += evt->data_len; + } else { + ESP_LOGW(TAG, "Response exceeds %d bytes, truncating", WEATHER_RESP_MAX); + } + break; + + default: + break; + } + + return ESP_OK; +} + +esp_err_t weather_init(void) +{ + if (AMBIENT_WEATHER_API_KEY[0] == '\0' || + AMBIENT_WEATHER_APP_KEY[0] == '\0' || + AMBIENT_WEATHER_DEVICE_ID[0] == '\0') { + ESP_LOGW(TAG, "API credentials not set in main/app_config.h -- " + "weather updates will be skipped"); + return ESP_ERR_INVALID_STATE; + } + + return ESP_OK; +} + +static void parse_record(const cJSON *record, weather_data_t *data) +{ + const cJSON *tempf = cJSON_GetObjectItemCaseSensitive(record, "tempf"); + if (cJSON_IsNumber(tempf)) { + data->temp_f = (float)tempf->valuedouble; + data->has_temp = true; + } + + const cJSON *humidity = cJSON_GetObjectItemCaseSensitive(record, "humidity"); + if (cJSON_IsNumber(humidity)) { + data->humidity = (float)humidity->valuedouble; + data->has_humidity = true; + } + + const cJSON *wind = cJSON_GetObjectItemCaseSensitive(record, "windspeedmph"); + if (cJSON_IsNumber(wind)) { + data->wind_mph = (float)wind->valuedouble; + data->has_wind = true; + } +} + +esp_err_t weather_update(weather_data_t *data) +{ + if (data == NULL) { + return ESP_ERR_INVALID_ARG; + } + + /* Caller may reuse the struct across polls; start from a known state so a + * partial response cannot leave stale values looking fresh. */ + memset(data, 0, sizeof(*data)); + + char url[WEATHER_URL_MAX]; + const int written = snprintf(url, sizeof(url), + "https://rt.ambientweather.net/v1/devices/%s" + "?apiKey=%s&applicationKey=%s&limit=1", + AMBIENT_WEATHER_DEVICE_ID, AMBIENT_WEATHER_API_KEY, AMBIENT_WEATHER_APP_KEY); + + if (written < 0 || written >= (int)sizeof(url)) { + ESP_LOGE(TAG, "URL truncated -- check credential lengths"); + return ESP_ERR_INVALID_SIZE; + } + + const esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = WEATHER_TIMEOUT_MS, + .event_handler = http_event_handler, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (client == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_err_t err = esp_http_client_perform(client); + if (err != ESP_OK) { + ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + return err; + } + + const int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (status != 200) { + ESP_LOGE(TAG, "HTTP %d from Ambient Weather", status); + return ESP_FAIL; + } + + if (resp_len == 0) { + ESP_LOGE(TAG, "Empty response"); + return ESP_FAIL; + } + + resp_buf[resp_len] = '\0'; + + cJSON *root = cJSON_Parse(resp_buf); + if (root == NULL) { + ESP_LOGE(TAG, "Failed to parse JSON response"); + return ESP_FAIL; + } + + err = ESP_FAIL; + if (cJSON_IsArray(root) && cJSON_GetArraySize(root) > 0) { + parse_record(cJSON_GetArrayItem(root, 0), data); + if (data->has_temp) { + data->last_update = time(NULL); + err = ESP_OK; + } else { + ESP_LOGW(TAG, "Response contained no tempf field"); + } + } else { + ESP_LOGW(TAG, "Response was not a non-empty array"); + } + + cJSON_Delete(root); + return err; +} + +void weather_get_string(const weather_data_t *data, char *buf, size_t len) +{ + if (data == NULL || buf == NULL || len == 0) { + return; + } + + int n = snprintf(buf, len, "%.1f°F", data->has_temp ? data->temp_f : 0.0f); + + if (data->has_humidity && n > 0 && (size_t)n < len) { + n += snprintf(buf + n, len - n, " · %.0f%% RH", data->humidity); + } + + if (data->has_wind && n > 0 && (size_t)n < len) { + snprintf(buf + n, len - n, " · %.1f mph", data->wind_mph); + } +} diff --git a/main/api/ambient_weather.h b/main/api/ambient_weather.h new file mode 100644 index 0000000..f2dd0fc --- /dev/null +++ b/main/api/ambient_weather.h @@ -0,0 +1,32 @@ +#ifndef AMBIENT_WEATHER_H +#define AMBIENT_WEATHER_H + +#include +#include +#include +#include + +#include "esp_err.h" + +/* + * Fields mirror the Ambient Weather device data spec: + * https://github.com/ambient-weather/api-docs/wiki/Device-Data-Specs + * + * Note the API reports no textual "condition" or "icon" -- it is a personal + * weather station feed, not a forecast service. + */ +typedef struct { + float temp_f; + float humidity; /* percent */ + float wind_mph; + bool has_temp; + bool has_humidity; + bool has_wind; + time_t last_update; +} weather_data_t; + +esp_err_t weather_init(void); +esp_err_t weather_update(weather_data_t *data); +void weather_get_string(const weather_data_t *data, char *buf, size_t len); + +#endif diff --git a/main/app_config.h.example b/main/app_config.h.example new file mode 100644 index 0000000..c61b9a6 --- /dev/null +++ b/main/app_config.h.example @@ -0,0 +1,19 @@ +/* + * Copy this file to app_config.h and fill in your own values. + * app_config.h is gitignored -- it holds credentials. + */ +#ifndef APP_CONFIG_H +#define APP_CONFIG_H + +/* From https://ambientweather.net/account + * DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF" */ +#define AMBIENT_WEATHER_API_KEY "" +#define AMBIENT_WEATHER_APP_KEY "" +#define AMBIENT_WEATHER_DEVICE_ID "" + +#define WIFI_SSID "" +#define WIFI_PASSWORD "" + +#define WEATHER_UPDATE_INTERVAL_MS (1 * 60 * 1000) + +#endif diff --git a/main/drivers/backlight/backlight.c b/main/drivers/backlight/backlight.c new file mode 100644 index 0000000..5d7133f --- /dev/null +++ b/main/drivers/backlight/backlight.c @@ -0,0 +1,80 @@ +/* + * STC8H1K28 board controller, CrowPanel 7" Advance v1.3. + * + * A small 8051-class MCU sitting on the I2C bus at 0x30. It drives the LCD + * backlight and, critically, the GT911 reset line -- there is no ESP32 GPIO + * for touch reset on this board, so the GT911 stays mute until the sequence + * below has run. + * + * Protocol is single raw bytes, no register addressing: + * 0x19 wake + * 0x10, 0x18 configure / release GT911 from reset + * 0x00 - 0xF5 brightness, INVERTED (0x00 = full, 0xF5 = off) + * + * v1.2 boards use a different, narrower range (0x05 = off .. 0x10 = max). + */ + +#include "esp_check.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "backlight.h" +#include "i2c_bus.h" + +static const char *TAG = "backlight"; + +#define STC8_ADDR 0x30 +#define STC8_I2C_HZ 100000 +#define STC8_TIMEOUT_MS 100 + +#define STC8_CMD_WAKE 0x19 +#define STC8_CMD_CFG1 0x10 +#define STC8_CMD_CFG2 0x18 + +/* Inverted brightness range. */ +#define STC8_DUTY_MAX_ON 0x00 +#define STC8_DUTY_OFF 0xF5 + +static i2c_master_dev_handle_t dev = NULL; + +static esp_err_t stc8_cmd(uint8_t cmd) +{ + return i2c_master_transmit(dev, &cmd, 1, STC8_TIMEOUT_MS); +} + +esp_err_t backlight_init(void) +{ + const i2c_device_config_t cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = STC8_ADDR, + .scl_speed_hz = STC8_I2C_HZ, + }; + ESP_RETURN_ON_ERROR(i2c_master_bus_add_device(i2c_bus_get(), &cfg, &dev), TAG, + "could not add STC8H1K28 at 0x%02X", STC8_ADDR); + + ESP_RETURN_ON_ERROR(stc8_cmd(STC8_CMD_WAKE), TAG, "wake failed"); + vTaskDelay(pdMS_TO_TICKS(10)); + + /* These two also release the GT911 from reset. */ + ESP_RETURN_ON_ERROR(stc8_cmd(STC8_CMD_CFG1), TAG, "config 1 failed"); + vTaskDelay(pdMS_TO_TICKS(10)); + + ESP_RETURN_ON_ERROR(stc8_cmd(STC8_CMD_CFG2), TAG, "config 2 failed"); + vTaskDelay(pdMS_TO_TICKS(100)); + + ESP_LOGI(TAG, "STC8H1K28 initialized (GT911 released from reset)"); + return ESP_OK; +} + +esp_err_t backlight_set(uint8_t brightness) +{ + if (dev == NULL) { + return ESP_ERR_INVALID_STATE; + } + + /* Map 0..255 (intuitive) onto 0xF5..0x00 (wire, inverted). */ + const uint8_t duty = (uint8_t)(STC8_DUTY_OFF - + ((uint32_t)brightness * STC8_DUTY_OFF) / 255); + return stc8_cmd(duty); +} diff --git a/main/drivers/backlight/backlight.h b/main/drivers/backlight/backlight.h new file mode 100644 index 0000000..c6d58c0 --- /dev/null +++ b/main/drivers/backlight/backlight.h @@ -0,0 +1,22 @@ +#ifndef BACKLIGHT_H +#define BACKLIGHT_H + +#include +#include + +#include "esp_err.h" + +/* + * STC8H1K28 board controller (I2C 0x30) on the CrowPanel 7" Advance. + * + * It owns more than the backlight: its init sequence also drives the GT911 + * reset line, so this must run before the touch controller will answer on + * the bus at all. Requires i2c_bus_init() first. + */ +esp_err_t backlight_init(void); + +/* 0 = off, 255 = full. The v1.3 wire protocol is inverted; that is handled + * internally so callers can use the intuitive direction. */ +esp_err_t backlight_set(uint8_t brightness); + +#endif diff --git a/main/drivers/display/display_rgb.c b/main/drivers/display/display_rgb.c new file mode 100644 index 0000000..9306054 --- /dev/null +++ b/main/drivers/display/display_rgb.c @@ -0,0 +1,229 @@ +/* + * RGB parallel panel driver for the Elecrow CrowPanel 7" ADVANCE (800x480). + * + * NOTE: the Advance and the Basic CrowPanel 7" share almost no pins. Every + * data line, every sync line, the pixel clock, the porches and the sync + * polarities differ, and the Advance drives its backlight over I2C rather + * than from a GPIO. Configuration below is the Advance; do not mix the two. + * + * Reference: Elecrow Advance-series wiki and the LovyanGFX board config in + * the FluidTouch project (elecrow-crowpanel-7-advance-v13). + */ + +#include +#include + +#include "driver/gpio.h" +#include "esp_heap_caps.h" +#include "esp_check.h" +#include "esp_lcd_panel_ops.h" +#include "esp_lcd_panel_rgb.h" +#include "esp_log.h" +#include "lvgl.h" + +#include "display_rgb.h" + +static const char *TAG = "display_rgb"; + +/* --- Sync / control pins (Advance) --- */ +#define RGB_DE_GPIO 42 +#define RGB_VSYNC_GPIO 41 +#define RGB_HSYNC_GPIO 40 +#define RGB_PCLK_GPIO 39 + +/* + * --- Data pins (Advance), 16-bit RGB565 --- + * data_gpio_nums[0..4] = B0-B4, [5..10] = G0-G5, [11..15] = R0-R4. + */ +#define RGB_B0_GPIO 21 +#define RGB_B1_GPIO 47 +#define RGB_B2_GPIO 48 +#define RGB_B3_GPIO 45 +#define RGB_B4_GPIO 38 + +#define RGB_G0_GPIO 9 +#define RGB_G1_GPIO 10 +#define RGB_G2_GPIO 11 +#define RGB_G3_GPIO 12 +#define RGB_G4_GPIO 13 +#define RGB_G5_GPIO 14 + +#define RGB_R0_GPIO 7 +#define RGB_R1_GPIO 17 +#define RGB_R2_GPIO 18 +#define RGB_R3_GPIO 3 +#define RGB_R4_GPIO 46 + +/* + * --- Panel timings (Advance) --- + * + * 18MHz is not tunable in practice. With htotal 820 and vtotal 500 it gives + * ~44 Hz, which this panel locks to; dropping to 14MHz yields ~34 Hz and the + * image rolls vertically because the panel cannot lock that slow. Bandwidth + * is dealt with by the bounce buffer instead. + */ +#define RGB_PCLK_HZ (18 * 1000 * 1000) +#define RGB_HSYNC_PULSE_WIDTH 4 +#define RGB_HSYNC_BACK_PORCH 8 +#define RGB_HSYNC_FRONT_PORCH 8 +#define RGB_VSYNC_PULSE_WIDTH 4 +#define RGB_VSYNC_BACK_PORCH 8 +#define RGB_VSYNC_FRONT_PORCH 8 + +/* + * Scanlines staged in internal RAM per bounce-buffer chunk (two are + * allocated, so this costs 2 * 800 * LINES * 2 bytes of internal RAM). + * + * Bigger is better here: each chunk buys the refill ISR that much more time + * to copy the next one out of PSRAM before the LCD needs it. When it runs + * late -- which WiFi interrupt activity is enough to cause -- the panel + * displays whatever was left in the buffer, showing up as short stale lines + * at the left edge where each chunk begins. + */ +/* 16 is the practical ceiling: two buffers at 20 lines need 64KB of internal + * RAM and the allocation fails once WiFi is up. */ +#define BOUNCE_BUFFER_LINES 16 + +static esp_lcd_panel_handle_t panel = NULL; +static lv_display_t *disp = NULL; + +/* + * Partial-mode flush: blit the dirty rectangle from a small internal-RAM + * draw buffer into the single PSRAM framebuffer. + * + * Page-flipping two framebuffers instead would be cheaper, but the swap + * lands mid-scanout on this panel and tears visibly. Copying into one + * framebuffer that the LCD scans continuously is what the vendor driver does. + */ +/* + * Double-buffered flush. + * + * LVGL renders into whichever framebuffer the LCD is NOT currently scanning, + * and the final flush of a refresh hands that buffer to the panel, which + * switches to it. Nothing ever writes into the buffer being displayed. + * + * This matters more than it sounds: copying a partial rectangle into the live + * framebuffer races the LCD's own reads, and the corruption scales with how + * much is written -- forcing full-width flushes made it visibly worse. Those + * corrupt pixels then persist indefinitely, because LVGL has no idea they are + * wrong and never reinvalidates them. + */ +static void rgb_flush_cb(lv_display_t *display, const lv_area_t *area, uint8_t *px_map) +{ + if (lv_display_flush_is_last(display)) { + esp_lcd_panel_draw_bitmap(panel, 0, 0, DISPLAY_H_RES, DISPLAY_V_RES, px_map); + } + lv_display_flush_ready(display); +} + +esp_err_t display_init(void) +{ + ESP_LOGI(TAG, "Initializing 800x480 RGB panel (CrowPanel Advance)"); + + /* lv_display_create() below allocates from LVGL's heap. Without lv_init() + * that heap is a null pool and the call faults with LoadProhibited. */ + ESP_RETURN_ON_FALSE(lv_is_initialized(), ESP_ERR_INVALID_STATE, TAG, + "lv_init() must run before display_init()"); + + const esp_lcd_rgb_panel_config_t panel_config = { + .clk_src = LCD_CLK_SRC_DEFAULT, + .timings = { + .pclk_hz = RGB_PCLK_HZ, + .h_res = DISPLAY_H_RES, + .v_res = DISPLAY_V_RES, + .hsync_pulse_width = RGB_HSYNC_PULSE_WIDTH, + .hsync_back_porch = RGB_HSYNC_BACK_PORCH, + .hsync_front_porch = RGB_HSYNC_FRONT_PORCH, + .vsync_pulse_width = RGB_VSYNC_PULSE_WIDTH, + .vsync_back_porch = RGB_VSYNC_BACK_PORCH, + .vsync_front_porch = RGB_VSYNC_FRONT_PORCH, + .flags = { + /* + * The Advance runs both syncs idle-HIGH (LovyanGFX + * hsync_polarity/vsync_polarity = 1), which is esp_lcd's + * default, so the *_idle_low flags stay clear. Its + * pclk_idle_high = 1 maps onto LCD_CK_OUT_EDGE, which esp_lcd + * exposes as pclk_active_neg. + */ + .pclk_active_neg = true, + }, + }, + .data_width = 16, + .bits_per_pixel = 16, + /* Two framebuffers so LVGL never draws into the one being scanned. */ + .num_fbs = 2, + /* + * Bounce buffer in internal RAM. Fetching the framebuffer straight + * from PSRAM at 18MHz underruns the LCD FIFO and the image walks + * sideways; this stages each chunk in internal RAM first. + * + * Its refill ISR reads PSRAM, and PSRAM shares the cache with flash + * on the S3, so a flash write while the panel is running faults it + * with "Cache disabled but cached memory region accessed". app_main + * therefore finishes all NVS/WiFi flash activity *before* calling + * display_init(), and CONFIG_ESP_WIFI_NVS_ENABLED is off so the WiFi + * stack does not write to flash later. + */ + .bounce_buffer_size_px = DISPLAY_H_RES * BOUNCE_BUFFER_LINES, + .dma_burst_size = 64, + .hsync_gpio_num = RGB_HSYNC_GPIO, + .vsync_gpio_num = RGB_VSYNC_GPIO, + .de_gpio_num = RGB_DE_GPIO, + .pclk_gpio_num = RGB_PCLK_GPIO, + .disp_gpio_num = GPIO_NUM_NC, + .data_gpio_nums = { + RGB_B0_GPIO, RGB_B1_GPIO, RGB_B2_GPIO, RGB_B3_GPIO, RGB_B4_GPIO, + RGB_G0_GPIO, RGB_G1_GPIO, RGB_G2_GPIO, RGB_G3_GPIO, RGB_G4_GPIO, RGB_G5_GPIO, + RGB_R0_GPIO, RGB_R1_GPIO, RGB_R2_GPIO, RGB_R3_GPIO, RGB_R4_GPIO, + }, + .flags = { + .fb_in_psram = true, + }, + }; + + ESP_RETURN_ON_ERROR(esp_lcd_new_rgb_panel(&panel_config, &panel), TAG, + "esp_lcd_new_rgb_panel failed"); + ESP_RETURN_ON_ERROR(esp_lcd_panel_reset(panel), TAG, "panel reset failed"); + ESP_RETURN_ON_ERROR(esp_lcd_panel_init(panel), TAG, "panel init failed"); + + void *fb0 = NULL; + void *fb1 = NULL; + ESP_RETURN_ON_ERROR(esp_lcd_rgb_panel_get_frame_buffer(panel, 2, &fb0, &fb1), TAG, + "failed to get framebuffers"); + + const size_t fb_size = DISPLAY_H_RES * DISPLAY_V_RES * sizeof(uint16_t); + memset(fb0, 0, fb_size); + memset(fb1, 0, fb_size); + + disp = lv_display_create(DISPLAY_H_RES, DISPLAY_V_RES); + ESP_RETURN_ON_FALSE(disp != NULL, ESP_FAIL, TAG, "lv_display_create failed"); + + lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565); + lv_display_set_flush_cb(disp, rgb_flush_cb); + + /* + * DIRECT mode with the two framebuffers as LVGL's own buffers. LVGL draws + * the dirty regions into the back buffer and keeps both in sync itself; + * no separate internal draw buffer is needed, which also hands ~25KB of + * internal RAM back for the bounce buffers. + */ + lv_display_set_buffers(disp, fb0, fb1, fb_size, LV_DISPLAY_RENDER_MODE_DIRECT); + + ESP_LOGI(TAG, "Panel up: %dx%d @ %d MHz, double-buffered fb0=%p fb1=%p (%u KB each)", + DISPLAY_H_RES, DISPLAY_V_RES, RGB_PCLK_HZ / 1000000, + fb0, fb1, (unsigned)(fb_size / 1024)); + ESP_LOGI(TAG, "Free internal heap: %u KB", + (unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024)); + + return ESP_OK; +} + +lv_display_t *display_get_default(void) +{ + return disp; +} + +esp_lcd_panel_handle_t display_get_panel(void) +{ + return panel; +} diff --git a/main/drivers/display/display_rgb.h b/main/drivers/display/display_rgb.h new file mode 100644 index 0000000..0cd40a1 --- /dev/null +++ b/main/drivers/display/display_rgb.h @@ -0,0 +1,21 @@ +#ifndef DISPLAY_RGB_H +#define DISPLAY_RGB_H + +#include +#include "esp_err.h" +#include "esp_lcd_panel_ops.h" +#include "lvgl.h" + +#define DISPLAY_H_RES 800 +#define DISPLAY_V_RES 480 + +/* Requires lv_init() to have already run -- call lvgl_port_init() first. + * Backlight is NOT owned here on the Advance; see drivers/backlight. */ +esp_err_t display_init(void); + +lv_display_t *display_get_default(void); + +/* Underlying esp_lcd panel, for drawing that bypasses LVGL. */ +esp_lcd_panel_handle_t display_get_panel(void); + +#endif diff --git a/main/drivers/i2c_bus.c b/main/drivers/i2c_bus.c new file mode 100644 index 0000000..ebaa7d1 --- /dev/null +++ b/main/drivers/i2c_bus.c @@ -0,0 +1,61 @@ +#include "esp_check.h" +#include "esp_log.h" + +#include "i2c_bus.h" + +static const char *TAG = "i2c_bus"; + +/* + * CrowPanel 7" Advance pins. The Basic model uses 19/20 instead -- and on the + * ESP32-S3 those are the native USB D-/D+ pins, which is a separate trap. + */ +#define I2C_BUS_SDA_GPIO 15 +#define I2C_BUS_SCL_GPIO 16 +#define I2C_BUS_PORT I2C_NUM_0 + +static i2c_master_bus_handle_t bus = NULL; + +esp_err_t i2c_bus_init(void) +{ + if (bus != NULL) { + return ESP_OK; + } + + const i2c_master_bus_config_t cfg = { + .i2c_port = I2C_BUS_PORT, + .sda_io_num = I2C_BUS_SDA_GPIO, + .scl_io_num = I2C_BUS_SCL_GPIO, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + + ESP_RETURN_ON_ERROR(i2c_new_master_bus(&cfg, &bus), TAG, "i2c bus init failed"); + ESP_LOGI(TAG, "I2C bus up on SDA=%d SCL=%d", I2C_BUS_SDA_GPIO, I2C_BUS_SCL_GPIO); + return ESP_OK; +} + +i2c_master_bus_handle_t i2c_bus_get(void) +{ + return bus; +} + +void i2c_bus_scan(const char *tag) +{ + if (bus == NULL) { + ESP_LOGW(tag, "I2C bus not initialized"); + return; + } + + int n = 0; + for (uint8_t addr = 0x03; addr < 0x78; addr++) { + if (i2c_master_probe(bus, addr, 20) == ESP_OK) { + const char *what = "?"; + if (addr == 0x30) what = "STC8H1K28 board controller"; + else if (addr == 0x5D || addr == 0x14) what = "GT911 touch"; + ESP_LOGI(tag, " I2C 0x%02X %s", addr, what); + n++; + } + } + ESP_LOGI(tag, " %d device(s) on the bus", n); +} diff --git a/main/drivers/i2c_bus.h b/main/drivers/i2c_bus.h new file mode 100644 index 0000000..61b0a9e --- /dev/null +++ b/main/drivers/i2c_bus.h @@ -0,0 +1,19 @@ +#ifndef I2C_BUS_H +#define I2C_BUS_H + +#include "driver/i2c_master.h" +#include "esp_err.h" + +/* + * Shared I2C bus. On the CrowPanel Advance both the STC8H1K28 board + * controller (0x30) and the GT911 touch controller (0x5D) live on it, so a + * single owner avoids two drivers trying to install the same port. + */ +esp_err_t i2c_bus_init(void); + +i2c_master_bus_handle_t i2c_bus_get(void); + +/* Logs every responding address. Diagnostics for bring-up. */ +void i2c_bus_scan(const char *tag); + +#endif diff --git a/main/drivers/touch/touch_gt911.c b/main/drivers/touch/touch_gt911.c new file mode 100644 index 0000000..169c6c0 --- /dev/null +++ b/main/drivers/touch/touch_gt911.c @@ -0,0 +1,172 @@ +/* + * GT911 capacitive touch controller, CrowPanel 7" Advance. + * + * The GT911 uses 16-bit register addressing, so every read writes the two + * address bytes (MSB first) then does a repeated-start read. + * + * On the Advance there is no ESP32 GPIO for touch reset or interrupt -- the + * STC8H1K28 board controller owns both. backlight_init() must therefore run + * first, or the GT911 is still in reset and NACKs every address. + */ + +#include + +#include "esp_check.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lvgl.h" + +#include "display_rgb.h" +#include "i2c_bus.h" +#include "touch_gt911.h" + +static const char *TAG = "touch_gt911"; + +#define TOUCH_I2C_HZ 400000 +#define TOUCH_I2C_TIMEOUT_MS 100 + +#define GT911_ADDR_LOW 0x5D +#define GT911_ADDR_HIGH 0x14 + +#define GT911_REG_PRODUCT_ID 0x8140 +#define GT911_REG_STATUS 0x814E +#define GT911_REG_TRACK_ID 0x814F +/* Coordinate block: x_lo, x_hi, y_lo, y_hi, size_lo, size_hi */ +#define GT911_REG_POINT1_XY 0x8150 + +#define GT911_STATUS_READY 0x80 +#define GT911_POINT_COUNT_MASK 0x0F +#define GT911_MAX_POINTS 5 + +static i2c_master_dev_handle_t dev = NULL; +static lv_indev_t *indev = NULL; + +static esp_err_t gt911_read(uint16_t reg, uint8_t *data, size_t len) +{ + const uint8_t addr[2] = { (uint8_t)(reg >> 8), (uint8_t)(reg & 0xFF) }; + return i2c_master_transmit_receive(dev, addr, sizeof(addr), data, len, + TOUCH_I2C_TIMEOUT_MS); +} + +static esp_err_t gt911_write_u8(uint16_t reg, uint8_t value) +{ + const uint8_t buf[3] = { (uint8_t)(reg >> 8), (uint8_t)(reg & 0xFF), value }; + return i2c_master_transmit(dev, buf, sizeof(buf), TOUCH_I2C_TIMEOUT_MS); +} + +/* The status register latches until cleared; leaving it set stops the + * controller reporting new coordinates. */ +static void gt911_clear_status(void) +{ + const esp_err_t err = gt911_write_u8(GT911_REG_STATUS, 0x00); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to clear status register: %s", esp_err_to_name(err)); + } +} + +static void touch_read_cb(lv_indev_t *drv, lv_indev_data_t *data) +{ + /* LVGL expects the last coordinate to persist through release, otherwise + * the release event lands at (0,0) and cancels the gesture. */ + static int32_t last_x = 0; + static int32_t last_y = 0; + static bool was_pressed = false; + + data->point.x = last_x; + data->point.y = last_y; + data->state = LV_INDEV_STATE_RELEASED; + + uint8_t status = 0; + if (gt911_read(GT911_REG_STATUS, &status, 1) != ESP_OK || + !(status & GT911_STATUS_READY)) { + was_pressed = false; + return; + } + + const uint8_t points = status & GT911_POINT_COUNT_MASK; + if (points == 0 || points > GT911_MAX_POINTS) { + was_pressed = false; + gt911_clear_status(); + return; + } + + /* Only the first contact is used -- the UI has no multi-touch gestures. */ + uint8_t buf[4]; + if (gt911_read(GT911_REG_POINT1_XY, buf, sizeof(buf)) == ESP_OK) { + const uint16_t x = (uint16_t)((buf[1] << 8) | buf[0]); + const uint16_t y = (uint16_t)((buf[3] << 8) | buf[2]); + + if (x < DISPLAY_H_RES && y < DISPLAY_V_RES) { + /* Log only the initial contact: the panel reports ~30 samples a + * second for as long as a finger stays down. */ + if (!was_pressed) { + ESP_LOGD(TAG, "touch at (%u, %u)", x, y); + } + was_pressed = true; + + last_x = x; + last_y = y; + data->point.x = x; + data->point.y = y; + data->state = LV_INDEV_STATE_PRESSED; + } else { + ESP_LOGW(TAG, "touch out of range: raw (%u, %u)", x, y); + } + } + + gt911_clear_status(); +} + +static esp_err_t gt911_try_address(uint8_t addr) +{ + const i2c_device_config_t dev_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = addr, + .scl_speed_hz = TOUCH_I2C_HZ, + }; + + ESP_RETURN_ON_ERROR(i2c_master_bus_add_device(i2c_bus_get(), &dev_cfg, &dev), TAG, + "failed to add device 0x%02X", addr); + + uint8_t product_id[4] = {0}; + const esp_err_t err = gt911_read(GT911_REG_PRODUCT_ID, product_id, sizeof(product_id)); + if (err != ESP_OK) { + i2c_master_bus_rm_device(dev); + dev = NULL; + return err; + } + + ESP_LOGI(TAG, "GT911 found at 0x%02X (product ID \"%.4s\")", addr, (char *)product_id); + return ESP_OK; +} + +esp_err_t touch_init(void) +{ + ESP_RETURN_ON_FALSE(i2c_bus_get() != NULL, ESP_ERR_INVALID_STATE, TAG, + "i2c_bus_init() must run first"); + + if (gt911_try_address(GT911_ADDR_LOW) != ESP_OK && + gt911_try_address(GT911_ADDR_HIGH) != ESP_OK) { + ESP_LOGE(TAG, "No GT911 at 0x%02X or 0x%02X", GT911_ADDR_LOW, GT911_ADDR_HIGH); + i2c_bus_scan(TAG); + return ESP_ERR_NOT_FOUND; + } + + gt911_clear_status(); + + indev = lv_indev_create(); + ESP_RETURN_ON_FALSE(indev != NULL, ESP_FAIL, TAG, "lv_indev_create failed"); + + lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); + lv_indev_set_read_cb(indev, touch_read_cb); + lv_indev_set_display(indev, display_get_default()); + + ESP_LOGI(TAG, "Touch registered as LVGL pointer input"); + return ESP_OK; +} + +lv_indev_t *touch_get_indev(void) +{ + return indev; +} diff --git a/main/drivers/touch/touch_gt911.h b/main/drivers/touch/touch_gt911.h new file mode 100644 index 0000000..51e6506 --- /dev/null +++ b/main/drivers/touch/touch_gt911.h @@ -0,0 +1,17 @@ +#ifndef TOUCH_GT911_H +#define TOUCH_GT911_H + +#include "esp_err.h" +#include "lvgl.h" + +/* + * Probes the GT911 and registers it as an LVGL pointer input device. + * + * Requires i2c_bus_init(), backlight_init() (the STC8H1K28 holds the GT911 + * in reset until it runs), lv_init() and display_init() to have happened. + */ +esp_err_t touch_init(void); + +lv_indev_t *touch_get_indev(void); + +#endif diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..8f92fb5 --- /dev/null +++ b/main/idf_component.yml @@ -0,0 +1,5 @@ +dependencies: + idf: + version: ">=5.3" + lvgl/lvgl: + version: "^9.2.0" diff --git a/main/main.c b/main/main.c new file mode 100644 index 0000000..62c63ad --- /dev/null +++ b/main/main.c @@ -0,0 +1,135 @@ +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "ambient_weather.h" +#include "app_config.h" +#include "backlight.h" +#include "display_rgb.h" +#include "i2c_bus.h" +#include "lvgl_port.h" +#include "touch_gt911.h" +#include "ui_manager.h" +#include "wifi_manager.h" + +static const char *TAG = "main"; + +#define WEATHER_TASK_STACK 6144 +#define WEATHER_TASK_PRIO 3 +#define WEATHER_FIRST_DELAY_MS 3000 + +static void weather_update_task(void *arg) +{ + weather_data_t weather; + char line[96]; + + vTaskDelay(pdMS_TO_TICKS(WEATHER_FIRST_DELAY_MS)); + + while (1) { + if (!wifi_is_connected()) { + ui_set_status("WiFi: reconnecting..."); + vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS)); + continue; + } + + /* + * Freeze rendering for the duration of the fetch. The TLS session, + * the LVGL redraw and the flush into the PSRAM framebuffer all + * compete with the LCD's own DMA out of that same PSRAM; letting them + * overlap is what produces visible artefacts. The panel keeps + * scanning the last completed frame, so the screen simply holds still. + */ + lvgl_port_pause(); + const esp_err_t err = weather_update(&weather); + lvgl_port_resume(); + + if (err == ESP_OK) { + weather_get_string(&weather, line, sizeof(line)); + ESP_LOGI(TAG, "Weather: %s", line); + + char detail[64]; + snprintf(detail, sizeof(detail), "%.0f%% humidity", + weather.has_humidity ? weather.humidity : 0.0f); + + ui_update_weather((int16_t)(weather.temp_f + 0.5f), + weather.has_humidity ? detail : "Outdoor"); + + wifi_get_status(line, sizeof(line)); + ui_set_status(line); + } else { + ESP_LOGW(TAG, "Weather update failed"); + ui_set_status("Weather update failed"); + } + + vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS)); + } +} + +void app_main(void) +{ + ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)"); + + /* + * WiFi comes up FIRST, before the RGB panel exists. + * + * The panel's bounce-buffer ISR reads the framebuffer out of PSRAM, and + * PSRAM shares the cache with flash on the ESP32-S3. Bringing up NVS and + * the WiFi PHY writes calibration data to flash, which disables the cache + * and faults that ISR. Doing it here means every flash write is finished + * before the panel starts scanning. + */ + const esp_err_t wifi_err = wifi_init_sta(); + if (wifi_err != ESP_OK) { + ESP_LOGE(TAG, "WiFi failed -- continuing, weather will be disabled"); + } + + /* LVGL core must exist before the display and touch drivers register + * themselves with it. */ + ESP_ERROR_CHECK(lvgl_port_init()); + + /* Shared bus: STC8H1K28 board controller and GT911 both live on it. */ + ESP_ERROR_CHECK(i2c_bus_init()); + + /* Must precede touch_init(): the STC8H1K28 holds the GT911 in reset + * until its init sequence runs. */ + if (backlight_init() != ESP_OK) { + ESP_LOGE(TAG, "STC8H1K28 init failed -- backlight and touch will not work"); + } + i2c_bus_scan(TAG); + + ESP_ERROR_CHECK(display_init()); + + if (touch_init() != ESP_OK) { + /* A dead touch panel should not stop the display from working. */ + ESP_LOGW(TAG, "Touch unavailable -- continuing without input"); + } + + ui_init(); + ESP_ERROR_CHECK(lvgl_port_start()); + + /* First frame is rendered by now, so it is safe to light the panel. */ + vTaskDelay(pdMS_TO_TICKS(100)); + ESP_ERROR_CHECK(backlight_set(255)); + + if (wifi_err != ESP_OK) { + ui_set_status("WiFi: failed"); + return; + } + + char status[96]; + wifi_get_status(status, sizeof(status)); + ui_set_status(status); + + if (weather_init() != ESP_OK) { + ui_set_status("Weather: no API credentials"); + return; + } + + xTaskCreate(weather_update_task, "weather", WEATHER_TASK_STACK, NULL, + WEATHER_TASK_PRIO, NULL); + + ESP_LOGI(TAG, "System ready"); +} diff --git a/main/ui/lvgl_port.c b/main/ui/lvgl_port.c new file mode 100644 index 0000000..7fecd17 --- /dev/null +++ b/main/ui/lvgl_port.c @@ -0,0 +1,135 @@ +/* + * Minimal LVGL 9 port layer: tick source, service task, and a recursive lock. + * + * LVGL needs three things the application must supply: a millisecond tick, a + * periodic call to lv_timer_handler(), and mutual exclusion around its API. + */ + +#include "esp_check.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "lvgl.h" + +#include "lvgl_port.h" + +static const char *TAG = "lvgl_port"; + +#define LVGL_TICK_PERIOD_MS 2 +#define LVGL_TASK_STACK_SIZE 8192 +#define LVGL_TASK_PRIORITY 4 +#define LVGL_TASK_MAX_DELAY_MS 500 +#define LVGL_TASK_MIN_DELAY_MS 1 + +static SemaphoreHandle_t lvgl_mutex = NULL; +static esp_timer_handle_t tick_timer = NULL; +static volatile bool paused = false; + +static void lvgl_tick_cb(void *arg) +{ + lv_tick_inc(LVGL_TICK_PERIOD_MS); +} + +static void lvgl_task(void *arg) +{ + ESP_LOGI(TAG, "LVGL task started"); + + while (1) { + uint32_t delay_ms = LVGL_TASK_MAX_DELAY_MS; + + if (paused) { + /* Leave the framebuffer untouched -- the panel keeps scanning it + * out, so the last frame stays on screen. */ + vTaskDelay(pdMS_TO_TICKS(20)); + continue; + } + + if (lvgl_port_lock(-1)) { + delay_ms = lv_timer_handler(); + lvgl_port_unlock(); + } + + if (delay_ms > LVGL_TASK_MAX_DELAY_MS) { + delay_ms = LVGL_TASK_MAX_DELAY_MS; + } else if (delay_ms < LVGL_TASK_MIN_DELAY_MS) { + delay_ms = LVGL_TASK_MIN_DELAY_MS; + } + + vTaskDelay(pdMS_TO_TICKS(delay_ms)); + } +} + +esp_err_t lvgl_port_init(void) +{ + /* Recursive: lv_timer_handler() runs callbacks that may re-enter code + * which also takes the lock. */ + lvgl_mutex = xSemaphoreCreateRecursiveMutex(); + ESP_RETURN_ON_FALSE(lvgl_mutex != NULL, ESP_ERR_NO_MEM, TAG, "mutex alloc failed"); + + lv_init(); + + ESP_LOGI(TAG, "LVGL %d.%d.%d initialized", + lv_version_major(), lv_version_minor(), lv_version_patch()); + return ESP_OK; +} + +esp_err_t lvgl_port_start(void) +{ + const esp_timer_create_args_t timer_args = { + .callback = lvgl_tick_cb, + .name = "lvgl_tick", + }; + ESP_RETURN_ON_ERROR(esp_timer_create(&timer_args, &tick_timer), TAG, + "tick timer create failed"); + ESP_RETURN_ON_ERROR(esp_timer_start_periodic(tick_timer, LVGL_TICK_PERIOD_MS * 1000), + TAG, "tick timer start failed"); + + const BaseType_t ok = xTaskCreate(lvgl_task, "lvgl", LVGL_TASK_STACK_SIZE, + NULL, LVGL_TASK_PRIORITY, NULL); + ESP_RETURN_ON_FALSE(ok == pdPASS, ESP_ERR_NO_MEM, TAG, "LVGL task create failed"); + + return ESP_OK; +} + +void lvgl_port_pause(void) +{ + if (paused) { + return; + } + + /* + * Take the lock before setting the flag so we cannot stop the task + * mid-render, then release it -- the flag alone keeps the task idle and + * callers stay free to mutate widgets while paused. + */ + if (lvgl_port_lock(1000)) { + paused = true; + lvgl_port_unlock(); + } else { + ESP_LOGW(TAG, "pause: lock timed out, pausing anyway"); + paused = true; + } +} + +void lvgl_port_resume(void) +{ + paused = false; +} + +bool lvgl_port_is_paused(void) +{ + return paused; +} + +bool lvgl_port_lock(int timeout_ms) +{ + const TickType_t ticks = (timeout_ms < 0) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms); + return xSemaphoreTakeRecursive(lvgl_mutex, ticks) == pdTRUE; +} + +void lvgl_port_unlock(void) +{ + xSemaphoreGiveRecursive(lvgl_mutex); +} diff --git a/main/ui/lvgl_port.h b/main/ui/lvgl_port.h new file mode 100644 index 0000000..c6f5eb2 --- /dev/null +++ b/main/ui/lvgl_port.h @@ -0,0 +1,36 @@ +#ifndef LVGL_PORT_H +#define LVGL_PORT_H + +#include +#include "esp_err.h" + +/* Calls lv_init() and creates the LVGL mutex. Must run before display_init() + * and touch_init(), which register themselves with LVGL. */ +esp_err_t lvgl_port_init(void); + +/* Starts the tick source and the task that services lv_timer_handler(). + * Call once the display and input devices are registered. */ +esp_err_t lvgl_port_start(void); + +/* + * LVGL is not thread safe. Any code touching an lv_* object from outside the + * LVGL task must hold this lock. Returns false on timeout. + */ +bool lvgl_port_lock(int timeout_ms); +void lvgl_port_unlock(void); + +/* + * Suspend/resume LVGL rendering. + * + * The panel keeps scanning out whatever frame is already in the framebuffer, + * so the image stays on screen -- it simply stops being redrawn. Used to keep + * LVGL off the PSRAM bus while a network fetch is in flight, since the redraw, + * the flush and the TLS session otherwise contend with the LCD's own DMA. + * + * Calls do not nest. + */ +void lvgl_port_pause(void); +void lvgl_port_resume(void); +bool lvgl_port_is_paused(void); + +#endif diff --git a/main/ui/ui_manager.c b/main/ui/ui_manager.c new file mode 100644 index 0000000..77aca42 --- /dev/null +++ b/main/ui/ui_manager.c @@ -0,0 +1,162 @@ +#include +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "lvgl_port.h" +#include "ui_manager.h" + +static const char *TAG = "ui_manager"; + +#define UI_LOCK_TIMEOUT_MS 1000 + +static lv_obj_t *temp_label = NULL; +static lv_obj_t *cond_label = NULL; +static lv_obj_t *status_label = NULL; +static lv_obj_t *touch_label = NULL; + +/* + * lv_label_set_text() invalidates the label unconditionally, even when the + * new text is identical -- which would repaint the temperature every poll + * whether or not it moved. Comparing first means an unchanged value costs + * nothing: no invalidation, so no redraw and no flush. + */ +static void label_set_if_changed(lv_obj_t *label, const char *text) +{ + if (label == NULL || text == NULL) { + return; + } + + const char *current = lv_label_get_text(label); + if (current != NULL && strcmp(current, text) == 0) { + return; + } + + lv_label_set_text(label, text); +} + +/* + * Live readout of the last contact. + * + * Polled from an LVGL timer rather than driven by a widget event. An event + * handler on the screen object only fires for taps that actually reach the + * screen: LVGL does not bubble events to parents by default, so anything + * landing on the header or the temperature card is consumed by that child and + * never seen here. That made whole regions of the panel look like dead touch + * zones when the hardware was reporting them perfectly well. + */ +static void touch_readout_timer_cb(lv_timer_t *timer) +{ + lv_indev_t *indev = lv_indev_get_next(NULL); + if (indev == NULL || touch_label == NULL) { + return; + } + + lv_point_t p; + lv_indev_get_point(indev, &p); + + char buf[48]; + if (lv_indev_get_state(indev) == LV_INDEV_STATE_PRESSED) { + snprintf(buf, sizeof(buf), "touch %d, %d", (int)p.x, (int)p.y); + } else { + snprintf(buf, sizeof(buf), "touch %d, %d (released)", (int)p.x, (int)p.y); + } + label_set_if_changed(touch_label, buf); +} + +void ui_update_weather(int16_t temp_f, const char *condition) +{ + if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Dropped weather update: LVGL lock timed out"); + return; + } + + char buf[32]; + snprintf(buf, sizeof(buf), "%d°F", temp_f); + label_set_if_changed(temp_label, buf); + label_set_if_changed(cond_label, condition); + + lvgl_port_unlock(); +} + +void ui_set_status(const char *status) +{ + if (status == NULL) { + return; + } + + if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Dropped status update: LVGL lock timed out"); + return; + } + + label_set_if_changed(status_label, status); + + lvgl_port_unlock(); +} + +void ui_init(void) +{ + /* ui_init() runs from app_main, not the LVGL task, so it needs the lock + * like any other outside caller. */ + if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) { + ESP_LOGE(TAG, "Could not acquire LVGL lock for UI init"); + return; + } + + lv_obj_t *scr = lv_screen_active(); + lv_obj_set_style_bg_color(scr, lv_color_hex(0x1A2332), LV_PART_MAIN); + lv_obj_set_style_pad_all(scr, 0, LV_PART_MAIN); + lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(scr, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + + lv_obj_t *header = lv_obj_create(scr); + lv_obj_set_size(header, LV_PCT(100), 64); + lv_obj_set_style_radius(header, 0, LV_PART_MAIN); + lv_obj_set_style_bg_color(header, lv_color_hex(0x0D1620), LV_PART_MAIN); + lv_obj_set_style_border_width(header, 0, LV_PART_MAIN); + lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title = lv_label_create(header); + lv_label_set_text(title, "Thermostat"); + lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN); + lv_obj_set_style_text_font(title, &lv_font_montserrat_28, LV_PART_MAIN); + lv_obj_center(title); + + lv_obj_t *card = lv_obj_create(scr); + lv_obj_set_size(card, LV_PCT(80), LV_SIZE_CONTENT); + lv_obj_set_style_bg_color(card, lv_color_hex(0x2C3E50), LV_PART_MAIN); + lv_obj_set_style_border_width(card, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(card, lv_color_hex(0x3498DB), LV_PART_MAIN); + lv_obj_set_style_radius(card, 12, LV_PART_MAIN); + lv_obj_set_style_pad_all(card, 24, LV_PART_MAIN); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); + + temp_label = lv_label_create(card); + lv_label_set_text(temp_label, "--°F"); + lv_obj_set_style_text_color(temp_label, lv_color_white(), LV_PART_MAIN); + lv_obj_set_style_text_font(temp_label, &lv_font_montserrat_48, LV_PART_MAIN); + + cond_label = lv_label_create(card); + lv_label_set_text(cond_label, "Waiting for data..."); + lv_obj_set_style_text_color(cond_label, lv_color_hex(0xBDC3C7), LV_PART_MAIN); + + status_label = lv_label_create(scr); + lv_label_set_text(status_label, "Starting..."); + lv_obj_set_style_text_color(status_label, lv_color_hex(0x7F8C8D), LV_PART_MAIN); + + touch_label = lv_label_create(scr); + lv_label_set_text(touch_label, "touch --, --"); + lv_obj_set_style_text_color(touch_label, lv_color_hex(0x3498DB), LV_PART_MAIN); + + lv_timer_create(touch_readout_timer_cb, 100, NULL); + + lvgl_port_unlock(); + + ESP_LOGI(TAG, "UI initialized"); +} diff --git a/main/ui/ui_manager.h b/main/ui/ui_manager.h new file mode 100644 index 0000000..cd2f04e --- /dev/null +++ b/main/ui/ui_manager.h @@ -0,0 +1,13 @@ +#ifndef UI_MANAGER_H +#define UI_MANAGER_H + +#include +#include "lvgl.h" + +void ui_init(void); + +/* Safe to call from any task -- these take the LVGL lock internally. */ +void ui_update_weather(int16_t temp_f, const char *condition); +void ui_set_status(const char *status); + +#endif diff --git a/main/utils/utils.c b/main/utils/utils.c new file mode 100644 index 0000000..f79a331 --- /dev/null +++ b/main/utils/utils.c @@ -0,0 +1,37 @@ +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "utils.h" + +void utils_delay_ms(uint32_t ms) +{ + vTaskDelay(pdMS_TO_TICKS(ms)); +} + +uint32_t utils_get_tick_ms(void) +{ + return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); +} + +void utils_trim(char *str) +{ + if (!str) return; + + char *start = str; + while (*start && isspace((unsigned char)*start)) start++; + + if (*start == '\0') { + *str = '\0'; + return; + } + + char *end = start + strlen(start) - 1; + while (end > start && isspace((unsigned char)*end)) end--; + + size_t len = end - start + 1; + memmove(str, start, len); + str[len] = '\0'; +} diff --git a/main/utils/utils.h b/main/utils/utils.h new file mode 100644 index 0000000..799a468 --- /dev/null +++ b/main/utils/utils.h @@ -0,0 +1,11 @@ +#ifndef UTILS_H +#define UTILS_H + +#include +#include + +void utils_delay_ms(uint32_t ms); +uint32_t utils_get_tick_ms(void); +void utils_trim(char *str); + +#endif diff --git a/main/wifi/wifi_manager.c b/main/wifi/wifi_manager.c new file mode 100644 index 0000000..cf3ab57 --- /dev/null +++ b/main/wifi/wifi_manager.c @@ -0,0 +1,156 @@ +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_system.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "nvs_flash.h" + +#include "app_config.h" +#include "wifi_manager.h" + +static const char *TAG = "wifi_manager"; + +#define WIFI_CONNECTED_BIT BIT0 +#define WIFI_FAIL_BIT BIT1 +#define WIFI_MAX_RETRIES 8 +#define WIFI_CONNECT_TIMEOUT_MS 30000 + +static EventGroupHandle_t s_wifi_event_group = NULL; +static int s_retry_count = 0; + +static void wifi_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + return; + } + + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + /* + * Only give up after a bounded number of retries. Signalling failure + * on the first disconnect (as this previously did) meant a single + * transient drop was reported as a hard failure. + */ + if (s_retry_count < WIFI_MAX_RETRIES) { + s_retry_count++; + ESP_LOGW(TAG, "Disconnected, retry %d/%d", s_retry_count, WIFI_MAX_RETRIES); + esp_wifi_connect(); + } else { + ESP_LOGE(TAG, "Giving up after %d retries", WIFI_MAX_RETRIES); + xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT); + } + return; + } + + if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + const ip_event_got_ip_t *event = (const ip_event_got_ip_t *)event_data; + ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip)); + s_retry_count = 0; + xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + } +} + +esp_err_t wifi_init_sta(void) +{ + if (WIFI_SSID[0] == '\0') { + ESP_LOGE(TAG, "WIFI_SSID is empty -- set it in main/app_config.h"); + return ESP_ERR_INVALID_STATE; + } + + s_wifi_event_group = xEventGroupCreate(); + if (s_wifi_event_group == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + /* The TCP/IP stack and the default STA netif were never set up here, so + * IP_EVENT_STA_GOT_IP could never fire and every connect timed out. */ + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &wifi_event_handler, NULL, NULL)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &wifi_event_handler, NULL, NULL)); + + /* wifi_config_t.sta.ssid is a uint8_t array; it cannot be initialised + * from a string literal in a designated initialiser. */ + wifi_config_t wifi_config = { + .sta = { + .threshold.authmode = WIFI_AUTH_WPA2_PSK, + .sae_pwe_h2e = WPA3_SAE_PWE_BOTH, + }, + }; + strlcpy((char *)wifi_config.sta.ssid, WIFI_SSID, sizeof(wifi_config.sta.ssid)); + strlcpy((char *)wifi_config.sta.password, WIFI_PASSWORD, + sizeof(wifi_config.sta.password)); + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + /* Modem sleep adds hundreds of ms of latency per round trip, which is + * enough to push a TLS handshake past its timeout on this board. */ + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + + const EventBits_t bits = xEventGroupWaitBits( + s_wifi_event_group, + WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, + pdFALSE, pdFALSE, + pdMS_TO_TICKS(WIFI_CONNECT_TIMEOUT_MS)); + + if (bits & WIFI_CONNECTED_BIT) { + ESP_LOGI(TAG, "Connected to SSID: %s", WIFI_SSID); + return ESP_OK; + } + + if (bits & WIFI_FAIL_BIT) { + ESP_LOGE(TAG, "Failed to connect to SSID: %s", WIFI_SSID); + return ESP_FAIL; + } + + ESP_LOGE(TAG, "Timed out waiting for WiFi connection"); + return ESP_ERR_TIMEOUT; +} + +bool wifi_is_connected(void) +{ + if (s_wifi_event_group == NULL) { + return false; + } + return (xEventGroupGetBits(s_wifi_event_group) & WIFI_CONNECTED_BIT) != 0; +} + +void wifi_get_status(char *buf, size_t len) +{ + if (buf == NULL || len == 0) { + return; + } + + if (!wifi_is_connected()) { + snprintf(buf, len, "WiFi: not connected"); + return; + } + + wifi_ap_record_t ap; + if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { + snprintf(buf, len, "WiFi: %s (%d dBm)", (char *)ap.ssid, ap.rssi); + } else { + snprintf(buf, len, "WiFi: connected"); + } +} diff --git a/main/wifi/wifi_manager.h b/main/wifi/wifi_manager.h new file mode 100644 index 0000000..3970020 --- /dev/null +++ b/main/wifi/wifi_manager.h @@ -0,0 +1,14 @@ +#ifndef WIFI_MANAGER_H +#define WIFI_MANAGER_H + +#include +#include +#include + +#include "esp_err.h" + +esp_err_t wifi_init_sta(void); +bool wifi_is_connected(void); +void wifi_get_status(char *buf, size_t len); + +#endif diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..79e6fd6 --- /dev/null +++ b/partitions.csv @@ -0,0 +1,8 @@ +# Partition table for ESP32-S3-WROOM-1-N16R8 (16MB flash, 8MB octal PSRAM) +# as fitted to the CrowPanel 7" Advance. +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x400000, +storage, data, spiffs, 0x410000, 0xBF0000, diff --git a/project_structure.txt b/project_structure.txt new file mode 100644 index 0000000..fd54aa9 --- /dev/null +++ b/project_structure.txt @@ -0,0 +1,35 @@ +CMakeLists.txt (project root -- required by ESP-IDF) +sdkconfig.defaults +partitions.csv +README.md +main/ +├── CMakeLists.txt (the only component registration in the project) +├── idf_component.yml +├── app_config.h.example (template; copy to app_config.h) +├── app_config.h (gitignored -- credentials) +├── main.c +├── wifi/ +│ ├── wifi_manager.h +│ └── wifi_manager.c +├── drivers/ +│ ├── display/ +│ │ ├── display_rgb.h +│ │ └── display_rgb.c +│ └── touch/ +│ ├── touch_gt911.h +│ └── touch_gt911.c +├── ui/ +│ ├── lvgl_port.h (tick, service task, locking) +│ ├── lvgl_port.c +│ ├── ui_manager.h +│ └── ui_manager.c +├── api/ +│ ├── ambient_weather.h +│ └── ambient_weather.c +└── utils/ + ├── utils.h + └── utils.c + +Note: main/ subdirectories are plain source folders, not ESP-IDF components. +IDF only auto-discovers components under a top-level components/ directory, +so all sources are registered by main/CMakeLists.txt. diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..d19a2c7 --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,84 @@ +# Elecrow CrowPanel 7.0" HMI -- ESP32-S3-WROOM-1-N4R8 +# 4MB flash, 8MB octal PSRAM, 800x480 RGB panel. + +CONFIG_IDF_TARGET="esp32s3" + +# --- Flash / partitions --- +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_ESPTOOLPY_FLASHMODE_QIO=y +CONFIG_ESPTOOLPY_FLASHFREQ_80M=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + +# --- CPU --- +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_FREERTOS_HZ=1000 + +# --- PSRAM --- +# The R8 suffix is 8MB OCTAL PSRAM. QSPI mode will not sustain an 800x480 +# RGB framebuffer; octal at 80MHz is required for a stable panel. +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +# 80MHz. The vendor's Arduino build uses 120MHz, which would give the RGB +# panel 50% more bandwidth -- but on ESP-IDF 5.3 selecting 120MHz PSRAM raises +# the shared MSPI core clock and forces flash to 120MHz too. That needs flash +# High Performance Mode plus a DC-aware bootloader, and getting it wrong +# leaves the board unbootable. Not worth it; the bounce buffer covers the gap. +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +# Do NOT enable SPIRAM_FETCH_INSTRUCTIONS / SPIRAM_RODATA here. Espressif +# documents them as incompatible with an RGB panel framebuffer in PSRAM: the +# LCD DMA and the instruction fetch path contend for the same bus, which +# blanks the panel and stalls TLS handshakes. + +# --- Cache: reduces RGB panel tearing/drift --- +CONFIG_ESP32S3_DATA_CACHE_64KB=y +CONFIG_ESP32S3_DATA_CACHE_LINE_32B=y + +# --- LCD --- +CONFIG_LCD_RGB_ISR_IRAM_SAFE=y +# Re-align the scanout to vsync if it ever does slip. +CONFIG_LCD_RGB_RESTART_IN_VSYNC=y + +# The bounce-buffer ISR faults if the cache is disabled by a flash write while +# the panel is running. app_main finishes WiFi bring-up before starting the +# panel; this stops the WiFi stack writing to flash afterwards too. +CONFIG_ESP_WIFI_NVS_ENABLED=n + +# --- WiFi buffer trim --- +# Frees ~25KB of internal RAM so the LVGL draw buffers fit there rather than +# in PSRAM. Keeping them out of PSRAM removes a third round trip over the same +# bus the LCD is streaming from, which is what causes the refill artefacts. +# This board only polls a small JSON endpoint, so the smaller pools are ample. +CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=4 +CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=16 +CONFIG_ESP_WIFI_TX_BUFFER_TYPE=1 +CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=16 +CONFIG_ESP_WIFI_AMPDU_TX_ENABLED=n +CONFIG_ESP_WIFI_AMPDU_RX_ENABLED=n + +# --- Console --- +# Console reaches us over the CH340 UART bridge. The USB-Serial-JTAG +# peripheral is left off: it is hardwired to GPIO19/20 on the ESP32-S3, which +# the Basic CrowPanel uses for touch I2C. The Advance uses 15/16 so this is +# not strictly required here, but nothing needs it and it keeps those pins free. +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y + +# --- TLS --- +# The LVGL draw buffers take ~62KB of internal RAM, which leaves too little +# for an mbedTLS session (it fails with MBEDTLS_ERR_SSL_ALLOC_FAILED, -0x7F00). +# Push TLS allocations to PSRAM and shrink the outgoing content buffer -- the +# only request this makes is a short GET. +CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y +CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096 + +# --- LVGL 9 --- +CONFIG_LV_COLOR_DEPTH_16=y +CONFIG_LV_USE_LOG=y +CONFIG_LV_LOG_LEVEL_WARN=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_28=y +CONFIG_LV_FONT_MONTSERRAT_48=y + +# --- Main task needs headroom for driver init --- +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192