Compare commits

...

9 Commits

Author SHA1 Message Date
dependabot[bot]
f71e463e0e Bump github/codeql-action from 4.32.2 to 4.32.3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.32.2 to 4.32.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](45cbd0c69e...9e907b5e64)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.32.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-13 19:23:41 +00:00
Kevin Ahrendt
903971de12 [runtime_image, online_image] Create runtime_image component to decode images (#10212) 2026-02-13 11:25:43 -05:00
J. Nick Koston
b04e427f01 [usb_host] Extract cold path from loop(), replace std::string with buffer API (#13957)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 06:39:00 -06:00
J. Nick Koston
e0c03b2dfa [api] Fix ESP8266 noise API handshake deadlock and prompt socket cleanup (#13972) 2026-02-12 18:20:58 -06:00
J. Nick Koston
7dff631dcb [core] Flatten single-callsite vector realloc functions (#13970) 2026-02-12 18:20:39 -06:00
J. Nick Koston
36aba385af [web_server] Flatten deq_push_back_with_dedup_ to inline vector realloc (#13968) 2026-02-12 18:20:21 -06:00
Jonathan Swoboda
136d17366f [docker] Suppress git detached HEAD advice (#13962)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:12:17 -05:00
Jonathan Swoboda
db7870ef5f [alarm_control_panel] Fix flaky integration test race condition (#13964)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:04:39 -05:00
dependabot[bot]
bbc88d92ea Bump docker/build-push-action from 6.19.1 to 6.19.2 in /.github/actions/build-image (#13965)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-12 14:31:43 -06:00
31 changed files with 1203 additions and 718 deletions

View File

@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@601a80b39c9405e50806ae38af30926f9d957c47 # v6.19.1
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
with:
category: "/language:${{matrix.language}}"

View File

@@ -411,6 +411,7 @@ esphome/components/rp2040_pwm/* @jesserockz
esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet
esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt
esphome/components/runtime_stats/* @bdraco
esphome/components/rx8130/* @beormund
esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti

View File

@@ -9,7 +9,8 @@ FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS b
ARG BUILD_TYPE
FROM base-source-${BUILD_TYPE} AS base
RUN git config --system --add safe.directory "*"
RUN git config --system --add safe.directory "*" \
&& git config --system advice.detachedHead false
# Install build tools for Python packages that require compilation
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)

View File

@@ -1864,6 +1864,8 @@ void APIConnection::on_fatal_error() {
this->flags_.remove = true;
}
void __attribute__((flatten)) APIConnection::DeferredBatch::push_item(const BatchItem &item) { items.push_back(item); }
void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
uint8_t aux_data_index) {
// Check if we already have a message of this type for this entity
@@ -1880,7 +1882,7 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_
}
}
// No existing item found (or event), add new one
items.push_back({entity, message_type, estimated_size, aux_data_index});
this->push_item({entity, message_type, estimated_size, aux_data_index});
}
void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
@@ -1888,7 +1890,7 @@ void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t me
// This avoids expensive vector::insert which shifts all elements
// Note: We only ever have one high-priority message at a time (ping OR disconnect)
// If we're disconnecting, pings are blocked, so this simple swap is sufficient
items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
this->push_item({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (items.size() > 1) {
// Swap the new high-priority item to the front
std::swap(items.front(), items.back());

View File

@@ -541,6 +541,8 @@ class APIConnection final : public APIServerConnectionBase {
uint8_t aux_data_index = AUX_DATA_UNUSED);
// Add item to the front of the batch (for high priority messages like ping)
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
// Single push_back site to avoid duplicate _M_realloc_insert instantiation
void push_item(const BatchItem &item);
// Clear all items
void clear() {

View File

@@ -138,10 +138,12 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
// During handshake phase, process as many actions as possible until we can't progress
// socket_->ready() stays true until next main loop, but state_action() will return
// WOULD_BLOCK when no more data is available to read
while (state_ != State::DATA && this->socket_->ready()) {
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
// that must follow reads, deadlocking the handshake. state_action() will return
// WOULD_BLOCK when no more data is available to read.
bool socket_ready = this->socket_->ready();
while (state_ != State::DATA && socket_ready) {
APIError err = state_action_();
if (err == APIError::WOULD_BLOCK) {
break;

View File

@@ -148,12 +148,16 @@ void APIServer::loop() {
while (client_index < this->clients_.size()) {
auto &client = this->clients_[client_index];
// Common case: process active client
if (!client->flags_.remove) {
client->loop();
}
// Handle disconnection promptly - close socket to free LWIP PCB
// resources and prevent retransmit crashes on ESP8266.
if (client->flags_.remove) {
// Rare case: handle disconnection (don't increment - swapped element needs processing)
this->remove_client_(client_index);
} else {
// Common case: process active client
client->loop();
client_index++;
}
}
@@ -195,7 +199,7 @@ void APIServer::remove_client_(size_t client_index) {
#endif
}
void APIServer::accept_new_connections_() {
void __attribute__((flatten)) APIServer::accept_new_connections_() {
while (true) {
struct sockaddr_storage source_addr;
socklen_t addr_len = sizeof(source_addr);

View File

@@ -2,97 +2,34 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER, CONF_REQUEST_HEADERS
from esphome.components import runtime_image
from esphome.components.const import CONF_REQUEST_HEADERS
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
from esphome.components.image import (
CONF_INVERT_ALPHA,
CONF_TRANSPARENCY,
IMAGE_SCHEMA,
Image_,
get_image_type_enum,
get_transparency_enum,
validate_settings,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_DITHER,
CONF_FILE,
CONF_FORMAT,
CONF_ID,
CONF_ON_ERROR,
CONF_RESIZE,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_URL,
)
from esphome.core import Lambda
AUTO_LOAD = ["image"]
AUTO_LOAD = ["image", "runtime_image"]
DEPENDENCIES = ["display", "http_request"]
CODEOWNERS = ["@guillempages", "@clydebarrow"]
MULTI_CONF = True
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
CONF_PLACEHOLDER = "placeholder"
CONF_UPDATE = "update"
_LOGGER = logging.getLogger(__name__)
online_image_ns = cg.esphome_ns.namespace("online_image")
ImageFormat = online_image_ns.enum("ImageFormat")
class Format:
def __init__(self, image_type):
self.image_type = image_type
@property
def enum(self):
return getattr(ImageFormat, self.image_type)
def actions(self):
pass
class BMPFormat(Format):
def __init__(self):
super().__init__("BMP")
def actions(self):
cg.add_define("USE_ONLINE_IMAGE_BMP_SUPPORT")
class JPEGFormat(Format):
def __init__(self):
super().__init__("JPEG")
def actions(self):
cg.add_define("USE_ONLINE_IMAGE_JPEG_SUPPORT")
cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2")
class PNGFormat(Format):
def __init__(self):
super().__init__("PNG")
def actions(self):
cg.add_define("USE_ONLINE_IMAGE_PNG_SUPPORT")
cg.add_library("pngle", "1.1.0")
IMAGE_FORMATS = {
x.image_type: x
for x in (
BMPFormat(),
JPEGFormat(),
PNGFormat(),
)
}
IMAGE_FORMATS.update({"JPG": IMAGE_FORMATS["JPEG"]})
OnlineImage = online_image_ns.class_("OnlineImage", cg.PollingComponent, Image_)
OnlineImage = online_image_ns.class_(
"OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage
)
# Actions
SetUrlAction = online_image_ns.class_(
@@ -111,29 +48,17 @@ DownloadErrorTrigger = online_image_ns.class_(
)
def remove_options(*options):
return {
cv.Optional(option): cv.invalid(
f"{option} is an invalid option for online_image"
)
for option in options
}
ONLINE_IMAGE_SCHEMA = (
IMAGE_SCHEMA.extend(remove_options(CONF_FILE, CONF_INVERT_ALPHA, CONF_DITHER))
runtime_image.runtime_image_schema(OnlineImage)
.extend(
{
cv.Required(CONF_ID): cv.declare_id(OnlineImage),
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
# Online Image specific options
cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent),
cv.Required(CONF_URL): cv.url,
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536),
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
@@ -162,7 +87,7 @@ CONFIG_SCHEMA = cv.Schema(
rp2040_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
),
validate_settings,
runtime_image.validate_runtime_image_settings,
)
)
@@ -199,23 +124,21 @@ async def online_image_action_to_code(config, action_id, template_arg, args):
async def to_code(config):
image_format = IMAGE_FORMATS[config[CONF_FORMAT]]
image_format.actions()
# Use the enhanced helper function to get all runtime image parameters
settings = await runtime_image.process_runtime_image_config(config)
url = config[CONF_URL]
width, height = config.get(CONF_RESIZE, (0, 0))
transparent = get_transparency_enum(config[CONF_TRANSPARENCY])
var = cg.new_Pvariable(
config[CONF_ID],
url,
width,
height,
image_format.enum,
get_image_type_enum(config[CONF_TYPE]),
transparent,
settings.width,
settings.height,
settings.format_enum,
settings.image_type_enum,
settings.transparent,
settings.placeholder or cg.nullptr,
config[CONF_BUFFER_SIZE],
config.get(CONF_BYTE_ORDER) != "LITTLE_ENDIAN",
settings.byte_order_big_endian,
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])
@@ -227,10 +150,6 @@ async def to_code(config):
else:
cg.add(var.add_request_header(key, value))
if placeholder_id := config.get(CONF_PLACEHOLDER):
placeholder = await cg.get_variable(placeholder_id)
cg.add(var.set_placeholder(placeholder))
for conf in config.get(CONF_ON_DOWNLOAD_FINISHED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [(bool, "cached")], conf)

View File

@@ -1,29 +1,10 @@
#include "image_decoder.h"
#include "online_image.h"
#include "download_buffer.h"
#include "esphome/core/log.h"
#include <cstring>
namespace esphome {
namespace online_image {
namespace esphome::online_image {
static const char *const TAG = "online_image.decoder";
bool ImageDecoder::set_size(int width, int height) {
bool success = this->image_->resize_(width, height) > 0;
this->x_scale_ = static_cast<double>(this->image_->buffer_width_) / width;
this->y_scale_ = static_cast<double>(this->image_->buffer_height_) / height;
return success;
}
void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
auto width = std::min(this->image_->buffer_width_, static_cast<int>(std::ceil((x + w) * this->x_scale_)));
auto height = std::min(this->image_->buffer_height_, static_cast<int>(std::ceil((y + h) * this->y_scale_)));
for (int i = x * this->x_scale_; i < width; i++) {
for (int j = y * this->y_scale_; j < height; j++) {
this->image_->draw_pixel_(i, j, color);
}
}
}
static const char *const TAG = "online_image.download_buffer";
DownloadBuffer::DownloadBuffer(size_t size) : size_(size) {
this->buffer_ = this->allocator_.allocate(size);
@@ -43,10 +24,12 @@ uint8_t *DownloadBuffer::data(size_t offset) {
}
size_t DownloadBuffer::read(size_t len) {
this->unread_ -= len;
if (this->unread_ > 0) {
memmove(this->data(), this->data(len), this->unread_);
if (len >= this->unread_) {
this->unread_ = 0;
return 0;
}
this->unread_ -= len;
memmove(this->data(), this->data(len), this->unread_);
return this->unread_;
}
@@ -69,5 +52,4 @@ size_t DownloadBuffer::resize(size_t size) {
}
}
} // namespace online_image
} // namespace esphome
} // namespace esphome::online_image

View File

@@ -0,0 +1,44 @@
#pragma once
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
namespace esphome::online_image {
/**
* @brief Buffer for managing downloaded data.
*
* This class provides a buffer for downloading data with tracking of
* unread bytes and dynamic resizing capabilities.
*/
class DownloadBuffer {
public:
DownloadBuffer(size_t size);
~DownloadBuffer() { this->allocator_.deallocate(this->buffer_, this->size_); }
uint8_t *data(size_t offset = 0);
uint8_t *append() { return this->data(this->unread_); }
size_t unread() const { return this->unread_; }
size_t size() const { return this->size_; }
size_t free_capacity() const { return this->size_ - this->unread_; }
size_t read(size_t len);
size_t write(size_t len) {
this->unread_ += len;
return this->unread_;
}
void reset() { this->unread_ = 0; }
size_t resize(size_t size);
protected:
RAMAllocator<uint8_t> allocator_{};
uint8_t *buffer_;
size_t size_;
/** Total number of downloaded bytes not yet read. */
size_t unread_;
};
} // namespace esphome::online_image

View File

@@ -1,6 +1,6 @@
#include "online_image.h"
#include "esphome/core/log.h"
#include <algorithm>
static const char *const TAG = "online_image";
static const char *const ETAG_HEADER_NAME = "etag";
@@ -8,142 +8,82 @@ static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match";
static const char *const LAST_MODIFIED_HEADER_NAME = "last-modified";
static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
#include "image_decoder.h"
namespace esphome::online_image {
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
#include "bmp_image.h"
#endif
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
#include "jpeg_image.h"
#endif
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
#include "png_image.h"
#endif
namespace esphome {
namespace online_image {
using image::ImageType;
inline bool is_color_on(const Color &color) {
// This produces the most accurate monochrome conversion, but is slightly slower.
// return (0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b) > 127;
// Approximation using fast integer computations; produces acceptable results
// Equivalent to 0.25 * R + 0.5 * G + 0.25 * B
return ((color.r >> 2) + (color.g >> 1) + (color.b >> 2)) & 0x80;
}
OnlineImage::OnlineImage(const std::string &url, int width, int height, ImageFormat format, ImageType type,
image::Transparency transparency, uint32_t download_buffer_size, bool is_big_endian)
: Image(nullptr, 0, 0, type, transparency),
buffer_(nullptr),
download_buffer_(download_buffer_size),
download_buffer_initial_size_(download_buffer_size),
format_(format),
fixed_width_(width),
fixed_height_(height),
is_big_endian_(is_big_endian) {
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
uint32_t buffer_size, bool is_big_endian)
: RuntimeImage(format, type, transparency, placeholder, is_big_endian, width, height),
download_buffer_(buffer_size),
download_buffer_initial_size_(buffer_size) {
this->set_url(url);
}
void OnlineImage::draw(int x, int y, display::Display *display, Color color_on, Color color_off) {
if (this->data_start_) {
Image::draw(x, y, display, color_on, color_off);
} else if (this->placeholder_) {
this->placeholder_->draw(x, y, display, color_on, color_off);
bool OnlineImage::validate_url_(const std::string &url) {
if (url.empty()) {
ESP_LOGE(TAG, "URL is empty");
return false;
}
}
void OnlineImage::release() {
if (this->buffer_) {
ESP_LOGV(TAG, "Deallocating old buffer");
this->allocator_.deallocate(this->buffer_, this->get_buffer_size_());
this->data_start_ = nullptr;
this->buffer_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
this->last_modified_ = "";
this->etag_ = "";
this->end_connection_();
if (url.length() > 2048) {
ESP_LOGE(TAG, "URL is too long");
return false;
}
}
size_t OnlineImage::resize_(int width_in, int height_in) {
int width = this->fixed_width_;
int height = this->fixed_height_;
if (this->is_auto_resize_()) {
width = width_in;
height = height_in;
if (this->width_ != width && this->height_ != height) {
this->release();
}
if (url.compare(0, 7, "http://") != 0 && url.compare(0, 8, "https://") != 0) {
ESP_LOGE(TAG, "URL must start with http:// or https://");
return false;
}
size_t new_size = this->get_buffer_size_(width, height);
if (this->buffer_) {
// Buffer already allocated => no need to resize
return new_size;
}
ESP_LOGD(TAG, "Allocating new buffer of %zu bytes", new_size);
this->buffer_ = this->allocator_.allocate(new_size);
if (this->buffer_ == nullptr) {
ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", new_size,
this->allocator_.get_max_free_block_size());
this->end_connection_();
return 0;
}
this->buffer_width_ = width;
this->buffer_height_ = height;
this->width_ = width;
ESP_LOGV(TAG, "New size: (%d, %d)", width, height);
return new_size;
return true;
}
void OnlineImage::update() {
if (this->decoder_) {
if (this->is_decoding()) {
ESP_LOGW(TAG, "Image already being updated.");
return;
}
ESP_LOGI(TAG, "Updating image %s", this->url_.c_str());
std::list<http_request::Header> headers = {};
http_request::Header accept_header;
accept_header.name = "Accept";
std::string accept_mime_type;
switch (this->format_) {
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
case ImageFormat::BMP:
accept_mime_type = "image/bmp";
break;
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
case ImageFormat::JPEG:
accept_mime_type = "image/jpeg";
break;
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
case ImageFormat::PNG:
accept_mime_type = "image/png";
break;
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
default:
accept_mime_type = "image/*";
if (!this->validate_url_(this->url_)) {
ESP_LOGE(TAG, "Invalid URL: %s", this->url_.c_str());
this->download_error_callback_.call();
return;
}
accept_header.value = accept_mime_type + ",*/*;q=0.8";
ESP_LOGD(TAG, "Updating image from %s", this->url_.c_str());
std::list<http_request::Header> headers;
// Add caching headers if we have them
if (!this->etag_.empty()) {
headers.push_back(http_request::Header{IF_NONE_MATCH_HEADER_NAME, this->etag_});
headers.push_back({IF_NONE_MATCH_HEADER_NAME, this->etag_});
}
if (!this->last_modified_.empty()) {
headers.push_back(http_request::Header{IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_});
headers.push_back({IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_});
}
headers.push_back(accept_header);
// Add Accept header based on image format
const char *accept_mime_type;
switch (this->get_format()) {
#ifdef USE_RUNTIME_IMAGE_BMP
case runtime_image::BMP:
accept_mime_type = "image/bmp,*/*;q=0.8";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
case runtime_image::JPEG:
accept_mime_type = "image/jpeg,*/*;q=0.8";
break;
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
case runtime_image::PNG:
accept_mime_type = "image/png,*/*;q=0.8";
break;
#endif
default:
accept_mime_type = "image/*,*/*;q=0.8";
break;
}
headers.push_back({"Accept", accept_mime_type});
// User headers last so they can override any of the above
for (auto &header : this->request_headers_) {
headers.push_back(http_request::Header{header.first, header.second.value()});
}
@@ -175,186 +115,117 @@ void OnlineImage::update() {
ESP_LOGD(TAG, "Starting download");
size_t total_size = this->downloader_->content_length;
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
if (this->format_ == ImageFormat::BMP) {
ESP_LOGD(TAG, "Allocating BMP decoder");
this->decoder_ = make_unique<BmpDecoder>(this);
this->enable_loop();
// Initialize decoder with the known format
if (!this->begin_decode(total_size)) {
ESP_LOGE(TAG, "Failed to initialize decoder for format %d", this->get_format());
this->end_connection_();
this->download_error_callback_.call();
return;
}
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
if (this->format_ == ImageFormat::JPEG) {
ESP_LOGD(TAG, "Allocating JPEG decoder");
this->decoder_ = esphome::make_unique<JpegDecoder>(this);
this->enable_loop();
}
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
if (this->format_ == ImageFormat::PNG) {
ESP_LOGD(TAG, "Allocating PNG decoder");
this->decoder_ = make_unique<PngDecoder>(this);
this->enable_loop();
}
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
if (!this->decoder_) {
ESP_LOGE(TAG, "Could not instantiate decoder. Image format unsupported: %d", this->format_);
this->end_connection_();
this->download_error_callback_.call();
return;
}
auto prepare_result = this->decoder_->prepare(total_size);
if (prepare_result < 0) {
this->end_connection_();
this->download_error_callback_.call();
return;
// JPEG requires the complete image in the download buffer before decoding
if (this->get_format() == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
this->download_buffer_.resize(total_size);
}
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
this->start_time_ = ::time(nullptr);
this->enable_loop();
}
void OnlineImage::loop() {
if (!this->decoder_) {
if (!this->is_decoding()) {
// Not decoding at the moment => nothing to do.
this->disable_loop();
return;
}
if (!this->downloader_ || this->decoder_->is_finished()) {
this->data_start_ = buffer_;
this->width_ = buffer_width_;
this->height_ = buffer_height_;
ESP_LOGD(TAG, "Image fully downloaded, read %zu bytes, width/height = %d/%d", this->downloader_->get_bytes_read(),
this->width_, this->height_);
ESP_LOGD(TAG, "Total time: %" PRIu32 "s", (uint32_t) (::time(nullptr) - this->start_time_));
if (!this->downloader_) {
ESP_LOGE(TAG, "Downloader not instantiated; cannot download");
this->end_connection_();
this->download_error_callback_.call();
return;
}
// Check if download is complete — use decoder's format-specific completion check
// to handle both known content-length and chunked transfer encoding
if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
this->downloader_->get_bytes_read() >= this->downloader_->content_length &&
this->download_buffer_.unread() == 0)) {
// Finalize decoding
this->end_decode();
ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 "s", this->downloader_->get_bytes_read(),
(uint32_t) (::time(nullptr) - this->start_time_));
// Save caching headers
this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME);
this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME);
this->download_finished_callback_.call(false);
this->end_connection_();
return;
}
if (this->downloader_ == nullptr) {
ESP_LOGE(TAG, "Downloader not instantiated; cannot download");
return;
}
// Download and decode more data
size_t available = this->download_buffer_.free_capacity();
if (available) {
// Some decoders need to fully download the image before downloading.
// In case of huge images, don't wait blocking until the whole image has been downloaded,
// use smaller chunks
if (available > 0) {
// Download in chunks to avoid blocking
available = std::min(available, this->download_buffer_initial_size_);
auto len = this->downloader_->read(this->download_buffer_.append(), available);
if (len > 0) {
this->download_buffer_.write(len);
auto fed = this->decoder_->decode(this->download_buffer_.data(), this->download_buffer_.unread());
if (fed < 0) {
ESP_LOGE(TAG, "Error when decoding image.");
// Feed data to decoder
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
if (consumed < 0) {
ESP_LOGE(TAG, "Error decoding image: %d", consumed);
this->end_connection_();
this->download_error_callback_.call();
return;
}
this->download_buffer_.read(fed);
}
}
}
void OnlineImage::map_chroma_key(Color &color) {
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (color.g == 1 && color.r == 0 && color.b == 0) {
color.g = 0;
}
if (color.w < 0x80) {
color.r = 0;
color.g = this->type_ == ImageType::IMAGE_TYPE_RGB565 ? 4 : 1;
color.b = 0;
}
}
}
void OnlineImage::draw_pixel_(int x, int y, Color color) {
if (!this->buffer_) {
ESP_LOGE(TAG, "Buffer not allocated!");
return;
}
if (x < 0 || y < 0 || x >= this->buffer_width_ || y >= this->buffer_height_) {
ESP_LOGE(TAG, "Tried to paint a pixel (%d,%d) outside the image!", x, y);
return;
}
uint32_t pos = this->get_position_(x, y);
switch (this->type_) {
case ImageType::IMAGE_TYPE_BINARY: {
const uint32_t width_8 = ((this->width_ + 7u) / 8u) * 8u;
pos = x + y * width_8;
auto bitno = 0x80 >> (pos % 8u);
pos /= 8u;
auto on = is_color_on(color);
if (this->has_transparency() && color.w < 0x80)
on = false;
if (on) {
this->buffer_[pos] |= bitno;
} else {
this->buffer_[pos] &= ~bitno;
if (consumed > 0) {
this->download_buffer_.read(consumed);
}
break;
} else if (len < 0) {
ESP_LOGE(TAG, "Error downloading image: %d", len);
this->end_connection_();
this->download_error_callback_.call();
return;
}
case ImageType::IMAGE_TYPE_GRAYSCALE: {
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
}
if (color.w < 0x80) {
gray = 1;
}
} else if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
if (color.w != 0xFF)
gray = color.w;
}
this->buffer_[pos] = gray;
break;
}
case ImageType::IMAGE_TYPE_RGB565: {
this->map_chroma_key(color);
uint16_t col565 = display::ColorUtil::color_to_565(color);
if (this->is_big_endian_) {
this->buffer_[pos + 0] = static_cast<uint8_t>((col565 >> 8) & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>(col565 & 0xFF);
} else {
this->buffer_[pos + 0] = static_cast<uint8_t>(col565 & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>((col565 >> 8) & 0xFF);
}
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 2] = color.w;
}
break;
}
case ImageType::IMAGE_TYPE_RGB: {
this->map_chroma_key(color);
this->buffer_[pos + 0] = color.r;
this->buffer_[pos + 1] = color.g;
this->buffer_[pos + 2] = color.b;
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 3] = color.w;
}
break;
} else {
// Buffer is full, need to decode some data first
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
if (consumed > 0) {
this->download_buffer_.read(consumed);
} else if (consumed < 0) {
ESP_LOGE(TAG, "Decode error with full buffer: %d", consumed);
this->end_connection_();
this->download_error_callback_.call();
return;
} else {
// Decoder can't process more data, might need complete image
// This is normal for JPEG which needs complete data
ESP_LOGV(TAG, "Decoder waiting for more data");
}
}
}
void OnlineImage::end_connection_() {
// Abort any in-progress decode to free decoder resources.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
if (this->is_decoding()) {
RuntimeImage::release();
}
if (this->downloader_) {
this->downloader_->end();
this->downloader_ = nullptr;
}
this->decoder_.reset();
this->download_buffer_.reset();
}
bool OnlineImage::validate_url_(const std::string &url) {
if ((url.length() < 8) || !url.starts_with("http") || (url.find("://") == std::string::npos)) {
ESP_LOGE(TAG, "URL is invalid and/or must be prefixed with 'http://' or 'https://'");
return false;
}
return true;
this->disable_loop();
}
void OnlineImage::add_on_finished_callback(std::function<void(bool)> &&callback) {
@@ -365,5 +236,16 @@ void OnlineImage::add_on_error_callback(std::function<void()> &&callback) {
this->download_error_callback_.add(std::move(callback));
}
} // namespace online_image
} // namespace esphome
void OnlineImage::release() {
// Clear cache headers
this->etag_ = "";
this->last_modified_ = "";
// End any active connection
this->end_connection_();
// Call parent's release to free the image buffer
RuntimeImage::release();
}
} // namespace esphome::online_image

View File

@@ -1,15 +1,14 @@
#pragma once
#include "download_buffer.h"
#include "esphome/components/http_request/http_request.h"
#include "esphome/components/image/image.h"
#include "esphome/components/runtime_image/runtime_image.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "image_decoder.h"
namespace esphome {
namespace online_image {
namespace esphome::online_image {
using t_http_codes = enum {
HTTP_CODE_OK = 200,
@@ -17,27 +16,13 @@ using t_http_codes = enum {
HTTP_CODE_NOT_FOUND = 404,
};
/**
* @brief Format that the image is encoded with.
*/
enum ImageFormat {
/** Automatically detect from MIME type. Not supported yet. */
AUTO,
/** JPEG format. */
JPEG,
/** PNG format. */
PNG,
/** BMP format. */
BMP,
};
/**
* @brief Download an image from a given URL, and decode it using the specified decoder.
* The image will then be stored in a buffer, so that it can be re-displayed without the
* need to re-download or re-decode.
*/
class OnlineImage : public PollingComponent,
public image::Image,
public runtime_image::RuntimeImage,
public Parented<esphome::http_request::HttpRequestComponent> {
public:
/**
@@ -46,17 +31,19 @@ class OnlineImage : public PollingComponent,
* @param url URL to download the image from.
* @param width Desired width of the target image area.
* @param height Desired height of the target image area.
* @param format Format that the image is encoded in (@see ImageFormat).
* @param format Format that the image is encoded in (@see runtime_image::ImageFormat).
* @param type The pixel format for the image.
* @param transparency The transparency type for the image.
* @param placeholder Optional placeholder image to show while loading.
* @param buffer_size Size of the buffer used to download the image.
* @param is_big_endian Whether the image is stored in big-endian format.
*/
OnlineImage(const std::string &url, int width, int height, ImageFormat format, image::ImageType type,
image::Transparency transparency, uint32_t buffer_size, bool is_big_endian);
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format, image::ImageType type,
image::Transparency transparency, image::Image *placeholder, uint32_t buffer_size,
bool is_big_endian = false);
void update() override;
void loop() override;
void map_chroma_key(Color &color);
/** Set the URL to download the image from. */
void set_url(const std::string &url) {
@@ -69,82 +56,26 @@ class OnlineImage : public PollingComponent,
/** Add the request header */
template<typename V> void add_request_header(const std::string &header, V value) {
this->request_headers_.push_back(std::pair<std::string, TemplatableValue<std::string> >(header, value));
this->request_headers_.push_back(std::pair<std::string, TemplatableValue<std::string>>(header, value));
}
/**
* @brief Set the image that needs to be shown as long as the downloaded image
* is not available.
*
* @param placeholder Pointer to the (@link Image) to show as placeholder.
*/
void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; }
/**
* Release the buffer storing the image. The image will need to be downloaded again
* to be able to be displayed.
*/
void release();
/**
* Resize the download buffer
*
* @param size The new size for the download buffer.
*/
size_t resize_download_buffer(size_t size) { return this->download_buffer_.resize(size); }
void add_on_finished_callback(std::function<void(bool)> &&callback);
void add_on_error_callback(std::function<void()> &&callback);
protected:
bool validate_url_(const std::string &url);
RAMAllocator<uint8_t> allocator_{};
uint32_t get_buffer_size_() const { return get_buffer_size_(this->buffer_width_, this->buffer_height_); }
int get_buffer_size_(int width, int height) const { return (this->get_bpp() * width + 7u) / 8u * height; }
int get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; }
ESPHOME_ALWAYS_INLINE bool is_auto_resize_() const { return this->fixed_width_ == 0 || this->fixed_height_ == 0; }
/**
* @brief Resize the image buffer to the requested dimensions.
*
* The buffer will be allocated if not existing.
* If the dimensions have been fixed in the yaml config, the buffer will be created
* with those dimensions and not resized, even on request.
* Otherwise, the old buffer will be deallocated and a new buffer with the requested
* allocated
*
* @param width
* @param height
* @return 0 if no memory could be allocated, the size of the new buffer otherwise.
*/
size_t resize_(int width, int height);
/**
* @brief Draw a pixel into the buffer.
*
* This is used by the decoder to fill the buffer that will later be displayed
* by the `draw` method. This will internally convert the supplied 32 bit RGBA
* color into the requested image storage format.
*
* @param x Horizontal pixel position.
* @param y Vertical pixel position.
* @param color 32 bit color to put into the pixel.
*/
void draw_pixel_(int x, int y, Color color);
void end_connection_();
CallbackManager<void(bool)> download_finished_callback_{};
CallbackManager<void()> download_error_callback_{};
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
std::unique_ptr<ImageDecoder> decoder_{nullptr};
uint8_t *buffer_;
DownloadBuffer download_buffer_;
/**
* This is the *initial* size of the download buffer, not the current size.
@@ -153,40 +84,10 @@ class OnlineImage : public PollingComponent,
*/
size_t download_buffer_initial_size_;
const ImageFormat format_;
image::Image *placeholder_{nullptr};
std::string url_{""};
std::vector<std::pair<std::string, TemplatableValue<std::string> > > request_headers_;
std::vector<std::pair<std::string, TemplatableValue<std::string>>> request_headers_;
/** width requested on configuration, or 0 if non specified. */
const int fixed_width_;
/** height requested on configuration, or 0 if non specified. */
const int fixed_height_;
/**
* Whether the image is stored in big-endian format.
* This is used to determine how to store 16 bit colors in the buffer.
*/
bool is_big_endian_;
/**
* Actual width of the current image. If fixed_width_ is specified,
* this will be equal to it; otherwise it will be set once the decoding
* starts and the original size is known.
* This needs to be separate from "BaseImage::get_width()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images).
*/
int buffer_width_;
/**
* Actual height of the current image. If fixed_height_ is specified,
* this will be equal to it; otherwise it will be set once the decoding
* starts and the original size is known.
* This needs to be separate from "BaseImage::get_height()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images).
*/
int buffer_height_;
/**
* The value of the ETag HTTP header provided in the last response.
*/
@@ -197,9 +98,6 @@ class OnlineImage : public PollingComponent,
std::string last_modified_ = "";
time_t start_time_;
friend bool ImageDecoder::set_size(int width, int height);
friend void ImageDecoder::draw(int x, int y, int w, int h, const Color &color);
};
template<typename... Ts> class OnlineImageSetUrlAction : public Action<Ts...> {
@@ -241,5 +139,4 @@ class DownloadErrorTrigger : public Trigger<> {
}
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::online_image

View File

@@ -0,0 +1,191 @@
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.image import (
IMAGE_TYPE,
Image_,
validate_settings,
validate_transparency,
validate_type,
)
import esphome.config_validation as cv
from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE
AUTO_LOAD = ["image"]
CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"]
CONF_PLACEHOLDER = "placeholder"
CONF_TRANSPARENCY = "transparency"
runtime_image_ns = cg.esphome_ns.namespace("runtime_image")
# Base decoder classes
ImageDecoder = runtime_image_ns.class_("ImageDecoder")
BmpDecoder = runtime_image_ns.class_("BmpDecoder", ImageDecoder)
JpegDecoder = runtime_image_ns.class_("JpegDecoder", ImageDecoder)
PngDecoder = runtime_image_ns.class_("PngDecoder", ImageDecoder)
# Runtime image class
RuntimeImage = runtime_image_ns.class_(
"RuntimeImage", cg.esphome_ns.namespace("image").class_("Image")
)
# Image format enum
ImageFormat = runtime_image_ns.enum("ImageFormat")
IMAGE_FORMAT_AUTO = ImageFormat.AUTO
IMAGE_FORMAT_JPEG = ImageFormat.JPEG
IMAGE_FORMAT_PNG = ImageFormat.PNG
IMAGE_FORMAT_BMP = ImageFormat.BMP
# Export enum for decode errors
DecodeError = runtime_image_ns.enum("DecodeError")
DECODE_ERROR_INVALID_TYPE = DecodeError.DECODE_ERROR_INVALID_TYPE
DECODE_ERROR_UNSUPPORTED_FORMAT = DecodeError.DECODE_ERROR_UNSUPPORTED_FORMAT
DECODE_ERROR_OUT_OF_MEMORY = DecodeError.DECODE_ERROR_OUT_OF_MEMORY
class Format:
"""Base class for image format definitions."""
def __init__(self, name: str, decoder_class: cg.MockObjClass) -> None:
self.name = name
self.decoder_class = decoder_class
def actions(self) -> None:
"""Add defines and libraries needed for this format."""
class BMPFormat(Format):
"""BMP format decoder configuration."""
def __init__(self):
super().__init__("BMP", BmpDecoder)
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_BMP")
class JPEGFormat(Format):
"""JPEG format decoder configuration."""
def __init__(self):
super().__init__("JPEG", JpegDecoder)
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_JPEG")
cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2")
class PNGFormat(Format):
"""PNG format decoder configuration."""
def __init__(self):
super().__init__("PNG", PngDecoder)
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_PNG")
cg.add_library("pngle", "1.1.0")
# Registry of available formats
IMAGE_FORMATS = {
"BMP": BMPFormat(),
"JPEG": JPEGFormat(),
"PNG": PNGFormat(),
"JPG": JPEGFormat(), # Alias for JPEG
}
def get_format(format_name: str) -> Format | None:
"""Get a format instance by name."""
return IMAGE_FORMATS.get(format_name.upper())
def enable_format(format_name: str) -> Format | None:
"""Enable a specific image format by adding its defines and libraries."""
format_obj = get_format(format_name)
if format_obj:
format_obj.actions()
return format_obj
return None
# Runtime image configuration schema base - to be extended by components
def runtime_image_schema(image_class: cg.MockObjClass = RuntimeImage) -> cv.Schema:
"""Create a runtime image schema with the specified image class."""
return cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(image_class),
cv.Required(CONF_FORMAT): cv.one_of(*IMAGE_FORMATS, upper=True),
cv.Optional(CONF_RESIZE): cv.dimensions,
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"BIG_ENDIAN", "LITTLE_ENDIAN", upper=True
),
cv.Optional(CONF_TRANSPARENCY, default="OPAQUE"): validate_transparency(),
cv.Optional(CONF_PLACEHOLDER): cv.use_id(Image_),
}
)
def validate_runtime_image_settings(config: dict) -> dict:
"""Apply validate_settings from image component to runtime image config."""
return validate_settings(config)
@dataclass
class RuntimeImageSettings:
"""Processed runtime image configuration parameters."""
width: int
height: int
format_enum: cg.MockObj
image_type_enum: cg.MockObj
transparent: cg.MockObj
byte_order_big_endian: bool
placeholder: cg.MockObj | None
async def process_runtime_image_config(config: dict) -> RuntimeImageSettings:
"""
Helper function to process common runtime image configuration parameters.
Handles format enabling and returns all necessary enums and parameters.
"""
from esphome.components.image import get_image_type_enum, get_transparency_enum
# Get resize dimensions with default (0, 0)
width, height = config.get(CONF_RESIZE, (0, 0))
# Handle format (required for runtime images)
format_name = config[CONF_FORMAT]
# Enable the format in the runtime_image component
enable_format(format_name)
# Map format names to enum values (handle JPG as alias for JPEG)
if format_name.upper() == "JPG":
format_name = "JPEG"
format_enum = getattr(ImageFormat, format_name.upper())
# Get image type enum
image_type_enum = get_image_type_enum(config[CONF_TYPE])
# Get transparency enum
transparent = get_transparency_enum(config.get(CONF_TRANSPARENCY, "OPAQUE"))
# Get byte order (True for big endian, False for little endian)
byte_order_big_endian = config.get(CONF_BYTE_ORDER) != "LITTLE_ENDIAN"
# Get placeholder if specified
placeholder = None
if placeholder_id := config.get(CONF_PLACEHOLDER):
placeholder = await cg.get_variable(placeholder_id)
return RuntimeImageSettings(
width=width,
height=height,
format_enum=format_enum,
image_type_enum=image_type_enum,
transparent=transparent,
byte_order_big_endian=byte_order_big_endian,
placeholder=placeholder,
)

View File

@@ -1,15 +1,14 @@
#include "bmp_image.h"
#include "bmp_decoder.h"
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_RUNTIME_IMAGE_BMP
#include "esphome/components/display/display.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
static const char *const TAG = "online_image.bmp";
static const char *const TAG = "image_decoder.bmp";
int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
size_t index = 0;
@@ -30,7 +29,11 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
return DECODE_ERROR_INVALID_TYPE;
}
this->download_size_ = encode_uint32(buffer[5], buffer[4], buffer[3], buffer[2]);
// BMP file contains its own size in the header
size_t file_size = encode_uint32(buffer[5], buffer[4], buffer[3], buffer[2]);
if (this->expected_size_ == 0) {
this->expected_size_ = file_size; // Use file header size if not provided
}
this->data_offset_ = encode_uint32(buffer[13], buffer[12], buffer[11], buffer[10]);
this->current_index_ = 14;
@@ -90,8 +93,8 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
while (index < size) {
uint8_t current_byte = buffer[index];
for (uint8_t i = 0; i < 8; i++) {
size_t x = (this->paint_index_ % this->width_) + i;
size_t y = (this->height_ - 1) - (this->paint_index_ / this->width_);
size_t x = (this->paint_index_ % static_cast<size_t>(this->width_)) + i;
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / static_cast<size_t>(this->width_));
Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF;
this->draw(x, y, 1, 1, c);
}
@@ -110,8 +113,8 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
uint8_t b = buffer[index];
uint8_t g = buffer[index + 1];
uint8_t r = buffer[index + 2];
size_t x = this->paint_index_ % this->width_;
size_t y = (this->height_ - 1) - (this->paint_index_ / this->width_);
size_t x = this->paint_index_ % static_cast<size_t>(this->width_);
size_t y = static_cast<size_t>(this->height_ - 1) - (this->paint_index_ / static_cast<size_t>(this->width_));
Color c = Color(r, g, b);
this->draw(x, y, 1, 1, c);
this->paint_index_++;
@@ -133,7 +136,6 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
return size;
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#endif // USE_RUNTIME_IMAGE_BMP

View File

@@ -1,27 +1,32 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ONLINE_IMAGE_BMP_SUPPORT
#ifdef USE_RUNTIME_IMAGE_BMP
#include "image_decoder.h"
#include "runtime_image.h"
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Image decoder specialization for PNG images.
* @brief Image decoder specialization for BMP images.
*/
class BmpDecoder : public ImageDecoder {
public:
/**
* @brief Construct a new BMP Decoder object.
*
* @param display The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
BmpDecoder(OnlineImage *image) : ImageDecoder(image) {}
BmpDecoder(RuntimeImage *image) : ImageDecoder(image) {}
int HOT decode(uint8_t *buffer, size_t size) override;
bool is_finished() const override {
// BMP is finished when we've decoded all pixel data
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
}
protected:
size_t current_index_{0};
size_t paint_index_{0};
@@ -36,7 +41,6 @@ class BmpDecoder : public ImageDecoder {
uint8_t padding_bytes_{0};
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_BMP_SUPPORT
#endif // USE_RUNTIME_IMAGE_BMP

View File

@@ -0,0 +1,28 @@
#include "image_decoder.h"
#include "runtime_image.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::runtime_image {
static const char *const TAG = "image_decoder";
bool ImageDecoder::set_size(int width, int height) {
bool success = this->image_->resize(width, height) > 0;
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
return success;
}
void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
auto width = std::min(this->image_->get_buffer_width(), static_cast<int>(std::ceil((x + w) * this->x_scale_)));
auto height = std::min(this->image_->get_buffer_height(), static_cast<int>(std::ceil((y + h) * this->y_scale_)));
for (int i = x * this->x_scale_; i < width; i++) {
for (int j = y * this->y_scale_; j < height; j++) {
this->image_->draw_pixel(i, j, color);
}
}
}
} // namespace esphome::runtime_image

View File

@@ -1,8 +1,7 @@
#pragma once
#include "esphome/core/color.h"
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
enum DecodeError : int {
DECODE_ERROR_INVALID_TYPE = -1,
@@ -10,7 +9,7 @@ enum DecodeError : int {
DECODE_ERROR_OUT_OF_MEMORY = -3,
};
class OnlineImage;
class RuntimeImage;
/**
* @brief Class to abstract decoding different image formats.
@@ -20,19 +19,19 @@ class ImageDecoder {
/**
* @brief Construct a new Image Decoder object
*
* @param image The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
ImageDecoder(OnlineImage *image) : image_(image) {}
ImageDecoder(RuntimeImage *image) : image_(image) {}
virtual ~ImageDecoder() = default;
/**
* @brief Initialize the decoder.
*
* @param download_size The total number of bytes that need to be downloaded for the image.
* @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 download_size) {
this->download_size_ = download_size;
virtual int prepare(size_t expected_size) {
this->expected_size_ = expected_size;
return 0;
}
@@ -73,49 +72,26 @@ class ImageDecoder {
*/
void draw(int x, int y, int w, int h, const Color &color);
bool is_finished() const { return this->decoded_bytes_ == this->download_size_; }
/**
* @brief Check if the decoder has finished processing.
*
* This should be overridden by decoders that can detect completion
* based on format-specific markers rather than byte counts.
*/
virtual bool is_finished() const {
if (this->expected_size_ > 0) {
return this->decoded_bytes_ >= this->expected_size_;
}
// If size is unknown, derived classes should override this
return false;
}
protected:
OnlineImage *image_;
// Initializing to 1, to ensure it is distinguishable from initial "decoded_bytes_".
// Will be overwritten anyway once the download size is known.
size_t download_size_ = 1;
size_t decoded_bytes_ = 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;
};
class DownloadBuffer {
public:
DownloadBuffer(size_t size);
virtual ~DownloadBuffer() { this->allocator_.deallocate(this->buffer_, this->size_); }
uint8_t *data(size_t offset = 0);
uint8_t *append() { return this->data(this->unread_); }
size_t unread() const { return this->unread_; }
size_t size() const { return this->size_; }
size_t free_capacity() const { return this->size_ - this->unread_; }
size_t read(size_t len);
size_t write(size_t len) {
this->unread_ += len;
return this->unread_;
}
void reset() { this->unread_ = 0; }
size_t resize(size_t size);
protected:
RAMAllocator<uint8_t> allocator_{};
uint8_t *buffer_;
size_t size_;
/** Total number of downloaded bytes not yet read. */
size_t unread_;
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image

View File

@@ -1,16 +1,19 @@
#include "jpeg_image.h"
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
#include "jpeg_decoder.h"
#ifdef USE_RUNTIME_IMAGE_JPEG
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "online_image.h"
static const char *const TAG = "online_image.jpeg";
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
namespace esphome {
namespace online_image {
static const char *const TAG = "image_decoder.jpeg";
namespace esphome::runtime_image {
/**
* @brief Callback method that will be called by the JPEGDEC engine when a chunk
@@ -22,8 +25,14 @@ static int draw_callback(JPEGDRAW *jpeg) {
ImageDecoder *decoder = (ImageDecoder *) jpeg->pUser;
// Some very big images take too long to decode, so feed the watchdog on each callback
// to avoid crashing.
App.feed_wdt();
// to avoid crashing if the executing task has a watchdog enabled.
#ifdef USE_ESP_IDF
if (esp_task_wdt_status(nullptr) == ESP_OK) {
#endif
App.feed_wdt();
#ifdef USE_ESP_IDF
}
#endif
size_t position = 0;
size_t height = static_cast<size_t>(jpeg->iHeight);
size_t width = static_cast<size_t>(jpeg->iWidth);
@@ -43,22 +52,23 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
int JpegDecoder::prepare(size_t download_size) {
ImageDecoder::prepare(download_size);
auto size = this->image_->resize_download_buffer(download_size);
if (size < download_size) {
ESP_LOGE(TAG, "Download buffer resize failed!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
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) {
if (size < this->download_size_) {
ESP_LOGV(TAG, "Download not complete. Size: %d/%d", size, this->download_size_);
// JPEG decoder requires complete data
// If we know the expected size, wait for it
if (this->expected_size_ > 0 && size < this->expected_size_) {
ESP_LOGV(TAG, "Download not complete. Size: %zu/%zu", size, this->expected_size_);
return 0;
}
// If size unknown, try to decode and see if it's valid
// The JPEGDEC library will fail gracefully if data is incomplete
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
return DECODE_ERROR_INVALID_TYPE;
@@ -88,7 +98,6 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
return size;
}
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#endif // USE_RUNTIME_IMAGE_JPEG

View File

@@ -1,12 +1,12 @@
#pragma once
#include "image_decoder.h"
#include "runtime_image.h"
#include "esphome/core/defines.h"
#ifdef USE_ONLINE_IMAGE_JPEG_SUPPORT
#ifdef USE_RUNTIME_IMAGE_JPEG
#include <JPEGDEC.h>
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Image decoder specialization for JPEG images.
@@ -16,19 +16,18 @@ class JpegDecoder : public ImageDecoder {
/**
* @brief Construct a new JPEG Decoder object.
*
* @param display The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(OnlineImage *image) : ImageDecoder(image) {}
JpegDecoder(RuntimeImage *image) : ImageDecoder(image) {}
~JpegDecoder() override {}
int prepare(size_t download_size) override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
protected:
JPEGDEC jpeg_{};
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_JPEG_SUPPORT
#endif // USE_RUNTIME_IMAGE_JPEG

View File

@@ -1,15 +1,14 @@
#include "png_image.h"
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
#include "png_decoder.h"
#ifdef USE_RUNTIME_IMAGE_PNG
#include "esphome/components/display/display_buffer.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
static const char *const TAG = "online_image.png";
static const char *const TAG = "image_decoder.png";
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Callback method that will be called by the PNGLE engine when the basic
@@ -49,7 +48,7 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui
}
}
PngDecoder::PngDecoder(OnlineImage *image) : ImageDecoder(image) {
PngDecoder::PngDecoder(RuntimeImage *image) : ImageDecoder(image) {
{
pngle_t *pngle = this->allocator_.allocate(1, PNGLE_T_SIZE);
if (!pngle) {
@@ -69,8 +68,8 @@ PngDecoder::~PngDecoder() {
}
}
int PngDecoder::prepare(size_t download_size) {
ImageDecoder::prepare(download_size);
int PngDecoder::prepare(size_t expected_size) {
ImageDecoder::prepare(expected_size);
if (!this->pngle_) {
ESP_LOGE(TAG, "PNG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
@@ -86,8 +85,9 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
ESP_LOGE(TAG, "PNG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (size < 256 && size < this->download_size_ - this->decoded_bytes_) {
ESP_LOGD(TAG, "Waiting for data");
// PNG can be decoded progressively, but wait for a reasonable chunk
if (size < 256 && this->expected_size_ > 0 && size < this->expected_size_ - this->decoded_bytes_) {
ESP_LOGD(TAG, "Waiting for more data");
return 0;
}
auto fed = pngle_feed(this->pngle_, buffer, size);
@@ -99,7 +99,6 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
return fed;
}
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
#endif // USE_RUNTIME_IMAGE_PNG

View File

@@ -3,11 +3,11 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "image_decoder.h"
#ifdef USE_ONLINE_IMAGE_PNG_SUPPORT
#include "runtime_image.h"
#ifdef USE_RUNTIME_IMAGE_PNG
#include <pngle.h>
namespace esphome {
namespace online_image {
namespace esphome::runtime_image {
/**
* @brief Image decoder specialization for PNG images.
@@ -17,12 +17,12 @@ class PngDecoder : public ImageDecoder {
/**
* @brief Construct a new PNG Decoder object.
*
* @param display The image to decode the stream into.
* @param image The RuntimeImage to decode the stream into.
*/
PngDecoder(OnlineImage *image);
PngDecoder(RuntimeImage *image);
~PngDecoder() override;
int prepare(size_t download_size) override;
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
void increment_pixels_decoded(uint32_t count) { this->pixels_decoded_ += count; }
@@ -30,11 +30,10 @@ class PngDecoder : public ImageDecoder {
protected:
RAMAllocator<pngle_t> allocator_;
pngle_t *pngle_;
pngle_t *pngle_{nullptr};
uint32_t pixels_decoded_{0};
};
} // namespace online_image
} // namespace esphome
} // namespace esphome::runtime_image
#endif // USE_ONLINE_IMAGE_PNG_SUPPORT
#endif // USE_RUNTIME_IMAGE_PNG

View File

@@ -0,0 +1,300 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include <cstring>
#ifdef USE_RUNTIME_IMAGE_BMP
#include "bmp_decoder.h"
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
#include "jpeg_decoder.h"
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
#include "png_decoder.h"
#endif
namespace esphome::runtime_image {
static const char *const TAG = "runtime_image";
inline bool is_color_on(const Color &color) {
// This produces the most accurate monochrome conversion, but is slightly slower.
// return (0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b) > 127;
// Approximation using fast integer computations; produces acceptable results
// Equivalent to 0.25 * R + 0.5 * G + 0.25 * B
return ((color.r >> 2) + (color.g >> 1) + (color.b >> 2)) & 0x80;
}
RuntimeImage::RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency,
image::Image *placeholder, bool is_big_endian, int fixed_width, int fixed_height)
: Image(nullptr, 0, 0, type, transparency),
format_(format),
fixed_width_(fixed_width),
fixed_height_(fixed_height),
placeholder_(placeholder),
is_big_endian_(is_big_endian) {}
RuntimeImage::~RuntimeImage() { this->release(); }
int RuntimeImage::resize(int width, int height) {
// Use fixed dimensions if specified (0 means auto-resize)
int target_width = this->fixed_width_ ? this->fixed_width_ : width;
int target_height = this->fixed_height_ ? this->fixed_height_ : height;
size_t result = this->resize_buffer_(target_width, target_height);
if (result > 0 && this->progressive_display_) {
// Update display dimensions for progressive display
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
}
return result;
}
void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
if (!this->buffer_) {
ESP_LOGE(TAG, "Buffer not allocated!");
return;
}
if (x < 0 || y < 0 || x >= this->buffer_width_ || y >= this->buffer_height_) {
ESP_LOGE(TAG, "Tried to paint a pixel (%d,%d) outside the image!", x, y);
return;
}
switch (this->type_) {
case image::IMAGE_TYPE_BINARY: {
const uint32_t width_8 = ((this->buffer_width_ + 7u) / 8u) * 8u;
uint32_t pos = x + y * width_8;
auto bitno = 0x80 >> (pos % 8u);
pos /= 8u;
auto on = is_color_on(color);
if (this->has_transparency() && color.w < 0x80)
on = false;
if (on) {
this->buffer_[pos] |= bitno;
} else {
this->buffer_[pos] &= ~bitno;
}
break;
}
case image::IMAGE_TYPE_GRAYSCALE: {
uint32_t pos = this->get_position_(x, y);
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
}
if (color.w < 0x80) {
gray = 1;
}
} else if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
if (color.w != 0xFF)
gray = color.w;
}
this->buffer_[pos] = gray;
break;
}
case image::IMAGE_TYPE_RGB565: {
uint32_t pos = this->get_position_(x, y);
Color mapped_color = color;
this->map_chroma_key(mapped_color);
uint16_t rgb565 = display::ColorUtil::color_to_565(mapped_color);
if (this->is_big_endian_) {
this->buffer_[pos + 0] = static_cast<uint8_t>((rgb565 >> 8) & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>(rgb565 & 0xFF);
} else {
this->buffer_[pos + 0] = static_cast<uint8_t>(rgb565 & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>((rgb565 >> 8) & 0xFF);
}
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 2] = color.w;
}
break;
}
case image::IMAGE_TYPE_RGB: {
uint32_t pos = this->get_position_(x, y);
Color mapped_color = color;
this->map_chroma_key(mapped_color);
this->buffer_[pos + 0] = mapped_color.r;
this->buffer_[pos + 1] = mapped_color.g;
this->buffer_[pos + 2] = mapped_color.b;
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 3] = color.w;
}
break;
}
}
}
void RuntimeImage::map_chroma_key(Color &color) {
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (color.g == 1 && color.r == 0 && color.b == 0) {
color.g = 0;
}
if (color.w < 0x80) {
color.r = 0;
color.g = this->type_ == image::IMAGE_TYPE_RGB565 ? 4 : 1;
color.b = 0;
}
}
}
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
Image::draw(x, y, display, color_on, color_off);
} else if (this->placeholder_) {
// Show placeholder while the runtime image is not available
this->placeholder_->draw(x, y, display, color_on, color_off);
}
// If no image is loaded and no placeholder, nothing to draw
}
bool RuntimeImage::begin_decode(size_t expected_size) {
if (this->decoder_) {
ESP_LOGW(TAG, "Decoding already in progress");
return false;
}
this->decoder_ = this->create_decoder_();
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;
return false;
}
return true;
}
int RuntimeImage::feed_data(uint8_t *data, size_t len) {
if (!this->decoder_) {
ESP_LOGE(TAG, "No decoder initialized");
return -1;
}
int consumed = this->decoder_->decode(data, len);
if (consumed > 0) {
this->decoded_bytes_ += consumed;
}
return consumed;
}
bool RuntimeImage::end_decode() {
if (!this->decoder_) {
return false;
}
// Finalize the image for display
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_;
}
// Clean up decoder
this->decoder_ = nullptr;
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();
}
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;
}
void RuntimeImage::release_buffer_() {
if (this->buffer_) {
ESP_LOGV(TAG, "Releasing buffer of size %zu", this->get_buffer_size_(this->buffer_width_, this->buffer_height_));
this->allocator_.deallocate(this->buffer_, this->get_buffer_size_(this->buffer_width_, this->buffer_height_));
this->buffer_ = nullptr;
this->data_start_ = nullptr;
this->width_ = 0;
this->height_ = 0;
this->buffer_width_ = 0;
this->buffer_height_ = 0;
}
}
size_t RuntimeImage::resize_buffer_(int width, int height) {
size_t new_size = this->get_buffer_size_(width, height);
if (this->buffer_ && this->buffer_width_ == width && this->buffer_height_ == height) {
// Buffer already allocated with correct size
return new_size;
}
// Release old buffer if dimensions changed
if (this->buffer_) {
this->release_buffer_();
}
ESP_LOGD(TAG, "Allocating buffer: %dx%d, %zu bytes", width, height, new_size);
this->buffer_ = this->allocator_.allocate(new_size);
if (!this->buffer_) {
ESP_LOGE(TAG, "Failed to allocate %zu bytes. Largest free block: %zu", new_size,
this->allocator_.get_max_free_block_size());
return 0;
}
// Clear buffer
memset(this->buffer_, 0, new_size);
this->buffer_width_ = width;
this->buffer_height_ = height;
return new_size;
}
size_t RuntimeImage::get_buffer_size_(int width, int height) const {
return (this->get_bpp() * width + 7u) / 8u * height;
}
int RuntimeImage::get_position_(int x, int y) const { return (x + y * this->buffer_width_) * this->get_bpp() / 8; }
std::unique_ptr<ImageDecoder> RuntimeImage::create_decoder_() {
switch (this->format_) {
#ifdef USE_RUNTIME_IMAGE_BMP
case BMP:
return make_unique<BmpDecoder>(this);
#endif
#ifdef USE_RUNTIME_IMAGE_JPEG
case JPEG:
return make_unique<JpegDecoder>(this);
#endif
#ifdef USE_RUNTIME_IMAGE_PNG
case PNG:
return make_unique<PngDecoder>(this);
#endif
default:
ESP_LOGE(TAG, "Unsupported image format: %d", this->format_);
return nullptr;
}
}
} // namespace esphome::runtime_image

View File

@@ -0,0 +1,214 @@
#pragma once
#include "esphome/components/image/image.h"
#include "esphome/core/helpers.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.
*
* This class provides dynamic buffer allocation and management for images
* that are decoded at runtime, as opposed to static images compiled into
* the firmware. It serves as a base class for components that need to
* load images dynamically from various sources.
*/
class RuntimeImage : public image::Image {
public:
/**
* @brief Construct a new RuntimeImage object.
*
* @param format The image format to decode.
* @param type The pixel format for the image.
* @param transparency The transparency type for the image.
* @param placeholder Optional placeholder image to show while loading.
* @param is_big_endian Whether the image is stored in big-endian format.
* @param fixed_width Fixed width for the image (0 for auto-resize).
* @param fixed_height Fixed height for the image (0 for auto-resize).
*/
RuntimeImage(ImageFormat format, image::ImageType type, image::Transparency transparency,
image::Image *placeholder = nullptr, bool is_big_endian = false, int fixed_width = 0,
int fixed_height = 0);
~RuntimeImage();
// Decoder interface methods
/**
* @brief Resize the image buffer to the requested dimensions.
*
* The buffer will be allocated if not existing.
* If fixed dimensions have been specified in the constructor, the buffer will be created
* with those dimensions and not resized, even on request.
* Otherwise, the old buffer will be deallocated and a new buffer with the requested
* dimensions allocated.
*
* @param width Requested width (ignored if fixed_width_ is set)
* @param height Requested height (ignored if fixed_height_ is set)
* @return Size of the allocated buffer in bytes, or 0 if allocation failed.
*/
int resize(int width, int height);
void draw_pixel(int x, int y, const Color &color);
void map_chroma_key(Color &color);
int get_buffer_width() const { return this->buffer_width_; }
int get_buffer_height() const { return this->buffer_height_; }
// Image drawing interface
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
/**
* @brief Begin decoding an image.
*
* @param expected_size Optional hint about the expected data size.
* @return true if decoder was successfully initialized.
*/
bool begin_decode(size_t expected_size = 0);
/**
* @brief Feed data to the decoder.
*
* @param data Pointer to the data buffer.
* @param len Length of data to process.
* @return Number of bytes consumed by the decoder.
*/
int feed_data(uint8_t *data, size_t len);
/**
* @brief Complete the decoding process.
*
* @return true if decoding completed successfully.
*/
bool end_decode();
/**
* @brief Check if decoding is currently in progress.
*/
bool is_decoding() const { return this->decoder_ != nullptr; }
/**
* @brief Check if the decoder has finished processing all data.
*
* This delegates to the decoder's format-specific completion check,
* which handles both known-size and chunked transfer cases.
*/
bool is_decode_finished() const;
/**
* @brief Check if an image is currently loaded.
*/
bool is_loaded() const { return this->buffer_ != nullptr; }
/**
* @brief Get the image format.
*/
ImageFormat get_format() const { return this->format_; }
/**
* @brief Release the image buffer and free memory.
*/
void release();
/**
* @brief Set whether to allow progressive display during decode.
*
* When enabled, the image can be displayed even while still decoding.
* When disabled, the image is only displayed after decoding completes.
*/
void set_progressive_display(bool progressive) { this->progressive_display_ = progressive; }
protected:
/**
* @brief Resize the image buffer to the requested dimensions.
*
* @param width New width in pixels.
* @param height New height in pixels.
* @return Size of the allocated buffer, or 0 on failure.
*/
size_t resize_buffer_(int width, int height);
/**
* @brief Release only the image buffer without resetting the decoder.
*
* This is safe to call from within the decoder (e.g., during resize).
*/
void release_buffer_();
/**
* @brief Get the buffer size in bytes for given dimensions.
*/
size_t get_buffer_size_(int width, int height) const;
/**
* @brief Get the position in the buffer for a pixel.
*/
int get_position_(int x, int y) const;
/**
* @brief Create decoder instance for the image's format.
*/
std::unique_ptr<ImageDecoder> create_decoder_();
// Memory management
RAMAllocator<uint8_t> allocator_{};
uint8_t *buffer_{nullptr};
// Decoder management
std::unique_ptr<ImageDecoder> decoder_{nullptr};
/** The image format this RuntimeImage is configured to decode. */
const ImageFormat format_;
/**
* Actual width of the current image.
* This needs to be separate from "Image::get_width()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images). When progressive_display_ is enabled, Image dimensions
* are updated during decoding to allow rendering in progress.
*/
int buffer_width_{0};
/**
* Actual height of the current image.
* This needs to be separate from "Image::get_height()" because the latter
* must return 0 until the image has been decoded (to avoid showing partially
* decoded images). When progressive_display_ is enabled, Image dimensions
* are updated during decoding to allow rendering in progress.
*/
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. */
const int fixed_width_{0};
/** Fixed height requested on configuration, or 0 if not specified. */
const int fixed_height_{0};
/** Placeholder image to show when the runtime image is not available. */
image::Image *placeholder_{nullptr};
// Configuration
bool progressive_display_{false};
/**
* Whether the image is stored in big-endian format.
* This is used to determine how to store 16 bit colors in the buffer.
*/
bool is_big_endian_{false};
};
} // namespace esphome::runtime_image

View File

@@ -148,6 +148,7 @@ class USBClient : public Component {
EventPool<UsbEvent, USB_EVENT_QUEUE_SIZE> event_pool;
protected:
void handle_open_state_();
TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe)
virtual void disconnect();
virtual void on_connected() {}

View File

@@ -9,6 +9,7 @@
#include <cinttypes>
#include <cstring>
#include <atomic>
#include <span>
namespace esphome {
namespace usb_host {
@@ -142,18 +143,23 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc
} while (next_desc != NULL);
}
#endif
static std::string get_descriptor_string(const usb_str_desc_t *desc) {
char buffer[256];
if (desc == nullptr)
// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType).
// Character count = (bLength - 2) / 2, max 126 chars + null terminator.
static constexpr size_t DESC_STRING_BUF_SIZE = 128;
static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span<char, DESC_STRING_BUF_SIZE> buffer) {
if (desc == nullptr || desc->bLength < 2)
return "(unspecified)";
char *p = buffer;
for (int i = 0; i != desc->bLength / 2; i++) {
int char_count = (desc->bLength - 2) / 2;
char *p = buffer.data();
char *end = p + buffer.size() - 1;
for (int i = 0; i != char_count && p < end; i++) {
auto c = desc->wData[i];
if (c < 0x100)
*p++ = static_cast<char>(c);
}
*p = '\0';
return {buffer};
return buffer.data();
}
// CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task)
@@ -259,60 +265,63 @@ void USBClient::loop() {
ESP_LOGW(TAG, "Dropped %u USB events due to queue overflow", dropped);
}
switch (this->state_) {
case USB_CLIENT_OPEN: {
int err;
ESP_LOGD(TAG, "Open device %d", this->device_addr_);
err = usb_host_device_open(this->handle_, this->device_addr_, &this->device_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device open failed: %s", esp_err_to_name(err));
this->state_ = USB_CLIENT_INIT;
break;
}
ESP_LOGD(TAG, "Get descriptor device %d", this->device_addr_);
const usb_device_desc_t *desc;
err = usb_host_get_device_descriptor(this->device_handle_, &desc);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device get_desc failed: %s", esp_err_to_name(err));
this->disconnect();
} else {
ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct);
if (desc->idVendor == this->vid_ && desc->idProduct == this->pid_ || this->vid_ == 0 && this->pid_ == 0) {
usb_device_info_t dev_info;
err = usb_host_device_info(this->device_handle_, &dev_info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device info failed: %s", esp_err_to_name(err));
this->disconnect();
break;
}
this->state_ = USB_CLIENT_CONNECTED;
ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s",
get_descriptor_string(dev_info.str_desc_manufacturer).c_str(),
get_descriptor_string(dev_info.str_desc_product).c_str(),
get_descriptor_string(dev_info.str_desc_serial_num).c_str());
if (this->state_ == USB_CLIENT_OPEN) {
this->handle_open_state_();
}
}
void USBClient::handle_open_state_() {
int err;
ESP_LOGD(TAG, "Open device %d", this->device_addr_);
err = usb_host_device_open(this->handle_, this->device_addr_, &this->device_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device open failed: %s", esp_err_to_name(err));
this->state_ = USB_CLIENT_INIT;
return;
}
ESP_LOGD(TAG, "Get descriptor device %d", this->device_addr_);
const usb_device_desc_t *desc;
err = usb_host_get_device_descriptor(this->device_handle_, &desc);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device get_desc failed: %s", esp_err_to_name(err));
this->disconnect();
return;
}
ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct);
if (desc->idVendor != this->vid_ || desc->idProduct != this->pid_) {
if (this->vid_ != 0 || this->pid_ != 0) {
ESP_LOGD(TAG, "Not our device, closing");
this->disconnect();
return;
}
}
usb_device_info_t dev_info;
err = usb_host_device_info(this->device_handle_, &dev_info);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Device info failed: %s", esp_err_to_name(err));
this->disconnect();
return;
}
this->state_ = USB_CLIENT_CONNECTED;
char buf_manuf[DESC_STRING_BUF_SIZE];
char buf_product[DESC_STRING_BUF_SIZE];
char buf_serial[DESC_STRING_BUF_SIZE];
ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s",
get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf),
get_descriptor_string(dev_info.str_desc_product, buf_product),
get_descriptor_string(dev_info.str_desc_serial_num, buf_serial));
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
const usb_device_desc_t *device_desc;
err = usb_host_get_device_descriptor(this->device_handle_, &device_desc);
if (err == ESP_OK)
usb_client_print_device_descriptor(device_desc);
const usb_config_desc_t *config_desc;
err = usb_host_get_active_config_descriptor(this->device_handle_, &config_desc);
if (err == ESP_OK)
usb_client_print_config_descriptor(config_desc, nullptr);
const usb_device_desc_t *device_desc;
err = usb_host_get_device_descriptor(this->device_handle_, &device_desc);
if (err == ESP_OK)
usb_client_print_device_descriptor(device_desc);
const usb_config_desc_t *config_desc;
err = usb_host_get_active_config_descriptor(this->device_handle_, &config_desc);
if (err == ESP_OK)
usb_client_print_config_descriptor(config_desc, nullptr);
#endif
this->on_connected();
} else {
ESP_LOGD(TAG, "Not our device, closing");
this->disconnect();
}
}
break;
}
default:
break;
}
this->on_connected();
}
void USBClient::on_opened(uint8_t addr) {

View File

@@ -198,7 +198,8 @@ EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const {
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
// helper for allowing only unique entries in the queue
void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
void __attribute__((flatten))
DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
DeferredEvent item(source, message_generator);
// Use range-based for loop instead of std::find_if to reduce template instantiation overhead and binary size

View File

@@ -487,6 +487,19 @@ bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t
return false;
}
void __attribute__((flatten)) WiFiComponent::set_sta_priority(bssid_t bssid, int8_t priority) {
for (auto &it : this->sta_priorities_) {
if (it.bssid == bssid) {
it.priority = priority;
return;
}
}
this->sta_priorities_.push_back(WiFiSTAPriority{
.bssid = bssid,
.priority = priority,
});
}
void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
// Skip logging during roaming scans to avoid log buffer overflow

View File

@@ -488,18 +488,7 @@ class WiFiComponent : public Component {
}
return 0;
}
void set_sta_priority(const bssid_t bssid, int8_t priority) {
for (auto &it : this->sta_priorities_) {
if (it.bssid == bssid) {
it.priority = priority;
return;
}
}
this->sta_priorities_.push_back(WiFiSTAPriority{
.bssid = bssid,
.priority = priority,
});
}
void set_sta_priority(bssid_t bssid, int8_t priority);
network::IPAddresses wifi_sta_ip_addresses();
// Remove before 2026.9.0

View File

@@ -148,9 +148,9 @@
#define USE_MQTT
#define USE_MQTT_COVER_JSON
#define USE_NETWORK
#define USE_ONLINE_IMAGE_BMP_SUPPORT
#define USE_ONLINE_IMAGE_PNG_SUPPORT
#define USE_ONLINE_IMAGE_JPEG_SUPPORT
#define USE_RUNTIME_IMAGE_BMP
#define USE_RUNTIME_IMAGE_PNG
#define USE_RUNTIME_IMAGE_JPEG
#define USE_OTA
#define USE_OTA_PASSWORD
#define USE_OTA_STATE_LISTENER

View File

@@ -270,6 +270,14 @@ async def test_alarm_control_panel_state_transitions(
# The chime_sensor has chime: true, so opening it while disarmed
# should trigger on_chime callback
# Set up future for the on_ready from opening the chime sensor
# (alarm becomes "not ready" when chime sensor opens).
# We must wait for this BEFORE creating the close future, otherwise
# the open event's log can arrive late and resolve the close future,
# causing the test to proceed before the chime close is processed.
ready_after_chime_open: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_open)
# We're currently DISARMED - open the chime sensor
client.switch_command(chime_switch_info.key, True)
@@ -279,11 +287,18 @@ async def test_alarm_control_panel_state_transitions(
except TimeoutError:
pytest.fail(f"on_chime callback not fired. Log lines: {log_lines[-20:]}")
# Close the chime sensor and wait for alarm to become ready again
# We need to wait for this transition before testing door sensor,
# otherwise there's a race where the door sensor state change could
# arrive before the chime sensor state change, leaving the alarm in
# a continuous "not ready" state with no on_ready callback fired.
# Wait for the on_ready from the chime sensor opening
try:
await asyncio.wait_for(ready_after_chime_open, timeout=2.0)
except TimeoutError:
pytest.fail(
f"on_ready callback not fired when chime sensor opened. "
f"Log lines: {log_lines[-20:]}"
)
# Now create the future for the close event and close the sensor.
# Since we waited for the open event above, the close event's
# on_ready log cannot be confused with the open event's.
ready_after_chime_close: asyncio.Future[bool] = loop.create_future()
ready_futures.append(ready_after_chime_close)