From 5177972c041d75bf00351c7f6d2cb250253c19e5 Mon Sep 17 00:00:00 2001 From: guillempages Date: Fri, 21 Aug 2026 00:27:44 +0200 Subject: [PATCH] [runtime_image] keep decoder allocated (#18488) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 2 +- esphome/components/runtime_image/__init__.py | 5 + .../components/runtime_image/bmp_decoder.cpp | 21 +- .../components/runtime_image/bmp_decoder.h | 12 +- .../components/runtime_image/image_decoder.h | 34 +- .../components/runtime_image/image_format.h | 19 + .../components/runtime_image/jpeg_decoder.cpp | 6 - .../components/runtime_image/jpeg_decoder.h | 3 +- .../components/runtime_image/png_decoder.cpp | 7 +- .../components/runtime_image/png_decoder.h | 8 + .../runtime_image/runtime_image.cpp | 53 +-- .../components/runtime_image/runtime_image.h | 34 +- .../sendspin/image/sendspin_image.cpp | 4 +- tests/components/runtime_image/__init__.py | 15 + .../runtime_image/test_decoder_reuse.cpp | 336 ++++++++++++++++++ 15 files changed, 487 insertions(+), 72 deletions(-) create mode 100644 esphome/components/runtime_image/image_format.h create mode 100644 tests/components/runtime_image/__init__.py create mode 100644 tests/components/runtime_image/test_decoder_reuse.cpp diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 22bce4cc41..fe4f727cd6 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -216,7 +216,7 @@ void OnlineImage::loop() { } void OnlineImage::end_connection_() { - // Abort any in-progress decode to free decoder resources. + // 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(); diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 9fa32a5a65..3c130a7d75 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -77,6 +77,11 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") + if CORE.is_host: + # JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only + # predefines the lowercase __linux__; without this a Linux host + # build tries to include Arduino.h. + cg.add_build_flag("-D__LINUX__") if CORE.is_esp32: from esphome.components.esp32 import add_idf_component diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 6a1bd61d86..5d45621fb7 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -12,6 +12,22 @@ namespace esphome::runtime_image { static const char *const TAG = "image_decoder.bmp"; +void BmpDecoder::reset() { + ImageDecoder::reset(); + this->bits_per_pixel_ = 0; + this->compression_method_ = 0; + this->image_data_size_ = 0; + this->width_ = 0; + this->height_ = 0; + this->current_index_ = 0; + this->paint_index_ = 0; + // color_table_ is deliberately kept allocated so the next decode can reuse it + this->color_table_entries_ = 0; + this->data_offset_ = 0; + this->padding_bytes_ = 0; + this->width_bytes_ = 0; +} + int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t index = 0; if (this->current_index_ == 0) { @@ -85,7 +101,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t header_size = encode_uint32(buffer[17], buffer[16], buffer[15], buffer[14]); size_t offset = 14 + header_size; - this->color_table_ = std::make_unique(this->color_table_entries_); + if (this->color_table_entries_ > this->color_table_capacity_) { + this->color_table_ = std::make_unique(this->color_table_entries_); + this->color_table_capacity_ = this->color_table_entries_; + } for (size_t i = 0; i < this->color_table_entries_; i++) { this->color_table_[i] = encode_uint32(buffer[offset + i * 4 + 3], buffer[offset + i * 4 + 2], diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index a52a561584..01acc41f91 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -21,8 +21,9 @@ class BmpDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - BmpDecoder(RuntimeImage *image) : ImageDecoder(image) {} + BmpDecoder(RuntimeImage *image) : ImageDecoder(image, BMP) {} + void reset() override; int HOT decode(uint8_t *buffer, size_t size) override; bool is_finished() const override { @@ -35,17 +36,18 @@ class BmpDecoder : public ImageDecoder { } protected: + std::unique_ptr color_table_; size_t current_index_{0}; size_t paint_index_{0}; ssize_t width_{0}; ssize_t height_{0}; - uint16_t bits_per_pixel_{0}; + size_t width_bytes_{0}; + size_t data_offset_{0}; uint32_t compression_method_{0}; uint32_t image_data_size_{0}; uint32_t color_table_entries_{0}; - std::unique_ptr color_table_; - size_t width_bytes_{0}; - size_t data_offset_{0}; + uint32_t color_table_capacity_{0}; // Allocated entries in color_table_, kept across decodes + uint16_t bits_per_pixel_{0}; uint8_t padding_bytes_{0}; }; diff --git a/esphome/components/runtime_image/image_decoder.h b/esphome/components/runtime_image/image_decoder.h index 6d351a10aa..2a8b393888 100644 --- a/esphome/components/runtime_image/image_decoder.h +++ b/esphome/components/runtime_image/image_decoder.h @@ -1,5 +1,6 @@ #pragma once #include "esphome/core/color.h" +#include "image_format.h" namespace esphome::runtime_image { @@ -36,18 +37,41 @@ class ImageDecoder { * @brief Construct a new Image Decoder object * * @param image The RuntimeImage to decode the stream into. + * @param format The image format this decoder handles. */ - ImageDecoder(RuntimeImage *image) : image_(image) {} + ImageDecoder(RuntimeImage *image, ImageFormat format) : image_(image), format_(format) {} virtual ~ImageDecoder() = default; + /// @brief Get the image format handled by this decoder. + ImageFormat get_format() const { return this->format_; } + + /// @brief Check if a decoding session is in progress (prepare() called, reset() not yet). + bool is_active() const { return this->active_; } + /** - * @brief Initialize the decoder. + * @brief Reset the decoder state, ending any decoding session. + * Subclasses should override this method to reset any format-specific state. + * Buffers the next decode can reuse should be kept allocated to avoid heap churn. + */ + virtual void reset() { + this->active_ = false; + this->expected_size_ = 0; + this->decoded_bytes_ = 0; + this->size_valid_ = true; + this->x_scale_ = 1.0; + this->y_scale_ = 1.0; + } + + /** + * @brief Initialize the decoder, starting a new decoding session. * * @param expected_size Hint about the expected data size (0 if unknown). * @return int Returns 0 on success, a {@see DecodeError} value in case of an error. */ virtual int prepare(size_t expected_size) { + this->reset(); this->expected_size_ = expected_size; + this->active_ = true; return 0; } @@ -103,11 +127,13 @@ class ImageDecoder { } protected: + double x_scale_ = 1.0; + double y_scale_ = 1.0; RuntimeImage *image_; size_t expected_size_ = 0; // Expected data size (0 if unknown) size_t decoded_bytes_ = 0; // Bytes processed so far - double x_scale_ = 1.0; - double y_scale_ = 1.0; + const ImageFormat format_; + bool active_ = false; // A decoding session is in progress bool size_valid_ = true; // Last set_size() result; draw() no-ops while false }; diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h new file mode 100644 index 0000000000..524e52d7bc --- /dev/null +++ b/esphome/components/runtime_image/image_format.h @@ -0,0 +1,19 @@ +#pragma once + +namespace esphome::runtime_image { + +/** + * @brief Image format types that can be decoded dynamically. + */ +enum ImageFormat { + /** Automatically detect from data. Not implemented yet. */ + AUTO, + /** JPEG format. */ + JPEG, + /** PNG format. */ + PNG, + /** BMP format. */ + BMP, +}; + +} // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index c46e86fd0d..85ec945259 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -52,12 +52,6 @@ static int draw_callback(JPEGDRAW *jpeg) { return 1; } -int JpegDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); - // JPEG decoder needs complete data before decoding - return 0; -} - int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { // JPEG decoder requires complete data // If we know the expected size, wait for it diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index ed2401e263..67c9b77f4d 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -18,10 +18,9 @@ class JpegDecoder : public ImageDecoder { * * @param image The RuntimeImage to decode the stream into. */ - JpegDecoder(RuntimeImage *image) : ImageDecoder(image) {} + JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {} ~JpegDecoder() override {} - int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; protected: diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 9501702711..106f25bbe1 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -48,7 +48,7 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui } } -PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { +PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image, PNG) { { RAMAllocator allocator; pngle_t *pngle = allocator.allocate(1, PNGLE_T_SIZE); @@ -57,8 +57,8 @@ PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) { return; } memset(pngle, 0, PNGLE_T_SIZE); - pngle_reset(pngle); this->pngle_ = pngle; + pngle_reset(this->pngle_); } } @@ -71,11 +71,12 @@ PngDecoder::~PngDecoder() { } int PngDecoder::prepare(size_t expected_size) { - ImageDecoder::prepare(expected_size); + // Check before the base prepare() so a failure never leaves an active session if (!this->pngle_) { ESP_LOGE(TAG, "PNG decoder engine not initialized!"); return DECODE_ERROR_OUT_OF_MEMORY; } + ImageDecoder::prepare(expected_size); pngle_set_user_data(this->pngle_, this); pngle_set_init_callback(this->pngle_, init_callback); pngle_set_draw_callback(this->pngle_, draw_callback); diff --git a/esphome/components/runtime_image/png_decoder.h b/esphome/components/runtime_image/png_decoder.h index 24521d33a8..a1cd60e0a6 100644 --- a/esphome/components/runtime_image/png_decoder.h +++ b/esphome/components/runtime_image/png_decoder.h @@ -22,6 +22,14 @@ class PngDecoder : public ImageDecoder { PngDecoder(RuntimeImage *image); ~PngDecoder() override; + void reset() override { + ImageDecoder::reset(); + if (this->pngle_) { + pngle_reset(this->pngle_); + } + this->pixels_decoded_ = 0; + } + int prepare(size_t expected_size) override; int HOT decode(uint8_t *buffer, size_t size) override; diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 8fe9be4c8c..e269f7d8f3 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -172,33 +172,38 @@ void RuntimeImage::draw(int x, int y, display::Display *display, Color color_on, } bool RuntimeImage::begin_decode(size_t expected_size) { - if (this->decoder_) { + if (this->is_decoding()) { ESP_LOGW(TAG, "Decoding already in progress"); return false; } - this->decoder_ = this->create_decoder_(); + // An idle decoder for a different format cannot be reused + if (this->decoder_ != nullptr && this->decoder_->get_format() != this->format_) { + ESP_LOGD(TAG, "Decoder format mismatch: current: %d, new: %d", this->decoder_->get_format(), this->format_); + this->decoder_ = nullptr; + } + if (!this->decoder_) { - ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); - return false; + this->decoder_ = this->create_decoder_(this->format_); + if (!this->decoder_) { + ESP_LOGE(TAG, "Failed to create decoder for format %d", this->format_); + return false; + } } - this->total_size_ = expected_size; this->decoded_bytes_ = 0; - // Initialize decoder int result = this->decoder_->prepare(expected_size); if (result < 0) { ESP_LOGE(TAG, "Failed to prepare decoder: %d", result); - this->decoder_ = nullptr; + this->decoder_ = nullptr; // If prepare fails, a full reset is needed return false; } - return true; } int RuntimeImage::feed_data(uint8_t *data, size_t len) { - if (!this->decoder_) { + if (!this->is_decoding()) { ESP_LOGE(TAG, "No decoder initialized"); return -1; } @@ -212,7 +217,7 @@ int RuntimeImage::feed_data(uint8_t *data, size_t len) { } bool RuntimeImage::end_decode() { - if (!this->decoder_) { + if (!this->is_decoding()) { return false; } @@ -224,26 +229,23 @@ bool RuntimeImage::end_decode() { this->data_start_ = this->buffer_; } - // Clean up decoder - this->decoder_ = nullptr; + // End the session; the decoder object stays warm so the next decode can + // reuse it (and its buffers) without churning the heap. + this->decoder_->reset(); ESP_LOGD(TAG, "Decoding complete: %dx%d, %zu bytes", this->width_, this->height_, this->decoded_bytes_); return true; } -bool RuntimeImage::is_decode_finished() const { - if (!this->decoder_) { - return false; - } - return this->decoder_->is_finished(); -} +bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); } void RuntimeImage::release() { this->release_buffer_(); - // Reset decoder separately — release() can be called from within the decoder - // (via set_size -> resize -> resize_buffer_), so we must not destroy the decoder here. - // The decoder lifecycle is managed by begin_decode()/end_decode(). - this->decoder_ = nullptr; + // 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_) { + this->decoder_->reset(); + } } void RuntimeImage::release_buffer_() { @@ -347,8 +349,9 @@ size_t RuntimeImage::get_buffer_size(int width, int height) const { int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; } -std::unique_ptr RuntimeImage::create_decoder_() { - switch (this->format_) { +std::unique_ptr RuntimeImage::create_decoder_(ImageFormat format) { + ESP_LOGV(TAG, "Creating decoder for format %d", format); + switch (format) { #ifdef USE_RUNTIME_IMAGE_BMP case BMP: return make_unique(this); @@ -362,7 +365,7 @@ std::unique_ptr RuntimeImage::create_decoder_() { return make_unique(this); #endif default: - ESP_LOGE(TAG, "Unsupported image format: %d", this->format_); + ESP_LOGE(TAG, "Unsupported image format: %d", format); return nullptr; } } diff --git a/esphome/components/runtime_image/runtime_image.h b/esphome/components/runtime_image/runtime_image.h index 10ce980be2..cfac253fdb 100644 --- a/esphome/components/runtime_image/runtime_image.h +++ b/esphome/components/runtime_image/runtime_image.h @@ -3,25 +3,11 @@ #include "esphome/components/image/image.h" #include "esphome/core/helpers.h" +#include "image_decoder.h" +#include "image_format.h" + namespace esphome::runtime_image { -// Forward declaration -class ImageDecoder; - -/** - * @brief Image format types that can be decoded dynamically. - */ -enum ImageFormat { - /** Automatically detect from data. Not implemented yet. */ - AUTO, - /** JPEG format. */ - JPEG, - /** PNG format. */ - PNG, - /** BMP format. */ - BMP, -}; - /** * @brief A dynamic image that can be loaded and decoded at runtime. * @@ -99,7 +85,7 @@ class RuntimeImage : public image::Image { /** * @brief Check if decoding is currently in progress. */ - bool is_decoding() const { return this->decoder_ != nullptr; } + bool is_decoding() const { return this->decoder_ != nullptr && this->decoder_->is_active(); } /** * @brief Check if the decoder has finished processing all data. @@ -120,9 +106,10 @@ class RuntimeImage : public image::Image { ImageFormat get_format() const { return this->format_; } /** - * @brief Release the image buffer and free memory. + * @brief Release the image buffer and free its memory, ending any decode session. * - * An external buffer is let go of rather than freed. + * An external buffer is let go of rather than freed. The decoder object is kept + * warm so the next decode can reuse it without churning the heap. */ void release(); @@ -194,9 +181,11 @@ class RuntimeImage : public image::Image { int get_position_(int x, int y) const; /** - * @brief Create decoder instance for the image's format. + * @brief Create decoder instance for the requested format. + * @param format The image format to decode. + * @return Unique pointer to the created decoder, or nullptr on failure. */ - std::unique_ptr create_decoder_(); + std::unique_ptr create_decoder_(ImageFormat format); // Memory management uint8_t *buffer_{nullptr}; @@ -224,7 +213,6 @@ class RuntimeImage : public image::Image { int buffer_height_{0}; // Decoding state - size_t total_size_{0}; size_t decoded_bytes_{0}; /** Fixed width requested on configuration, or 0 if not specified. */ diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 626d7966b7..558a292d5b 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -86,8 +86,8 @@ void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { } const bool decoded = this->decode_frame_(data, length, target); - // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is - // safe on every path. + // Ends any half-finished decode session (the decoder object is kept for reuse). An external + // buffer is let go of rather than freed, so this is safe on every path. this->decode_sink_.release(); if (!decoded) { diff --git a/tests/components/runtime_image/__init__.py b/tests/components/runtime_image/__init__.py new file mode 100644 index 0000000000..a8ff4bb68e --- /dev/null +++ b/tests/components/runtime_image/__init__.py @@ -0,0 +1,15 @@ +from esphome.components.runtime_image import enable_format +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code is suppressed in cpptest builds; formats are normally enabled by + # process_runtime_image_config(). Enable all formats so the format-switch + # tests have two decoder types and every retained decoder is under test. + async def to_code_testing(config: ConfigType) -> None: + enable_format("BMP") + enable_format("PNG") + enable_format("JPEG") + + manifest.to_code = to_code_testing diff --git a/tests/components/runtime_image/test_decoder_reuse.cpp b/tests/components/runtime_image/test_decoder_reuse.cpp new file mode 100644 index 0000000000..87e77b00be --- /dev/null +++ b/tests/components/runtime_image/test_decoder_reuse.cpp @@ -0,0 +1,336 @@ +#include +#include + +#include +#include +#include +#include + +#include "esphome/components/runtime_image/image_decoder.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::testing { + +// 3x2 24bpp BMP, every pixel a unique color (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, +}; + +static const uint8_t BMP_24BPP_EXPECTED[2][3][3] = { + {{0xE0, 0x10, 0x20}, {0x30, 0xC0, 0x40}, {0x50, 0x60, 0xA0}}, + {{0x11, 0x22, 0x33}, {0x99, 0x88, 0x77}, {0xAB, 0xCD, 0xEF}}, +}; + +// 3x2 8bpp BMP with a 4-entry color table +static const uint8_t BMP_8BPP[] = { + 0x42, 0x4D, 0x4E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x20, 0x10, 0x00, 0xD0, 0xE0, 0xF0, 0x00, 0x00, 0xFF, + 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0x03, 0x02, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, +}; + +static const uint8_t BMP_8BPP_EXPECTED[2][3][3] = { + {{0x10, 0x20, 0x30}, {0xF0, 0xE0, 0xD0}, {0x00, 0xFF, 0x00}}, + {{0xFF, 0x00, 0xFF}, {0x00, 0xFF, 0x00}, {0xF0, 0xE0, 0xD0}}, +}; + +// 3x2 8bpp BMP with an 8-entry color table, all colors distinct from BMP_8BPP's +static const uint8_t BMP_8BPP_BIG[] = { + 0x42, 0x4D, 0x5E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x00, 0x00, 0x00, 0x28, 0x00, 0x00, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x13, 0x0B, 0x00, 0x00, 0x13, 0x0B, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x28, 0x18, 0x08, + 0x00, 0xA8, 0xB8, 0xC8, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0x80, 0x00, 0x00, 0x80, 0xFF, 0x00, 0x55, 0x99, + 0x11, 0x00, 0xCC, 0x00, 0x66, 0x00, 0x44, 0x22, 0xEE, 0x00, 0x01, 0x06, 0x04, 0x00, 0x07, 0x05, 0x03, 0x00, +}; + +static const uint8_t BMP_8BPP_BIG_EXPECTED[2][3][3] = { + {{0xEE, 0x22, 0x44}, {0x11, 0x99, 0x55}, {0x80, 0xFF, 0x00}}, + {{0xC8, 0xB8, 0xA8}, {0x66, 0x00, 0xCC}, {0xFF, 0x80, 0x00}}, +}; + +// 4x4 RGB PNG, every pixel a unique color +static const uint8_t PNG_RGB[] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x00, 0x00, 0x04, 0x08, 0x02, 0x00, 0x00, 0x00, 0x26, 0x93, 0x09, 0x29, 0x00, 0x00, 0x00, 0x38, 0x49, + 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x60, 0x64, 0x62, 0x16, 0x50, 0x30, 0x58, 0xB0, 0xE1, 0xC0, 0xFF, 0xFF, 0x0C, + 0x0C, 0x0E, 0x0C, 0x50, 0xEC, 0xE0, 0xE0, 0xC0, 0x50, 0xCF, 0xF0, 0x9F, 0xA1, 0xFE, 0xFF, 0xFF, 0x7A, 0x86, 0xFA, + 0xFF, 0x0C, 0x0C, 0x42, 0x26, 0x61, 0xA9, 0xCE, 0x8A, 0xFF, 0xEE, 0xEC, 0x5A, 0x7D, 0xF6, 0x3D, 0x00, 0x81, 0xCB, + 0x12, 0x4D, 0xB3, 0xFB, 0xD4, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, +}; + +static const uint8_t PNG_RGB_EXPECTED[4][4][3] = { + {{0x01, 0x02, 0x03}, {0x10, 0x20, 0x30}, {0xA0, 0xB0, 0xC0}, {0xFF, 0xFF, 0x00}}, + {{0x40, 0x00, 0x00}, {0x00, 0x40, 0x00}, {0x00, 0x00, 0x40}, {0x40, 0x40, 0x40}}, + {{0x7F, 0x00, 0xFF}, {0x00, 0x7F, 0xFF}, {0xFF, 0x7F, 0x00}, {0x7F, 0xFF, 0x00}}, + {{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}}, +}; + +/// Exposes the protected decoder machinery so reuse and eviction can be observed directly. +class TestableRuntimeImage : public RuntimeImage { + public: + explicit TestableRuntimeImage(ImageFormat format) + : RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {} + + ImageDecoder *decoder() { return this->decoder_.get(); } + + /// Simulates the state a dynamic-format producer (PR #16337) would leave behind: + /// a cached decoder whose format no longer matches the image's format. + /// TODO: once #16337 adds a public way to change the format, drive the mismatch + /// through it and delete this seam. + void plant_decoder(ImageFormat format) { this->decoder_ = this->create_decoder_(format); } +}; + +/// Runs one full decode session. Returns true when every stage succeeded. +static bool decode_all(TestableRuntimeImage &img, const uint8_t *data, size_t len) { + std::vector buffer(data, data + len); // feed_data needs mutable bytes + if (!img.begin_decode(len)) { + return false; + } + size_t offset = 0; + while (offset < len) { + int consumed = img.feed_data(buffer.data() + offset, len - offset); + if (consumed <= 0) { + return false; // decode error, or no progress despite full data + } + offset += consumed; + } + return img.end_decode(); +} + +/// Feeds the image the way online_image's download loop does: append a small +/// chunk to a window, feed the window, drop what was consumed, repeat. A zero +/// return mid-stream means "need more data" and grows the window. +static bool decode_chunked(TestableRuntimeImage &img, const uint8_t *data, size_t len, size_t chunk_size) { + if (!img.begin_decode(len)) { + return false; + } + std::vector window; + size_t supplied = 0; + while (supplied < len || !window.empty()) { + if (supplied < len) { + size_t take = std::min(chunk_size, len - supplied); + window.insert(window.end(), data + supplied, data + supplied + take); + supplied += take; + } + int consumed = img.feed_data(window.data(), window.size()); + if (consumed < 0 || (consumed == 0 && supplied >= len)) { + return false; // decode error, or stuck with all data supplied + } + window.erase(window.begin(), window.begin() + consumed); + } + return img.end_decode(); +} + +template static void expect_pixels(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][3]) { + ASSERT_EQ(img.get_width(), static_cast(W)); + ASSERT_EQ(img.get_height(), static_cast(H)); + for (size_t y = 0; y < H; y++) { + for (size_t x = 0; x < W; x++) { + SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")"); + Color color = img.get_pixel(x, y); + EXPECT_THAT((std::array{color.r, color.g, color.b}), ::testing::ElementsAreArray(expected[y][x])); + } + } +} + +TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated"; +} + +TEST(RuntimeImageDecoder, SecondDecodeStartsClean) { + TestableRuntimeImage img(BMP); + + // Palettized decode, then a 24bpp decode, then palettized again, all on the + // same decoder: each session must produce correct pixels for its own image. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ColorTableGrowsAndShrinksAcrossReuse) { + TestableRuntimeImage img(BMP); + + // Small palette first: the retained table is allocated at 4 entries. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Growing to 8 entries on the reused decoder must reallocate, not overflow. + ASSERT_TRUE(decode_all(img, BMP_8BPP_BIG, sizeof(BMP_8BPP_BIG))); + expect_pixels(img, BMP_8BPP_BIG_EXPECTED); + EXPECT_EQ(img.decoder(), first); + + // Shrinking back must not surface stale colors from the larger table. + ASSERT_TRUE(decode_all(img, BMP_8BPP, sizeof(BMP_8BPP))); + expect_pixels(img, BMP_8BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, ChunkedFeedDecodesLikeDownloadLoop) { + TestableRuntimeImage img(BMP); + + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + ImageDecoder *first = img.decoder(); + + // Chunked again on the warm decoder: the cross-call resume state + // (current_index_ / paint_index_) must have been fully reset. + ASSERT_TRUE(decode_chunked(img, BMP_24BPP, sizeof(BMP_24BPP), 16)); + expect_pixels(img, BMP_24BPP_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FormatSwitchEvictsMismatchedDecoder) { + // PNG image holding a stale BMP decoder: begin_decode must evict and recreate. + TestableRuntimeImage png_img(PNG); + png_img.plant_decoder(BMP); + ASSERT_NE(png_img.decoder(), nullptr); + ASSERT_EQ(png_img.decoder()->get_format(), BMP); + + ASSERT_TRUE(decode_all(png_img, PNG_RGB, sizeof(PNG_RGB))); + EXPECT_EQ(png_img.decoder()->get_format(), PNG); + expect_pixels(png_img, PNG_RGB_EXPECTED); + + // And the other direction: BMP image holding a stale PNG decoder. + TestableRuntimeImage bmp_img(BMP); + bmp_img.plant_decoder(PNG); + ASSERT_NE(bmp_img.decoder(), nullptr); + ASSERT_EQ(bmp_img.decoder()->get_format(), PNG); + + ASSERT_TRUE(decode_all(bmp_img, BMP_24BPP, sizeof(BMP_24BPP))); + EXPECT_EQ(bmp_img.decoder()->get_format(), BMP); + expect_pixels(bmp_img, BMP_24BPP_EXPECTED); +} + +TEST(RuntimeImageDecoder, ReleaseKeepsDecoderWarm) { + TestableRuntimeImage img(PNG); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + img.release(); + EXPECT_EQ(img.decoder(), first) << "release() must keep the decoder for reuse"; + EXPECT_FALSE(img.is_decoding()); + EXPECT_EQ(img.get_width(), 0); + EXPECT_EQ(img.get_height(), 0); + + ASSERT_TRUE(decode_all(img, PNG_RGB, sizeof(PNG_RGB))); + expect_pixels(img, PNG_RGB_EXPECTED); + EXPECT_EQ(img.decoder(), first); +} + +TEST(RuntimeImageDecoder, FailedDecodeRecovers) { + TestableRuntimeImage img(BMP); + + uint8_t garbage[32]; + memset(garbage, 'X', sizeof(garbage)); + ASSERT_TRUE(img.begin_decode(sizeof(garbage))); + EXPECT_LT(img.feed_data(garbage, sizeof(garbage)), 0) << "garbage must fail to decode"; + img.release(); + + ASSERT_TRUE(decode_all(img, BMP_24BPP, sizeof(BMP_24BPP))); + expect_pixels(img, BMP_24BPP_EXPECTED); +} + +#ifdef USE_RUNTIME_IMAGE_JPEG +// 8x8 gradient JPEG (quality 90). JPEG is lossy, so the test asserts that a +// reused decoder reproduces the exact same pixels, not absolute colors. +static const uint8_t JPEG_GRADIENT[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, + 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0A, 0x07, 0x07, 0x06, 0x08, 0x0C, 0x0A, 0x0C, 0x0C, 0x0B, 0x0A, + 0x0B, 0x0B, 0x0D, 0x0E, 0x12, 0x10, 0x0D, 0x0E, 0x11, 0x0E, 0x0B, 0x0B, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, + 0x15, 0x15, 0x0C, 0x0F, 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x03, + 0x04, 0x04, 0x05, 0x04, 0x05, 0x09, 0x05, 0x05, 0x09, 0x14, 0x0D, 0x0B, 0x0D, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0x14, 0x14, 0x14, 0x14, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, + 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, + 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, + 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, + 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, + 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, + 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, + 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, + 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, + 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, + 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, + 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, + 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, + 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, + 0xD9, 0xDA, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, + 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, 0xE5, 0x3E, 0x0B, 0xFE, + 0xC8, 0x7F, 0xEA, 0x3F, 0xD0, 0xBD, 0x3F, 0x86, 0x8A, 0x28, 0xAA, 0xC2, 0x62, 0x6A, 0xFB, 0x25, 0xA9, 0xD5, 0xC0, + 0x7C, 0x6B, 0x9D, 0x7F, 0x62, 0xD3, 0xFD, 0xEF, 0xF5, 0xF7, 0x9F, 0xFF, 0xD9, +}; + +static std::vector pixel_bytes(TestableRuntimeImage &img) { + const uint8_t *start = img.get_data_start(); + return std::vector(start, start + img.get_width_stride() * img.get_height()); +} + +TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) { + TestableRuntimeImage img(JPEG); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + ASSERT_EQ(img.get_width(), 8); + ASSERT_EQ(img.get_height(), 8); + std::vector first_pixels = pixel_bytes(img); + ImageDecoder *first = img.decoder(); + ASSERT_NE(first, nullptr); + + ASSERT_TRUE(decode_all(img, JPEG_GRADIENT, sizeof(JPEG_GRADIENT))); + EXPECT_EQ(img.decoder(), first); + EXPECT_EQ(pixel_bytes(img), first_pixels) << "reused decoder must reproduce identical pixels"; +} +#endif // USE_RUNTIME_IMAGE_JPEG + +TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) { + TestableRuntimeImage img(BMP); + std::vector buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP)); + + ASSERT_TRUE(img.begin_decode(buffer.size())); + EXPECT_TRUE(img.is_decoding()); + EXPECT_FALSE(img.is_decode_finished()); + + ASSERT_EQ(img.feed_data(buffer.data(), buffer.size()), static_cast(buffer.size())); + EXPECT_TRUE(img.is_decode_finished()) << "all pixel data consumed"; + + ASSERT_TRUE(img.end_decode()); + EXPECT_FALSE(img.is_decoding()) << "end_decode() must close the session"; + EXPECT_FALSE(img.is_decode_finished()) << "no session means nothing is 'finished'"; +} + +} // namespace esphome::runtime_image::testing