Merge branch 'dev' into ota-inflate-table-decode

This commit is contained in:
J. Nick Koston authored and GitHub committed 2026-10-25 23:49:16 -10:00
commit 2df80fbf71
12 files changed
+545 -13

No files matched your search

+16
View File
@@ -1,6 +1,7 @@
#include "image.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -165,6 +166,21 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off
}
#ifdef USE_LVGL
lv_image_dsc_t *Image::get_lv_image_dsc() {
// No pixels yet. A widget keeps this descriptor and reads it on every draw, and LVGL warns on every draw of an image
// without data, so give it a transparent pixel instead
static constexpr uint8_t TRANSPARENT_PIXEL = 0;
if (this->data_start_ == nullptr) {
if (this->dsc_.data != &TRANSPARENT_PIXEL) {
memset(&this->dsc_, 0, sizeof(this->dsc_));
this->dsc_.header.cf = LV_COLOR_FORMAT_A8;
this->dsc_.header.w = 1;
this->dsc_.header.h = 1;
this->dsc_.header.stride = 1;
this->dsc_.data_size = 1;
this->dsc_.data = &TRANSPARENT_PIXEL;
}
return &this->dsc_;
}
// lazily construct lvgl image_dsc.
if (this->dsc_.data != this->data_start_) {
this->dsc_.data = this->data_start_;
+6 -1
View File
@@ -43,7 +43,12 @@ class Image : public display::BaseImage {
bool has_transparency() const { return this->transparency_ != TRANSPARENCY_OPAQUE; }
#ifdef USE_LVGL
lv_image_dsc_t *get_lv_image_dsc();
virtual lv_image_dsc_t *get_lv_image_dsc();
#endif
#ifdef USE_LVGL_IMAGE
/// Called when an LVGL image widget is given this image. An image whose size can change keeps the widget, so it can
/// give the widget its source again when the size changes: the widget keeps the size it read from the descriptor.
virtual void add_lvgl_widget(lv_obj_t *obj) {}
#endif
protected:
/// Per-pixel draw of the clipped range, rows (true) or columns (false) outer.
+4 -1
View File
@@ -82,7 +82,10 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) {
::lv_image_set_src(obj, image->get_lv_image_dsc());
image->add_lvgl_widget(obj);
}
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
+3
View File
@@ -42,6 +42,7 @@ ONLINE_IMAGE_SCHEMA = (
# AUTO (Content-Type detection) is online_image specific; not in the shared registry
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, "AUTO", upper=True),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(runtime_image.CONF_DOUBLE_BUFFER, default=False): cv.boolean,
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
@@ -141,6 +142,8 @@ async def setup_online_image(config: ConfigType) -> None:
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
if settings.double_buffer:
cg.add(var.set_double_buffer(True))
for key, value in config.get(CONF_REQUEST_HEADERS, {}).items():
if isinstance(value, Lambda):
@@ -428,9 +428,8 @@ void OnlineImage::finish_download_() {
void OnlineImage::end_connection_() {
// Abort any in-progress decode; the decoder object is kept warm for the next decode.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
if (this->is_decoding()) {
RuntimeImage::release();
this->abort_decode();
}
if (this->downloader_) {
this->downloader_->end();
@@ -19,6 +19,7 @@ AUTO_LOAD = ["image"]
CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"]
DOMAIN = "runtime_image"
CONF_DOUBLE_BUFFER = "double_buffer"
CONF_PLACEHOLDER = "placeholder"
CONF_TRANSPARENCY = "transparency"
@@ -212,6 +213,7 @@ class RuntimeImageSettings:
transparent: cg.MockObj
byte_order_big_endian: bool
placeholder: cg.MockObj | None
double_buffer: bool
async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
@@ -256,4 +258,5 @@ async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
transparent=transparent,
byte_order_big_endian=byte_order_big_endian,
placeholder=placeholder,
double_buffer=config.get(CONF_DOUBLE_BUFFER, False),
)
@@ -3,6 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <utility>
#include <cstdint>
#include <cstring>
#include <limits>
@@ -48,7 +49,14 @@ RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Tra
fixed_height_(fixed_height),
placeholder_(placeholder) {}
RuntimeImage::~RuntimeImage() { this->release(); }
RuntimeImage::~RuntimeImage() {
#ifdef USE_LVGL_IMAGE
for (lv_obj_t *obj : this->lvgl_widgets_)
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
this->lvgl_widgets_.clear();
#endif
this->release();
}
int RuntimeImage::resize(int width, int height) {
// Use fixed dimensions if specified (0 means auto-resize)
@@ -69,6 +77,7 @@ int RuntimeImage::resize(int width, int height) {
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
return result;
}
@@ -176,6 +185,52 @@ void RuntimeImage::map_chroma_key(Color &color) {
}
}
#ifdef USE_LVGL
lv_image_dsc_t *RuntimeImage::get_lv_image_dsc() {
if (this->data_start_ == nullptr && this->placeholder_ != nullptr) {
this->dsc_ = *this->placeholder_->get_lv_image_dsc();
return &this->dsc_;
}
return Image::get_lv_image_dsc();
}
#endif
#ifdef USE_LVGL_IMAGE
void RuntimeImage::add_lvgl_widget(lv_obj_t *obj) {
if (std::find(this->lvgl_widgets_.begin(), this->lvgl_widgets_.end(), obj) != this->lvgl_widgets_.end())
return;
this->lvgl_widgets_.push_back(obj);
lv_obj_add_event_cb(obj, lvgl_widget_deleted, LV_EVENT_DELETE, this);
}
void RuntimeImage::lvgl_widget_deleted(lv_event_t *event) {
auto *image = static_cast<RuntimeImage *>(lv_event_get_user_data(event));
auto &widgets = image->lvgl_widgets_;
widgets.erase(std::remove(widgets.begin(), widgets.end(), lv_event_get_target_obj(event)), widgets.end());
}
#endif
void RuntimeImage::refresh_lvgl_() {
#ifdef USE_LVGL
// Not a virtual call: this also runs from the destructor
lv_image_dsc_t *dsc = this->RuntimeImage::get_lv_image_dsc();
#ifdef USE_LVGL_IMAGE
auto &widgets = this->lvgl_widgets_;
for (size_t i = 0; i < widgets.size();) {
lv_obj_t *obj = widgets[i];
if (lv_image_get_src(obj) == dsc) {
// Setting the same source makes the widget read the size again, and redraws it
::lv_image_set_src(obj, dsc);
i++;
} else {
lv_obj_remove_event_cb_with_user_data(obj, lvgl_widget_deleted, this);
widgets.erase(widgets.begin() + i);
}
}
#endif
#endif
}
void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
if (this->data_start_) {
// If we have a complete image, use the base class draw method
@@ -263,11 +318,21 @@ bool RuntimeImage::end_decode() {
}
// Finalize the image for display
if (!this->progressive_display_) {
if (this->is_double_buffered_()) {
if (this->buffer_ != nullptr) {
// Show the new image, and decode into the old one's buffer next time
std::swap(this->front_buffer_, this->buffer_);
std::swap(this->width_, this->buffer_width_);
std::swap(this->height_, this->buffer_height_);
this->data_start_ = this->front_buffer_;
this->refresh_lvgl_();
}
} else if (!this->progressive_display_) {
// Only now make the image visible
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
this->refresh_lvgl_();
}
// End the session; the decoder object stays warm so the next decode can
@@ -284,6 +349,15 @@ bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && thi
void RuntimeImage::release() {
this->release_buffer_();
if (this->front_buffer_) {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->front_buffer_, this->get_buffer_size(this->width_, this->height_));
this->front_buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
// End any active decode session; decoders free the format-specific working buffers
// they can (PNG), while the decoder object itself is kept warm for the next decode.
if (this->decoder_) {
@@ -291,6 +365,16 @@ void RuntimeImage::release() {
}
}
void RuntimeImage::abort_decode() {
if (!this->is_double_buffered_()) {
this->release();
return;
}
if (this->decoder_) {
this->decoder_->reset();
}
}
void RuntimeImage::release_buffer_() {
if (this->buffer_) {
if (this->external_buffer_) {
@@ -302,19 +386,25 @@ void RuntimeImage::release_buffer_() {
RAMAllocator<uint8_t> allocator;
allocator.deallocate(this->buffer_, this->get_buffer_size(this->buffer_width_, this->buffer_height_));
}
const bool shown = this->data_start_ == this->buffer_;
this->buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
#ifdef USE_LVGL
memset(&this->dsc_, 0, sizeof(this->dsc_));
#endif
// With double buffering the buffer decoded into is not the one on show
if (shown) {
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->refresh_lvgl_();
}
}
}
bool RuntimeImage::set_external_buffer(uint8_t *buffer, int width, int height) {
if (this->is_double_buffered_()) {
ESP_LOGE(TAG, "An external buffer cannot be double buffered");
return false;
}
this->release_buffer_();
if (buffer == nullptr || this->get_buffer_size(width, height) == 0) {
// Keep the released state rather than remembering a buffer that cannot be decoded into: an
@@ -3,6 +3,8 @@
#include "esphome/components/image/image.h"
#include "esphome/core/helpers.h"
#include <vector>
#include "image_decoder.h"
#include "image_format.h"
@@ -54,6 +56,13 @@ class RuntimeImage : public image::Image {
void map_chroma_key(Color &color);
int get_buffer_width() const { return this->buffer_width_; }
int get_buffer_height() const { return this->buffer_height_; }
#ifdef USE_LVGL
/// Until an image is loaded, the placeholder's descriptor if there is one
lv_image_dsc_t *get_lv_image_dsc() override;
#endif
#ifdef USE_LVGL_IMAGE
void add_lvgl_widget(lv_obj_t *obj) override;
#endif
// Image drawing interface
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
@@ -118,7 +127,9 @@ class RuntimeImage : public image::Image {
/**
* @brief Check if an image is currently loaded.
*/
bool is_loaded() const { return this->buffer_ != nullptr; }
bool is_loaded() const {
return this->is_double_buffered_() ? this->front_buffer_ != nullptr : this->buffer_ != nullptr;
}
/**
* @brief Get the image format.
@@ -134,6 +145,14 @@ class RuntimeImage : public image::Image {
*/
void release();
/**
* @brief End a decode that will not be finished.
*
* With double buffering the image on show stays and the buffer decoded into is kept for the next
* decode. Otherwise the same as release(), since the image on show was being decoded over.
*/
void abort_decode();
/**
* @brief Decode into a buffer the caller owns, instead of one allocated here.
*
@@ -157,6 +176,8 @@ class RuntimeImage : public image::Image {
* A null buffer or dimensions the image cannot decode at are refused, leaving the image with
* no buffer at all.
*
* Refused when double buffering is on.
*
* @param buffer Memory for a picture of the given size, at least get_buffer_size() bytes.
* @param width Width of the buffer in pixels.
* @param height Height of the buffer in pixels.
@@ -179,6 +200,13 @@ class RuntimeImage : public image::Image {
*/
void set_progressive_display(bool progressive) { this->progressive_display_ = progressive; }
/**
* @brief Set whether to decode into a second buffer, keeping the image on show until the new one is complete.
*
* Both buffers are kept, so a new image of the same size allocates nothing. Ignored with progressive display.
*/
void set_double_buffer(bool double_buffer) { this->double_buffer_ = double_buffer; }
protected:
/**
* @brief Resize the image buffer to the requested dimensions.
@@ -196,6 +224,11 @@ class RuntimeImage : public image::Image {
*/
void release_buffer_();
bool is_double_buffered_() const { return this->double_buffer_ && !this->progressive_display_; }
/// Bring the LVGL descriptor up to date after the image shown changed, and give it again to the widgets showing it
void refresh_lvgl_();
/**
* @brief Get the position in the buffer for a pixel.
*/
@@ -210,6 +243,8 @@ class RuntimeImage : public image::Image {
// Memory management
uint8_t *buffer_{nullptr};
/// With double buffering, the buffer on show, width_ by height_. buffer_ is then the one decoded into
uint8_t *front_buffer_{nullptr};
// Decoder management
std::unique_ptr<ImageDecoder> decoder_{nullptr};
@@ -246,8 +281,14 @@ class RuntimeImage : public image::Image {
// Configuration
bool progressive_display_{false};
bool double_buffer_{false};
/** Whether buffer_ belongs to the caller, so it must not be freed or resized here. */
bool external_buffer_{false};
#ifdef USE_LVGL_IMAGE
static void lvgl_widget_deleted(lv_event_t *event);
/// Widgets given this image. One moved to another image is dropped at the next refresh
std::vector<lv_obj_t *> lvgl_widgets_;
#endif
friend class ImageDecoder;
};
@@ -42,6 +42,7 @@ image:
format: PNG
type: RGB
transparency: chroma_key
double_buffer: true
- platform: online_image
id: online_rgb111_image
url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png
@@ -1,3 +1,5 @@
import esphome.codegen as cg
from esphome.components.lvgl import LVGL_VERSION
from esphome.components.runtime_image import enable_format
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
@@ -12,5 +14,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
enable_format("JPEG")
enable_format("PNG")
enable_format("QOI")
# The LVGL image descriptor is tested too, so build with the LVGL library
cg.add_library("lvgl/lvgl", LVGL_VERSION)
cg.add_build_flag("-DLV_CONF_SKIP=1")
cg.add_define("USE_LVGL")
cg.add_define("USE_LVGL_IMAGE")
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["lvgl"]
@@ -0,0 +1,146 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::testing {
// 3x2 24bpp BMP (rows padded to 4 bytes)
static const uint8_t BMP_3X2[] = {
0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00,
0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00,
};
// 2x1 24bpp BMP (row padded to 8 bytes)
static const uint8_t BMP_2X1[] = {
0x42, 0x4D, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x02, 0x01, 0x06, 0x05, 0x04, 0x00, 0x00,
};
template<size_t N> static void load(RuntimeImage &img, const uint8_t (&bmp)[N]) {
std::vector<uint8_t> data(bmp, bmp + N);
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
}
class DoubleBufferedImage : public RuntimeImage {
public:
DoubleBufferedImage() : RuntimeImage(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE) {
this->set_double_buffer(true);
}
};
// The image on show is not decoded over: the new one appears only when it is complete
TEST(RuntimeImageDoubleBuffer, ImageOnShowStaysUntilTheNextIsDecoded) {
DoubleBufferedImage img;
load(img, BMP_3X2);
const uint8_t *first = img.get_data_start();
ASSERT_NE(first, nullptr);
const std::vector<uint8_t> shown(first, first + 3 * 2 * 3);
std::vector<uint8_t> data(BMP_2X1, BMP_2X1 + sizeof(BMP_2X1));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
EXPECT_EQ(img.get_data_start(), first);
EXPECT_EQ(img.get_width(), 3);
EXPECT_EQ(img.get_height(), 2);
EXPECT_EQ(std::vector<uint8_t>(first, first + shown.size()), shown);
ASSERT_TRUE(img.end_decode());
EXPECT_NE(img.get_data_start(), first);
EXPECT_EQ(img.get_width(), 2);
EXPECT_EQ(img.get_height(), 1);
}
// Images of one size take turns in the same two buffers
TEST(RuntimeImageDoubleBuffer, SameSizeImagesReuseBothBuffers) {
DoubleBufferedImage img;
load(img, BMP_3X2);
const uint8_t *first = img.get_data_start();
load(img, BMP_3X2);
const uint8_t *second = img.get_data_start();
EXPECT_NE(second, first);
load(img, BMP_3X2);
EXPECT_EQ(img.get_data_start(), first);
load(img, BMP_3X2);
EXPECT_EQ(img.get_data_start(), second);
}
TEST(RuntimeImageDoubleBuffer, AbortedDecodeLeavesTheImageOnShow) {
DoubleBufferedImage img;
load(img, BMP_3X2);
const uint8_t *first = img.get_data_start();
std::vector<uint8_t> data(BMP_2X1, BMP_2X1 + sizeof(BMP_2X1));
ASSERT_TRUE(img.begin_decode(data.size()));
img.feed_data(data.data(), data.size() / 2 + 8);
img.abort_decode();
EXPECT_FALSE(img.is_decoding());
EXPECT_EQ(img.get_data_start(), first);
EXPECT_EQ(img.get_width(), 3);
EXPECT_TRUE(img.is_loaded());
load(img, BMP_2X1);
EXPECT_EQ(img.get_width(), 2);
}
// An aborted first decode leaves nothing on show
TEST(RuntimeImageDoubleBuffer, AbortedFirstDecodeIsNotLoaded) {
DoubleBufferedImage img;
std::vector<uint8_t> data(BMP_3X2, BMP_3X2 + sizeof(BMP_3X2));
ASSERT_TRUE(img.begin_decode(data.size()));
img.feed_data(data.data(), data.size() / 2 + 8);
img.abort_decode();
EXPECT_EQ(img.get_data_start(), nullptr);
EXPECT_FALSE(img.is_loaded());
}
// Without double buffering an aborted decode has drawn over the image, so it goes
TEST(RuntimeImageDoubleBuffer, AbortedDecodeWithOneBufferReleasesTheImage) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
load(img, BMP_3X2);
std::vector<uint8_t> data(BMP_3X2, BMP_3X2 + sizeof(BMP_3X2));
ASSERT_TRUE(img.begin_decode(data.size()));
img.feed_data(data.data(), data.size() / 2 + 8);
img.abort_decode();
EXPECT_EQ(img.get_data_start(), nullptr);
EXPECT_FALSE(img.is_loaded());
}
// Both buffers are freed; LeakSanitizer reports one that is not
TEST(RuntimeImageDoubleBuffer, ReleaseFreesBothBuffers) {
DoubleBufferedImage img;
load(img, BMP_3X2);
load(img, BMP_3X2);
img.release();
EXPECT_EQ(img.get_data_start(), nullptr);
EXPECT_EQ(img.get_width(), 0);
EXPECT_FALSE(img.is_loaded());
}
TEST(RuntimeImageDoubleBuffer, ExternalBufferIsRefused) {
DoubleBufferedImage img;
std::vector<uint8_t> buffer(img.get_buffer_size(3, 2));
EXPECT_FALSE(img.set_external_buffer(buffer.data(), 3, 2));
load(img, BMP_3X2);
EXPECT_NE(img.get_data_start(), buffer.data());
}
// Progressive display turns double buffering off, so an external buffer is allowed
TEST(RuntimeImageDoubleBuffer, ExternalBufferIsAllowedWithProgressiveDisplay) {
DoubleBufferedImage img;
img.set_progressive_display(true);
std::vector<uint8_t> buffer(img.get_buffer_size(3, 2));
EXPECT_TRUE(img.set_external_buffer(buffer.data(), 3, 2));
img.release();
}
} // namespace esphome::runtime_image::testing
@@ -0,0 +1,217 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/runtime_image/runtime_image.h"
#ifdef USE_LVGL
namespace esphome::runtime_image::testing {
// 3x2 24bpp BMP (rows padded to 4 bytes)
static const uint8_t BMP_24BPP[] = {
0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0xC4, 0x0E, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x22, 0x11, 0x77, 0x88, 0x99, 0xEF, 0xCD, 0xAB, 0x00,
0x00, 0x00, 0x20, 0x10, 0xE0, 0x40, 0xC0, 0x30, 0xA0, 0x60, 0x50, 0x00, 0x00, 0x00,
};
// A widget keeps the descriptor it was given and reads it on every draw. Once the image lets go of its pixels,
// for example when it starts loading another image, the descriptor must still be one LVGL can draw.
TEST(RuntimeImageLvgl, ReleasedImageLeavesADrawableDescriptor) {
lv_init();
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
lv_image_dsc_t *dsc = img.get_lv_image_dsc();
lv_image_header_t header;
ASSERT_EQ(lv_image_decoder_get_info(dsc, &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 3);
EXPECT_EQ(header.h, 2);
img.release();
EXPECT_EQ(lv_image_decoder_get_info(dsc, &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 1);
EXPECT_EQ(header.h, 1);
lv_deinit();
}
// Before the first image is loaded, a widget gets a descriptor it can draw too
TEST(RuntimeImageLvgl, UnloadedImageHasADrawableDescriptor) {
lv_init();
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
lv_image_header_t header;
ASSERT_EQ(lv_image_decoder_get_info(img.get_lv_image_dsc(), &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 1);
EXPECT_EQ(header.h, 1);
lv_deinit();
}
// With a placeholder, a widget shows it before the first image and while the next one loads
TEST(RuntimeImageLvgl, PlaceholderIsShownWithoutAnImage) {
lv_init();
static constexpr uint8_t PLACEHOLDER_PIXELS[4 * 2 * 3] = {};
image::Image placeholder(PLACEHOLDER_PIXELS, 4, 2, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, &placeholder);
lv_image_dsc_t *dsc = img.get_lv_image_dsc();
EXPECT_EQ(dsc->data, PLACEHOLDER_PIXELS);
lv_image_header_t header;
ASSERT_EQ(lv_image_decoder_get_info(dsc, &header), LV_RESULT_OK);
EXPECT_EQ(header.w, 4);
EXPECT_EQ(header.h, 2);
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
EXPECT_EQ(img.get_lv_image_dsc(), dsc);
EXPECT_EQ(dsc->data, img.get_data_start());
EXPECT_EQ(dsc->header.w, 3);
img.release();
EXPECT_EQ(dsc->data, PLACEHOLDER_PIXELS);
EXPECT_EQ(dsc->header.w, 4);
EXPECT_EQ(dsc->header.h, 2);
lv_deinit();
}
#ifdef USE_LVGL_IMAGE
static constexpr int32_t SCREEN_WIDTH = 16;
static constexpr int32_t SCREEN_HEIGHT = 8;
static void flush_ready(lv_display_t *display, const lv_area_t *area, uint8_t *px_map) {
lv_display_flush_ready(display);
}
// A display to draw on, so ASan sees any read LVGL makes past an image's pixels
class RuntimeImageLvglWidget : public ::testing::Test {
protected:
void SetUp() override {
lv_init();
this->display_ = lv_display_create(SCREEN_WIDTH, SCREEN_HEIGHT);
lv_display_set_flush_cb(this->display_, flush_ready);
lv_display_set_buffers(this->display_, this->draw_buffer_.data(), nullptr, this->draw_buffer_.size(),
LV_DISPLAY_RENDER_MODE_FULL);
this->widget_ = lv_image_create(lv_screen_active());
}
void TearDown() override { lv_deinit(); }
// What the lvgl component's lv_image_set_src() overload does
void set_src_(image::Image *image) {
::lv_image_set_src(this->widget_, image->get_lv_image_dsc());
image->add_lvgl_widget(this->widget_);
}
static void load(RuntimeImage &img) {
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
ASSERT_TRUE(img.end_decode());
}
// Redraw the widget, as something drawn over it would make LVGL do during a load
void draw_() {
lv_obj_invalidate(this->widget_);
lv_refr_now(this->display_);
}
lv_display_t *display_{};
lv_obj_t *widget_{};
std::vector<uint8_t> draw_buffer_ = std::vector<uint8_t>(SCREEN_WIDTH * SCREEN_HEIGHT * 4);
};
// The widget keeps the size it read when given the descriptor, and draws that much of whatever the descriptor holds.
// The image gives the widget its source again whenever what it shows changes size.
TEST_F(RuntimeImageLvglWidget, WidgetFollowsTheImageSize) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
this->set_src_(&img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 1);
load(img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
EXPECT_EQ(lv_image_get_src_height(this->widget_), 2);
this->draw_();
img.release();
EXPECT_EQ(lv_image_get_src_width(this->widget_), 1);
EXPECT_EQ(lv_image_get_src_height(this->widget_), 1);
this->draw_();
load(img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
this->draw_();
}
// The placeholder is smaller than the image, and on the heap, so drawing it at the image's size trips ASan
TEST_F(RuntimeImageLvglWidget, WidgetFollowsThePlaceholderSize) {
std::vector<uint8_t> placeholder_pixels(2 * 1 * 3);
image::Image placeholder(placeholder_pixels.data(), 2, 1, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, &placeholder);
load(img);
this->set_src_(&img);
this->draw_();
img.release();
EXPECT_EQ(lv_image_get_src_width(this->widget_), 2);
EXPECT_EQ(lv_image_get_src_height(this->widget_), 1);
this->draw_();
}
// A widget given another image is left alone, however many times it was given this one
TEST_F(RuntimeImageLvglWidget, WidgetGivenAnotherImageIsLeftAlone) {
static constexpr uint8_t OTHER_PIXELS[4 * 5 * 3] = {};
image::Image other(OTHER_PIXELS, 4, 5, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
load(img);
this->set_src_(&img);
this->set_src_(&img);
this->set_src_(&other);
img.release();
EXPECT_EQ(lv_image_get_src(this->widget_), other.get_lv_image_dsc());
EXPECT_EQ(lv_image_get_src_width(this->widget_), 4);
// Given back, it is followed again
this->set_src_(&img);
load(img);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
}
// With double buffering the widget shows the old image while the new one decodes, then the new one at its size
TEST_F(RuntimeImageLvglWidget, DoubleBufferedWidgetKeepsTheImageWhileDecoding) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
img.set_double_buffer(true);
load(img);
this->set_src_(&img);
const void *first = static_cast<const lv_image_dsc_t *>(lv_image_get_src(this->widget_))->data;
std::vector<uint8_t> data(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
ASSERT_TRUE(img.begin_decode(data.size()));
ASSERT_EQ(img.feed_data(data.data(), data.size()), static_cast<int>(data.size()));
EXPECT_EQ(static_cast<const lv_image_dsc_t *>(lv_image_get_src(this->widget_))->data, first);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
this->draw_();
ASSERT_TRUE(img.end_decode());
EXPECT_NE(static_cast<const lv_image_dsc_t *>(lv_image_get_src(this->widget_))->data, first);
EXPECT_EQ(lv_image_get_src_width(this->widget_), 3);
this->draw_();
}
TEST_F(RuntimeImageLvglWidget, DeletedWidgetIsForgotten) {
RuntimeImage img(BMP, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
load(img);
this->set_src_(&img);
lv_obj_delete(this->widget_);
img.release();
}
#endif // USE_LVGL_IMAGE
} // namespace esphome::runtime_image::testing
#endif // USE_LVGL