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

@@ -45,6 +45,31 @@ menu "CalendarInk Network Configuration"
The IP address to send UDP logs to via port 514.
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
menu "Calendink Web Server"
@@ -63,4 +88,16 @@ menu "Calendink Web Server"
help
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

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_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));
#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());

View File

@@ -19,6 +19,7 @@
#include "api/ota/status.cpp"
#include "api/system/info.cpp"
#include "api/system/reboot.cpp"
#include "api/display/unity.cpp"
#include "api/tasks/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_open_sockets = 24;
config.lru_purge_enable = true;
config.stack_size = 16384;
httpd_handle_t server = NULL;
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
httpd_register_uri_handler(server, &api_system_info_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_frontend_uri);
httpd_register_uri_handler(server, &api_ota_firmware_uri);

View File

@@ -18,3 +18,4 @@ dependencies:
espressif/mdns: ^1.4.1
espressif/ethernet_init: ^1.3.0
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 "mdns_service.cpp"
#include "udp_logger.cpp"
#include "lodepng_alloc.cpp"
#include "lodepng/lodepng.cpp"
#include "lv_setup.cpp"
// clang-format on
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());
#if CONFIG_PM_ENABLE
esp_pm_config_t pm_config = {
.max_freq_mhz = 240, .min_freq_mhz = 40, .light_sleep_enable = true};
esp_pm_config_t pm_config = {};
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_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
httpd_handle_t web_server = NULL;
@@ -271,6 +281,13 @@ extern "C" void app_main()
start_udp_logging(514);
#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
web_server = start_webserver();