Added lvgl support to generate images. Made basic example, grayscale background + text displayed everytime we call /api/display/image.png

This commit is contained in:
2026-03-14 18:41:00 -04:00
parent a9d5aa83dc
commit 6384e93020
19 changed files with 10019 additions and 5 deletions

View File

@@ -79,6 +79,7 @@ Read the relevant TDD before any major architectural change:
| `tdd/firmware_ota.md` | Touching OTA partition logic | | `tdd/firmware_ota.md` | Touching OTA partition logic |
| `tdd/frontend_ota.md` | Touching frontend OTA upload or versioning | | `tdd/frontend_ota.md` | Touching frontend OTA upload or versioning |
| `tdd/concurrent_requests.md` | Changing HTTP server socket/connection config | | `tdd/concurrent_requests.md` | Changing HTTP server socket/connection config |
| `tdd/lvgl_image_generation.md` | Touching LVGL headless display or image gen |
--- ---

View File

@@ -0,0 +1,23 @@
# Listening to UDP Logs
When the Calendink Provider device is running (especially when connected via Ethernet and serial monitoring is not feasible), the firmware broadcasts the `ESP_LOG` output over UDP on port **514**.
You can listen to these live logs directly from your PC.
## Option 1: Using ncat (Nmap)
If you have `ncat` installed, you can listen to the UDP broadcast by running this command in your terminal:
```powershell
ncat -ul 514
```
## Option 2: Using Python (Windows Fallback)
On Windows, `ncat` sometimes throws a `WSAEMSGSIZE` error if a log line exceeds a certain size (like the chunked PM dump locks). If this happens, or if you don't have `ncat` installed, you can run this Python one-liner in your terminal instead:
```powershell
python -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('', 514)); [print(m[0].decode(errors='ignore'), end='') for m in iter(lambda:s.recvfrom(4096), None)]"
```
### Configuration
- **Port:** The UDP server broadcasts on port `514`.
- **Target IP:** By default, logs are broadcast to `255.255.255.255`. If you need to target a specific machine, make sure `CONFIG_CALENDINK_UDP_LOG_TARGET_IP` is set to your PC's IP address in your `sdkconfig`.

View File

@@ -76,6 +76,7 @@ Always read the relevant TDD before making major architectural changes:
| [firmware_ota.md](tdd/firmware_ota.md) | Touching OTA partition logic | | [firmware_ota.md](tdd/firmware_ota.md) | Touching OTA partition logic |
| [frontend_ota.md](tdd/frontend_ota.md) | Touching frontend OTA upload or versioning | | [frontend_ota.md](tdd/frontend_ota.md) | Touching frontend OTA upload or versioning |
| [concurrent_requests.md](tdd/concurrent_requests.md) | Changing HTTP server socket/connection config | | [concurrent_requests.md](tdd/concurrent_requests.md) | Changing HTTP server socket/connection config |
| [lvgl_image_generation.md](tdd/lvgl_image_generation.md) | Touching LVGL headless display or image gen |
--- ---

View File

@@ -145,12 +145,20 @@ dependencies:
registry_url: https://components.espressif.com/ registry_url: https://components.espressif.com/
type: service type: service
version: 1.20.4 version: 1.20.4
lvgl/lvgl:
component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae
dependencies: []
source:
registry_url: https://components.espressif.com/
type: service
version: 9.5.0
direct_dependencies: direct_dependencies:
- espressif/ethernet_init - espressif/ethernet_init
- espressif/led_strip - espressif/led_strip
- espressif/mdns - espressif/mdns
- idf - idf
- joltwallet/littlefs - joltwallet/littlefs
manifest_hash: 41e6c72fd10e152687d4a06741d6314e63ca2df7c6234cf03f4d27afda3d632a - lvgl/lvgl
manifest_hash: 96112412d371d78cc527b7d0904042e5a7ca7c4f25928de9483a1b53dd2a2f4e
target: esp32s3 target: esp32s3
version: 2.0.0 version: 2.0.0

View File

@@ -45,6 +45,31 @@ menu "CalendarInk Network Configuration"
The IP address to send UDP logs to via port 514. The IP address to send UDP logs to via port 514.
If left blank, logs will be broadcast to 255.255.255.255. If left blank, logs will be broadcast to 255.255.255.255.
choice CALENDINK_WIFI_PS_MODE
prompt "WiFi Power Save Mode"
default CALENDINK_WIFI_PS_NONE
help
Select the WiFi power save mode to balance power consumption and network stability.
config CALENDINK_WIFI_PS_NONE
bool "None (No power save, highest consumption)"
config CALENDINK_WIFI_PS_MIN_MODEM
bool "Minimum Modem (Wakes on beacon, balanced)"
config CALENDINK_WIFI_PS_MAX_MODEM
bool "Maximum Modem (Lowest consumption, may drop connection on strict routers)"
endchoice
config CALENDINK_ALLOW_LIGHT_SLEEP
bool "Allow Light Sleep (Tickless Idle)"
default n
help
If enabled, the device will heavily use light sleep to reduce power
consumption. Note that this may BREAK the UART console monitor since the
CPU sleeps and halts the UART! Use UDP logging if you need logs
while light sleep is enabled.
endmenu endmenu
menu "Calendink Web Server" menu "Calendink Web Server"
@@ -63,4 +88,16 @@ menu "Calendink Web Server"
help help
VFS path where the LittleFS partition is mounted. VFS path where the LittleFS partition is mounted.
config CALENDINK_DISPLAY_WIDTH
int "LVGL Display Width"
default 800
help
Width of the virtual LVGL display used for image generation.
config CALENDINK_DISPLAY_HEIGHT
int "LVGL Display Height"
default 480
help
Height of the virtual LVGL display used for image generation.
endmenu endmenu

View File

@@ -0,0 +1,113 @@
#include "../../lv_setup.hpp"
#include "../../lodepng/lodepng.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_random.h"
#include "lvgl.h"
#include <string.h>
#include "../../lodepng_alloc.hpp"
internal const char *kTagDisplayImage = "API_DISPLAY_IMAGE";
internal esp_err_t api_display_image_handler(httpd_req_t *req)
{
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
// We are generating PNG on the fly, don't let it be cached locally immediately
httpd_resp_set_hdr(req, "Cache-Control", "no-cache, no-store, must-revalidate");
httpd_resp_set_type(req, "image/png");
if (xSemaphoreTake(g_LvglMutex, pdMS_TO_TICKS(5000)) != pdTRUE)
{
ESP_LOGE(kTagDisplayImage, "Failed to get LVGL mutex");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "LVGL busy");
return ESP_FAIL;
}
// Change the background color securely to a random grayscale value
// esp_random() returns 32 bits, we just take the lowest 8.
uint8_t rand_gray = esp_random() & 0xFF;
lv_obj_t* active_screen = lv_screen_active();
lv_obj_set_style_bg_color(active_screen, lv_color_make(rand_gray, rand_gray, rand_gray), LV_PART_MAIN);
// Force a screen refresh to get the latest rendered frame
lv_refr_now(g_LvglDisplay);
lv_draw_buf_t *draw_buf = lv_display_get_buf_active(g_LvglDisplay);
if (!draw_buf)
{
xSemaphoreGive(g_LvglMutex);
ESP_LOGE(kTagDisplayImage, "No active draw buffer available");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Display uninitialized");
return ESP_FAIL;
}
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
// LodePNG expects tightly packed data without stride padding.
// Ensure we copy the data if stride differs from width.
uint8_t *packed_data = (uint8_t *)draw_buf->data;
bool needs_free = false;
if (draw_buf->header.stride != width)
{
ESP_LOGI(kTagDisplayImage, "Stride %lu differs from width %lu. Repacking buffer...", draw_buf->header.stride, width);
packed_data = (uint8_t *)heap_caps_malloc(width * height, MALLOC_CAP_SPIRAM);
if (!packed_data)
{
xSemaphoreGive(g_LvglMutex);
ESP_LOGE(kTagDisplayImage, "Failed to allocate packed buffer in PSRAM");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
needs_free = true;
for (uint32_t y = 0; y < height; ++y)
{
memcpy(packed_data + (y * width), (uint8_t *)draw_buf->data + (y * draw_buf->header.stride), width);
}
}
// Convert LVGL 8-bit L8 buffer to 8-bit grayscale PNG using LodePNG.
// LCT_GREY = 0, bitdepth = 8
unsigned char *png = nullptr;
size_t pngsize = 0;
// We are about to start a huge memory operation inside LodePNG.
// We reset our 2MB PSRAM bump allocator to 0 bytes used.
lodepng_allocator_reset();
ESP_LOGI(kTagDisplayImage, "Encoding %lux%lu frame to PNG...", width, height);
unsigned error = lodepng_encode_memory(&png, &pngsize, packed_data, width, height, LCT_GREY, 8);
if (needs_free)
{
free(packed_data);
}
xSemaphoreGive(g_LvglMutex);
if (error)
{
ESP_LOGE(kTagDisplayImage, "PNG encoding error %u: %s", error, lodepng_error_text(error));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "PNG generation failed");
return ESP_FAIL;
}
ESP_LOGI(kTagDisplayImage, "Prepared PNG, size: %zu bytes. Sending to client...", pngsize);
esp_err_t res = httpd_resp_send(req, (const char *)png, pngsize);
// No need to free(png) because it is managed by our bump allocator
// which automatically resets the entire 2MB buffer to 0 next time
// lodepng_allocator_reset() is called.
return res;
}
httpd_uri_t api_display_image_uri = {
.uri = "/api/display/image.png",
.method = HTTP_GET,
.handler = api_display_image_handler,
.user_ctx = NULL};

View File

@@ -0,0 +1,2 @@
// Unity build entry for display endpoints
#include "image.cpp"

View File

@@ -343,8 +343,14 @@ internal esp_err_t connect_wifi(const char *ssid, const char *password,
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start()); ESP_ERROR_CHECK(esp_wifi_start());
// Reverting to MIN_MODEM because MAX_MODEM causes disconnects on many routers // Set Power Save mode based on sdkconfig selection
#if defined(CONFIG_CALENDINK_WIFI_PS_MAX_MODEM)
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MAX_MODEM)); ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MAX_MODEM));
#elif defined(CONFIG_CALENDINK_WIFI_PS_NONE)
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
#else
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_MIN_MODEM));
#endif
ESP_ERROR_CHECK(esp_wifi_connect()); ESP_ERROR_CHECK(esp_wifi_connect());

View File

@@ -19,6 +19,7 @@
#include "api/ota/status.cpp" #include "api/ota/status.cpp"
#include "api/system/info.cpp" #include "api/system/info.cpp"
#include "api/system/reboot.cpp" #include "api/system/reboot.cpp"
#include "api/display/unity.cpp"
#include "api/tasks/unity.cpp" #include "api/tasks/unity.cpp"
#include "api/users/unity.cpp" #include "api/users/unity.cpp"
@@ -266,6 +267,7 @@ internal httpd_handle_t start_webserver(void)
config.max_uri_handlers = 20; config.max_uri_handlers = 20;
config.max_open_sockets = 24; config.max_open_sockets = 24;
config.lru_purge_enable = true; config.lru_purge_enable = true;
config.stack_size = 16384;
httpd_handle_t server = NULL; httpd_handle_t server = NULL;
ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port); ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port);
@@ -282,6 +284,7 @@ internal httpd_handle_t start_webserver(void)
// Register system API routes // Register system API routes
httpd_register_uri_handler(server, &api_system_info_uri); httpd_register_uri_handler(server, &api_system_info_uri);
httpd_register_uri_handler(server, &api_system_reboot_uri); httpd_register_uri_handler(server, &api_system_reboot_uri);
httpd_register_uri_handler(server, &api_display_image_uri);
httpd_register_uri_handler(server, &api_ota_status_uri); httpd_register_uri_handler(server, &api_ota_status_uri);
httpd_register_uri_handler(server, &api_ota_frontend_uri); httpd_register_uri_handler(server, &api_ota_frontend_uri);
httpd_register_uri_handler(server, &api_ota_firmware_uri); httpd_register_uri_handler(server, &api_ota_firmware_uri);

View File

@@ -18,3 +18,4 @@ dependencies:
espressif/mdns: ^1.4.1 espressif/mdns: ^1.4.1
espressif/ethernet_init: ^1.3.0 espressif/ethernet_init: ^1.3.0
joltwallet/littlefs: "^1.20" # https://github.com/joltwallet/esp_littlefs joltwallet/littlefs: "^1.20" # https://github.com/joltwallet/esp_littlefs
lvgl/lvgl: "^9.2.0"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
#include "lodepng_alloc.hpp"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include <string.h>
// LVGL's LodePNG memory optimization
// Instead of standard heap allocations which fragment quickly and crash on the ESP32,
// we allocate a single massive buffer in PSRAM and just bump a pointer during encode!
static const char* kTagLodeAlloc = "LODE_ALLOC";
// 2MB buffer for LodePNG encoding intermediate state.
// A typical 800x480 grayscale PNG should compress to ~50-100KB, but the dynamic window
// matching and filtering algorithms need a good amount of scratch space.
// We can tune this down to 1MB if 2MB is too aggressive, but PSRAM provides 8MB.
#define LODEPNG_ALLOC_POOL_SIZE (2 * 1024 * 1024)
static uint8_t* s_lodepng_pool = nullptr;
static size_t s_lodepng_pool_used = 0;
void lodepng_allocator_init()
{
if (s_lodepng_pool != nullptr) return;
ESP_LOGI(kTagLodeAlloc, "Allocating %d bytes in PSRAM for LodePNG bump allocator...", LODEPNG_ALLOC_POOL_SIZE);
// SPIRAM fallback to internal if someone tests without a PSRAM chip
s_lodepng_pool = (uint8_t*)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE, MALLOC_CAP_SPIRAM);
if (!s_lodepng_pool)
{
s_lodepng_pool = (uint8_t*)heap_caps_malloc(LODEPNG_ALLOC_POOL_SIZE, MALLOC_CAP_DEFAULT);
}
if (!s_lodepng_pool)
{
ESP_LOGE(kTagLodeAlloc, "CRITICAL: Failed to allocate LodePNG PSRAM pool!");
}
}
void lodepng_allocator_reset()
{
s_lodepng_pool_used = 0;
}
void lodepng_allocator_free()
{
if (s_lodepng_pool)
{
free(s_lodepng_pool);
s_lodepng_pool = nullptr;
}
s_lodepng_pool_used = 0;
}
// ----------------------------------------------------
// Custom Allocators injected into lodepng.c
// ----------------------------------------------------
// To support realloc properly, we prefix each allocation with an 8-byte header storing the size.
struct AllocHeader {
size_t size;
};
void* lodepng_custom_malloc(size_t size)
{
if (!s_lodepng_pool)
{
ESP_LOGE(kTagLodeAlloc, "lodepng_malloc called before lodepng_allocator_init!");
return nullptr;
}
// Align size to 8 bytes to avoid unaligned access faults
size_t aligned_size = (size + 7) & ~7;
size_t total_alloc = sizeof(AllocHeader) + aligned_size;
if (s_lodepng_pool_used + total_alloc > LODEPNG_ALLOC_POOL_SIZE)
{
ESP_LOGE(kTagLodeAlloc, "LodePNG pool exhausted! Requested: %zu, Used: %zu, Total: %d", size, s_lodepng_pool_used, LODEPNG_ALLOC_POOL_SIZE);
return nullptr;
}
// Grab pointer and bump
uint8_t* ptr = s_lodepng_pool + s_lodepng_pool_used;
s_lodepng_pool_used += total_alloc;
// Write header
AllocHeader* header = (AllocHeader*)ptr;
header->size = size; // We store exact size for realloc memcpy bounds
// Return pointer right after header
return ptr + sizeof(AllocHeader);
}
void* lodepng_custom_realloc(void* ptr, size_t new_size)
{
if (!ptr)
{
return lodepng_custom_malloc(new_size);
}
if (new_size == 0)
{
lodepng_custom_free(ptr);
return nullptr;
}
// Get original header
uint8_t* orig_ptr = (uint8_t*)ptr - sizeof(AllocHeader);
AllocHeader* header = (AllocHeader*)orig_ptr;
size_t old_size = header->size;
if (new_size <= old_size)
{
// Don't shrink to save time, bump allocator can't reclaim it easily anyway.
return ptr;
}
// Let's see if this ptr was the *very last* allocation.
// If so, we can just expand it in place!
size_t old_aligned_size = (old_size + 7) & ~7;
if (orig_ptr + sizeof(AllocHeader) + old_aligned_size == s_lodepng_pool + s_lodepng_pool_used)
{
// We are at the end! Just bump further!
size_t new_aligned_size = (new_size + 7) & ~7;
size_t size_diff = new_aligned_size - old_aligned_size;
if (s_lodepng_pool_used + size_diff > LODEPNG_ALLOC_POOL_SIZE)
{
ESP_LOGE(kTagLodeAlloc, "LodePNG pool exhausted during in-place realloc!");
return nullptr;
}
s_lodepng_pool_used += size_diff;
header->size = new_size;
return ptr;
}
// Otherwise, we have to copy into a new block
void* new_ptr = lodepng_custom_malloc(new_size);
if (new_ptr)
{
memcpy(new_ptr, ptr, old_size);
}
return new_ptr;
}
void lodepng_custom_free(void* ptr)
{
// No-op! The bump pointer will just reset to 0 once the API endpoint is done!
(void)ptr;
}

View File

@@ -0,0 +1,14 @@
#ifndef LODEPNG_ALLOC_HPP
#define LODEPNG_ALLOC_HPP
#include <stddef.h>
void lodepng_allocator_init();
void lodepng_allocator_reset();
void lodepng_allocator_free();
void* lodepng_custom_malloc(size_t size);
void* lodepng_custom_realloc(void* ptr, size_t new_size);
void lodepng_custom_free(void* ptr);
#endif // LODEPNG_ALLOC_HPP

106
Provider/main/lv_setup.cpp Normal file
View File

@@ -0,0 +1,106 @@
#include "lv_setup.hpp"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/task.h"
#include "types.hpp"
internal const char *kTagLvgl = "LVGL";
SemaphoreHandle_t g_LvglMutex = nullptr;
lv_display_t *g_LvglDisplay = nullptr;
uint8_t *g_LvglDrawBuffer = nullptr;
internal void lvgl_tick_task(void *arg)
{
while (true)
{
vTaskDelay(pdMS_TO_TICKS(10));
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
{
lv_timer_handler();
xSemaphoreGive(g_LvglMutex);
}
}
}
internal uint32_t my_tick_get_cb()
{
return (uint32_t)(esp_timer_get_time() / 1000);
}
internal void lv_dummy_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
{
// Headless display, so we don't actually flush to SPI/I2C.
// We just tell LVGL that the "flush" is completed so it unblocks wait_for_flushing.
lv_display_flush_ready(disp);
}
internal void lv_draw_sample_ui()
{
lv_obj_t *scr = lv_screen_active();
// Default background to white for the grayscale PNG
lv_obj_set_style_bg_color(scr, lv_color_white(), 0);
lv_obj_t *label = lv_label_create(scr);
lv_label_set_text(label, "Calendink Provider\nLVGL Headless Renderer");
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
}
void setup_lvgl()
{
ESP_LOGI(kTagLvgl, "Initializing LVGL");
g_LvglMutex = xSemaphoreCreateMutex();
lv_init();
lv_tick_set_cb(my_tick_get_cb);
uint32_t width = CONFIG_CALENDINK_DISPLAY_WIDTH;
uint32_t height = CONFIG_CALENDINK_DISPLAY_HEIGHT;
// Create a virtual display
g_LvglDisplay = lv_display_create(width, height);
lv_display_set_flush_cb(g_LvglDisplay, lv_dummy_flush_cb);
// Initialize LodePNG custom bump allocator
lodepng_allocator_init();
// Allocate draw buffers in PSRAM
// Using LV_COLOR_FORMAT_L8 (1 byte per pixel)
size_t buf_size = LV_DRAW_BUF_SIZE(width, height, LV_COLOR_FORMAT_L8);
// Fallback to MALLOC_CAP_DEFAULT if we can't get SPIRAM (for debugging without it)
void *buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
if (!buf1)
{
ESP_LOGW(kTagLvgl, "Failed to allocate LVGL draw buffer in PSRAM, falling back to internal RAM");
buf1 = heap_caps_malloc(buf_size, MALLOC_CAP_DEFAULT);
}
if (!buf1)
{
ESP_LOGE(kTagLvgl, "Failed to allocate LVGL draw buffer entirely.");
return;
}
g_LvglDrawBuffer = (uint8_t *)buf1;
lv_display_set_buffers(g_LvglDisplay, buf1, nullptr, buf_size, LV_DISPLAY_RENDER_MODE_FULL);
// Explicitly set the color format of the display if it's set in sdkconfig/driver
lv_display_set_color_format(g_LvglDisplay, LV_COLOR_FORMAT_L8);
// Create the background task for the LVGL timer
xTaskCreate(lvgl_tick_task, "LVGL Tick", 4096, nullptr, 5, nullptr);
// Draw the sample UI
if (xSemaphoreTake(g_LvglMutex, portMAX_DELAY) == pdTRUE)
{
lv_draw_sample_ui();
xSemaphoreGive(g_LvglMutex);
}
ESP_LOGI(kTagLvgl, "LVGL fully initialized. Display %lux%lu created.", width, height);
}

View File

@@ -0,0 +1,11 @@
#pragma once
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "lvgl.h"
extern SemaphoreHandle_t g_LvglMutex;
extern lv_display_t *g_LvglDisplay;
extern uint8_t *g_LvglDrawBuffer;
void setup_lvgl();

View File

@@ -26,6 +26,9 @@
#include "http_server.cpp" #include "http_server.cpp"
#include "mdns_service.cpp" #include "mdns_service.cpp"
#include "udp_logger.cpp" #include "udp_logger.cpp"
#include "lodepng_alloc.cpp"
#include "lodepng/lodepng.cpp"
#include "lv_setup.cpp"
// clang-format on // clang-format on
internal const char *kTagMain = "MAIN"; internal const char *kTagMain = "MAIN";
@@ -49,10 +52,17 @@ extern "C" void app_main()
ESP_LOGI(kTagMain, "PSRAM size: %d bytes", esp_psram_get_size()); ESP_LOGI(kTagMain, "PSRAM size: %d bytes", esp_psram_get_size());
#if CONFIG_PM_ENABLE #if CONFIG_PM_ENABLE
esp_pm_config_t pm_config = { esp_pm_config_t pm_config = {};
.max_freq_mhz = 240, .min_freq_mhz = 40, .light_sleep_enable = true}; pm_config.max_freq_mhz = 240;
pm_config.min_freq_mhz = 40;
#if CONFIG_CALENDINK_ALLOW_LIGHT_SLEEP
pm_config.light_sleep_enable = true;
#else
pm_config.light_sleep_enable = false;
#endif
esp_pm_configure(&pm_config); esp_pm_configure(&pm_config);
ESP_LOGI(kTagMain, "Dynamic Power Management initialized (Tickless Idle)."); ESP_LOGI(kTagMain, "Dynamic Power Management initialized. Light sleep %s.",
pm_config.light_sleep_enable ? "ENABLED" : "DISABLED");
#endif #endif
httpd_handle_t web_server = NULL; httpd_handle_t web_server = NULL;
@@ -271,6 +281,13 @@ extern "C" void app_main()
start_udp_logging(514); start_udp_logging(514);
#endif #endif
// Start LVGL
ESP_LOGI(kTagMain, "ABOUT TO START LVGL");
vTaskDelay(pdMS_TO_TICKS(500));
setup_lvgl();
ESP_LOGI(kTagMain, "LVGL STARTED");
vTaskDelay(pdMS_TO_TICKS(500));
// Start the webserver // Start the webserver
web_server = start_webserver(); web_server = start_webserver();

View File

@@ -4,3 +4,11 @@ CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
CONFIG_LWIP_MAX_SOCKETS=32 CONFIG_LWIP_MAX_SOCKETS=32
CONFIG_PM_ENABLE=y CONFIG_PM_ENABLE=y
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
# Enable PSRAM
CONFIG_SPIRAM=y
CONFIG_ESP32S3_SPIRAM_SUPPORT=y
# LVGL Configuration
CONFIG_LV_COLOR_DEPTH_8=y
CONFIG_LV_USE_SYSMON=n

View File

@@ -0,0 +1,75 @@
# LVGL Image Generation Architecture
**Authored by Antigravity**
**Date:** 2026-03-14
---
## 1. Goal
Integrate the Light and Versatile Graphics Library (LVGL) into the Calendink Provider to generate UI images server-side. The ESP32-S3 will render screens into a memory buffer and serve the resulting image (PNG) directly over the HTTP REST API to clients.
This allows the Provider to act as a "smart renderer" for dump clients like e-ink displays or web widgets that only have the capability to fetch and display static images. The display resolution (default 800x480) and color depth (4-level grayscale) are parameterized via Kconfig to support future hardware changes.
## 2. Chosen Approach
### 2.1. Headless LVGL Display Driver
LVGL is typically used to drive physical displays via SPI/I2C/RGB interfaces. For this use case, we will configure a **virtual (headless) display**:
- A display instance (`lv_display_t`) will be created without a physical flush callback (or a dummy one that does nothing).
- We will allocate a full-screen frame buffer (e.g., 800x480 at 8-bit grayscale). While small enough to fit in SRAM (~384 KB), we will use **PSRAM** to avoid memory pressure on the system.
- When an image is requested, we will force LVGL to update the screen (`lv_refr_now()`) and then read the raw pixel data directly from the draw buffer.
### 2.2. Image Encoding (PNG)
Raw pixel data is not web-friendly. To serve the image over HTTP, we will encode it as a **PNG** image.
- **Why PNG?** PNG is smaller over the network than BMP and natively supports lossless grayscale compression. Given the target of 4-level grayscale, a PNG will compress exceptionally well.
- **Encoder:** We will use `lodepng`, a lightweight single-file C library, to compress the raw LVGL buffer into a PNG on the fly.
- **Color Depth:** The target is **4-level grayscale**. We will map LVGL's output to a 2-bit grayscale PNG to minimize the payload size.
- **Parameterization:** Resolution (e.g., 800 width, 480 height) is configurable via Kconfig (`CONFIG_DISPLAY_WIDTH`, `CONFIG_DISPLAY_HEIGHT`) so it can be easily changed for different e-ink displays.
### 2.3. Thread Safety and Concurrency
LVGL is **not thread-safe**. Since the HTTP server runs its API handlers in different FreeRTOS tasks (from the `httpd` thread pool), we must protect LVGL with a **Mutex**.
- All LVGL initialization, UI setup (`lv_obj_create`, etc.), and the periodic `lv_timer_handler()` will take the `g_LvglMutex`.
- The `GET /api/display/image` API endpoint will acquire `g_LvglMutex`, draw the screen, encode the BMP header, transmit the buffer, and then release the mutex.
## 3. Architecture
### 3.1. File Structure
```
Provider/main/
├── lv_setup.hpp # LVGL initialization and mutex declarations
├── lv_setup.cpp # Driver setup, PSRAM buffer allocation, LVGL tick task
├── api/display/
│ ├── image.cpp # GET /api/display/image handler (BMP streamer)
│ └── unity.cpp # Aggregator for display endpoints
├── http_server.cpp # Modified to include api/display/unity.cpp
└── main.cpp # Starts LVGL task before HTTP server
```
### 3.2. Data Flow
1. **Client** makes a `GET /api/display/image` request.
2. **`image.cpp` handler** intercepts the request and takes the `g_LvglMutex`.
3. The handler forces LVGL to finish any pending rendering.
4. The handler uses `lodepng` to compress the raw frame buffer into a PNG payload.
5. The handler sends the PNG via `httpd_resp_send()`.
6. The handler frees the PNG payload buffer and releases `g_LvglMutex`.
7. The HTTP response completes.
## 4. Design Decisions & Trade-offs
| Decision | Trade-off | Rationale |
|---|---|---|
| **PSRAM Allocation** | Slower access than SRAM | A full framebuffer for high-res displays exceeds internal SRAM. PSRAM is required, and since we only read/write it occasionally for HTTP, the speed penalty is negligible compared to network latency. |
| **PNG over BMP** | More CPU overhead | PNG compression takes CPU cycles and temporary RAM, but significantly reduces network payload, which is better for web clients and saves transmission time. |
| **Pull vs Push** | Requires polling | The client must actively request the image. This is standard for web, but means the client won't know instantly if the UI state changes unless using WebSockets/SSE (future scope). |
| **Synchronous Render** | Blocks HTTP task | The HTTP handler waits for LVGL to finish drawing and PNG encoding to complete. Since LVGL renders very quickly in memory, this block should be acceptable. |
## 5. Next Steps
1. Add `lvgl/lvgl` to `idf_component.yml`.
2. Configure LVGL in `sdkconfig` to use custom memory allocation (routed to PSRAM).
3. Implement `lv_setup.cpp` and `api/display/image.cpp`.
4. Draw a sample UI on the virtual display.
---
*Created by Antigravity - Last Updated: 2026-03-14*