ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
online_image.cpp
Go to the documentation of this file.
1#include "online_image.h"
5#include "esphome/core/log.h"
6#include <algorithm>
7#include <cmath>
8#include <cstdio>
9
10ESPHOME_LOG_TAG(TAG, "online_image");
11static const char *const CONTENT_TYPE_HEADER_NAME = "content-type";
12static const char *const ETAG_HEADER_NAME = "etag";
13static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match";
14static const char *const LAST_MODIFIED_HEADER_NAME = "last-modified";
15static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
16
17namespace esphome::online_image {
18
19// Time one loop pass may spend reading, and decoding a JPEG as it downloads
20static constexpr uint32_t PASS_BUDGET_MS = 20;
21// Shortest time between two on_download_progress calls
22static constexpr uint32_t PROGRESS_INTERVAL_MS = 100;
23
24OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
25 image::ImageType type, image::Transparency transparency, image::Image *placeholder,
26 uint32_t buffer_size, bool is_big_endian)
27 : RuntimeImage(format, type, transparency, placeholder, is_big_endian, width, height),
28 download_buffer_(buffer_size),
29 download_buffer_initial_size_(buffer_size) {
30 this->set_url(url);
31}
32
33bool OnlineImage::validate_url_(const std::string &url) {
34 if (url.empty()) {
35 ESP_LOGE(TAG, "URL is empty");
36 return false;
37 }
38 if (url.length() > 2048) {
39 ESP_LOGE(TAG, "URL is too long");
40 return false;
41 }
42 if (!url.starts_with("http://") && !url.starts_with("https://")) {
43 ESP_LOGE(TAG, "URL must start with http:// or https://");
44 return false;
45 }
46 return true;
47}
48
50 if (this->is_decoding()) {
51 ESP_LOGW(TAG, "Image already being updated.");
52 return;
53 }
54
55 if (!this->validate_url_(this->url_)) {
56 ESP_LOGE(TAG, "Invalid URL: %s", this->url_.c_str());
57 this->download_error_callback_.call();
58 return;
59 }
60
61 // A failed allocation at boot, or while growing the buffer for a JPEG, leaves no download buffer
62 if (this->download_buffer_.size() == 0 && this->download_buffer_.resize(this->download_buffer_initial_size_) == 0) {
63 // resize() logged the failed allocation
64 this->download_error_callback_.call();
65 return;
66 }
67
68 ESP_LOGD(TAG, "Updating image from %s", this->url_.c_str());
69
70 std::vector<http_request::Header> headers;
71
72 // Add caching headers if we have them
73 if (!this->etag_.empty()) {
74 headers.push_back({IF_NONE_MATCH_HEADER_NAME, this->etag_});
75 }
76 if (!this->last_modified_.empty()) {
77 headers.push_back({IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_});
78 }
79
81 // Accept: "<mime>,*/*;q=0.8"; 32 covers the longest MIME type plus the suffix
82 char accept_header[32];
83 snprintf(accept_header, sizeof(accept_header), "%s,*/*;q=0.8", runtime_image::get_mime_type_for_format(format));
84 headers.push_back({"Accept", accept_header});
85
86 // User headers last so they can override any of the above
87 for (auto &header : this->request_headers_) {
88 headers.push_back(http_request::Header{header.first, header.second.value()});
89 }
90
91 this->downloader_ =
92 this->parent_->get(this->url_, headers, {ETAG_HEADER_NAME, LAST_MODIFIED_HEADER_NAME, CONTENT_TYPE_HEADER_NAME});
93 if (this->downloader_ == nullptr) {
94 ESP_LOGE(TAG, "Download failed.");
95 this->end_connection_();
96 this->download_error_callback_.call();
97 return;
98 }
99
100 int http_code = this->downloader_->status_code;
101 if (http_code == HTTP_CODE_NOT_MODIFIED) {
102 // Image hasn't changed on server. Skip download.
103 ESP_LOGI(TAG, "Server returned HTTP 304 (Not Modified). Download skipped.");
104 this->end_connection_();
105 this->download_finished_callback_.call(true);
106 return;
107 }
108 if (http_code != HTTP_CODE_OK) {
109 ESP_LOGE(TAG, "HTTP result: %d", http_code);
110 this->end_connection_();
111 this->download_error_callback_.call();
112 return;
113 }
114
115 ESP_LOGD(TAG, "Starting download");
116 size_t total_size = this->downloader_->content_length;
117 ESP_LOGV(TAG, "Content-Length: %zu", total_size);
118
120 // Try to auto-detect format from Content-Type header
121 auto content_type = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME);
122 ESP_LOGV(TAG, "Content-Type: %s", content_type.c_str());
123 auto mime_format = esphome::runtime_image::get_format_for_mime_type(content_type.c_str());
124 if (mime_format.has_value()) {
125 format = *mime_format;
126 } else {
127 if (content_type.empty()) {
128 ESP_LOGE(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly");
129 } else if (str_contains_ignore_case(content_type.c_str(), "image/")) {
130 ESP_LOGE(TAG, "Image format '%s' not supported.", content_type.c_str());
131 } else {
132 ESP_LOGE(TAG, "Server did not return an image (Content-Type: '%s')", content_type.c_str());
133 }
134 this->end_connection_();
135 this->download_error_callback_.call();
136 return;
137 }
138 }
139 ESP_LOGD(TAG, "Using image format: %s", LOG_STR_ARG(runtime_image::get_format_name(format)));
140
141 // Initialize decoder with the known format
142 if (!this->begin_decode(total_size, format)) {
143 ESP_LOGE(TAG, "Failed to initialize decoder for format %s", LOG_STR_ARG(runtime_image::get_format_name(format)));
144 this->end_connection_();
145 this->download_error_callback_.call();
146 return;
147 }
148
149 // A JPEG of known size is decoded as it arrives, so the download buffer only has to hold its headers.
150 // Otherwise the decoder needs the complete image in the buffer.
151 this->streaming_ = total_size > 0 && total_size <= INT32_MAX && this->decoder_supports_source();
152 this->stream_started_ = false;
153 this->source_.reset();
154 if (!this->streaming_ && format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
155 this->download_buffer_.resize(total_size) == 0) {
156 // resize() logged the failed allocation; the next update() allocates the configured buffer again
157 this->end_connection_();
158 this->download_error_callback_.call();
159 return;
160 }
161
162 ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
163 this->start_time_ = millis();
164 this->last_data_time_ = this->start_time_;
165 this->download_started_callback_.call();
166 this->report_progress_(true);
167 this->enable_loop();
168}
169
171 if (!this->is_decoding()) {
172 // Not decoding at the moment => nothing to do.
173 this->disable_loop();
174 return;
175 }
176
177 if (!this->downloader_) {
178 ESP_LOGE(TAG, "Downloader not instantiated; cannot download");
179 this->end_connection_();
180 this->download_error_callback_.call();
181 return;
182 }
183
184 if (this->streaming_) {
185 this->loop_stream_();
186 return;
187 }
188
189 // Check if download is complete — use decoder's format-specific completion check
190 // to handle both known content-length and chunked transfer encoding
191 if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
192 this->downloader_->get_bytes_read() >= this->downloader_->content_length &&
193 this->download_buffer_.unread() == 0)) {
194 this->finish_download_();
195 return;
196 }
197
198 // Download and decode more data
199 if (this->download_buffer_.free_capacity() > 0) {
201 size_t received = 0;
202 int len;
203 do {
204 len = this->downloader_->read(this->download_buffer_.append(),
205 std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
206 if (len <= 0)
207 break;
208 this->download_buffer_.write(len);
209 received += len;
210 } while (this->download_buffer_.free_capacity() != 0 && millis() - now < PASS_BUDGET_MS);
211
212 if (received > 0) {
213 this->last_data_time_ = now;
214 this->report_progress_(false);
215
216 // Feed data to decoder
217 auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
218
219 if (consumed < 0) {
220 ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed));
221 this->end_connection_();
222 this->download_error_callback_.call();
223 return;
224 }
225
226 if (consumed > 0) {
227 this->download_buffer_.read(consumed);
228 }
229 } else if (len < 0) {
230 ESP_LOGE(TAG, "Error downloading image: %d", len);
231 this->end_connection_();
232 this->download_error_callback_.call();
233 return;
234 } else {
235 // Nothing new arrived. The decoder works for a limited time per call, so it may still have data to use.
236 if (this->download_buffer_.unread() != 0) {
237 auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
238 if (consumed < 0) {
239 ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed));
240 this->end_connection_();
241 this->download_error_callback_.call();
242 return;
243 }
244 if (consumed > 0) {
245 this->download_buffer_.read(consumed);
247 return;
248 }
249 }
250 if (this->downloader_->is_read_complete()) {
251 // The response has ended, so data left in the buffer can never be completed
252 size_t unread = this->download_buffer_.unread();
253 if (unread == 0 && this->downloader_->get_bytes_read() > 0) {
254 // A decoder that counts pixels knows the image was cut off; PNG cannot tell, so it passes
255 if (this->is_decode_finished() || !this->decoder_knows_end()) {
256 this->finish_download_();
257 return;
258 }
259 ESP_LOGE(TAG, "Download ended before the image was complete");
260 } else if (unread == 0) {
261 ESP_LOGE(TAG, "Server sent an empty response");
262 } else {
263 ESP_LOGE(TAG, "Download ended with %zu bytes the decoder could not use", unread);
264 }
265 this->end_connection_();
266 this->download_error_callback_.call();
267 return;
268 } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) {
269 // ESP-IDF keeps returning 0 after the server closes the connection early
270 ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
271 this->end_connection_();
272 this->download_error_callback_.call();
273 return;
274 }
275 }
276 } else {
277 // Buffer is full, need to decode some data first
278 auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
279 if (consumed > 0) {
281 this->download_buffer_.read(consumed);
282 } else if (consumed < 0) {
283 ESP_LOGE(TAG, "Decode error with full buffer: %d", consumed);
284 this->end_connection_();
285 this->download_error_callback_.call();
286 return;
287 } else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) {
288 // The decoder needs more data than the buffer can hold, so it can never make progress. The
289 // no-data timeout gives a decoder that only had to wait the chance to show otherwise.
290 ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
291 this->download_buffer_.size());
292 this->end_connection_();
293 this->download_error_callback_.call();
294 return;
295 }
296 }
297}
298
301 const size_t total = this->downloader_->content_length;
302 if (this->stream_started_)
303 this->source_.compact();
304
305 if (this->downloader_->get_bytes_read() < total) {
306 if (this->download_buffer_.free_capacity() == 0) {
307 // compact() already ran, so a full buffer means the decoder read nothing in the last pass
308 if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
309 ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
310 this->download_buffer_.size());
311 this->fail_download_();
312 return;
313 }
314 } else {
315 int len;
316 do {
317 len = this->downloader_->read(this->download_buffer_.append(),
318 std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
319 if (len <= 0)
320 break;
321 this->last_data_time_ = now;
322 this->download_buffer_.write(len);
323 this->report_progress_(false);
324 } while (this->downloader_->get_bytes_read() < total && this->download_buffer_.free_capacity() != 0 &&
325 millis() - now < PASS_BUDGET_MS);
326 if (len < 0) {
327 ESP_LOGE(TAG, "Error downloading image: %d", len);
328 this->fail_download_();
329 return;
330 } else if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
331 ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
332 this->fail_download_();
333 return;
334 }
335 }
336 }
337 const bool arrived = this->downloader_->get_bytes_read() >= total;
338
339 if (!this->stream_started_) {
340 // The decoder reads the headers when it opens the image, so wait until they are in the buffer
341 if (!arrived && this->download_buffer_.free_capacity() != 0)
342 return;
343 const int err = this->begin_source(&this->source_);
344 if (err != 0) {
345 // Headers cut short by a full buffer fail to parse, or make the decoder seek past the buffer. Such an image
346 // still decodes once it is all in memory, as it did before JPEG images were decoded as they download.
348 ESP_LOGW(TAG,
349 "The image headers do not fit the %zu byte download buffer, so the whole image is downloaded "
350 "before it is decoded; increase buffer_size to decode it as it downloads",
351 this->download_buffer_.size());
352 // grow() logs a failed allocation
353 if (this->download_buffer_.grow(total)) {
354 this->streaming_ = false;
355 } else {
356 this->fail_download_();
357 }
358 return;
359 }
360 ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(err));
361 this->fail_download_();
362 return;
363 }
364 this->source_.set_keep_read(false);
365 this->stream_started_ = true;
366 // Opening the image and allocating its buffer is enough for one pass
367 return;
368 }
369
370 do {
371 const int result = this->decode_step();
373 continue;
374 if (result == runtime_image::DECODE_STEP_DONE) {
375 this->finish_download_();
376 } else if (result == runtime_image::DECODE_STEP_NEED_DATA) {
377 if (!arrived)
378 return;
379 // JPEGDEC only waits for bytes short of the size it was given, so this guards against a hang
380 ESP_LOGE(TAG, "Download ended before the image was complete");
381 this->fail_download_();
382 } else {
383 ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(result));
384 this->fail_download_();
385 }
386 return;
387 } while (millis() - now < PASS_BUDGET_MS);
388}
389
392 const size_t bytes = this->downloader_->get_bytes_read();
393 const size_t total = this->downloader_->content_length;
394 // The last bytes are always reported, so a progress bar reaches 100%
395 if (!force && now - this->last_progress_time_ < PROGRESS_INTERVAL_MS && (total == 0 || bytes < total))
396 return;
397 this->last_progress_time_ = now;
398 const float progress = total > 0 ? std::min(100.0f, bytes * 100.0f / total) : NAN;
399 this->download_progress_callback_.call(progress, bytes);
400}
401
403 this->end_connection_();
404 this->download_error_callback_.call();
405}
406
408 // Must be read before end_decode() ends the decoding session
409 bool decoder_finished = this->is_decode_finished();
410 this->end_decode();
411
412 ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(),
413 millis() - this->start_time_);
414
415 // Only cache an image the decoder confirmed as complete; otherwise a cut-off image would be kept by 304 responses
416 if (decoder_finished) {
417 this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME);
418 this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME);
419 } else {
420 ESP_LOGW(TAG, "Decoder could not confirm the image is complete; not caching it");
421 this->etag_.clear();
422 this->last_modified_.clear();
423 }
424
425 this->download_finished_callback_.call(false);
426 this->end_connection_();
427}
428
430 // Abort any in-progress decode; the decoder object is kept warm for the next decode.
431 // Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
432 if (this->is_decoding()) {
433 RuntimeImage::release();
434 }
435 if (this->downloader_) {
436 this->downloader_->end();
437 this->downloader_ = nullptr;
438 }
439 this->download_buffer_.reset();
440 this->streaming_ = false;
441 this->stream_started_ = false;
442 this->disable_loop();
443}
444
446 // Clear cache headers
447 this->etag_ = "";
448 this->last_modified_ = "";
449
450 // End any active connection
451 this->end_connection_();
452
453 // Call parent's release to free the image buffer
454 RuntimeImage::release();
455}
456
457} // namespace esphome::online_image
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
void enable_loop()
Enable this component's loop.
Definition component.h:248
void disable_loop()
Disable this component's loop.
esphome::http_request::HttpRequestComponent * parent_
Definition helpers.h:2084
std::shared_ptr< HttpContainer > get(const char *url)
bool grow(size_t size)
Make the buffer at least size bytes, keeping the unread data. Returns false if the allocation failed.
void compact()
Drop the bytes the decoder has read, to make room for more of the download.
void reset()
Start again at the beginning of a new download, keeping what is read.
std::string etag_
The value of the ETag HTTP header provided in the last response.
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)
Construct a new OnlineImage object.
LazyCallbackManager< void()> download_started_callback_
void loop_stream_()
loop() for a JPEG that is decoded as it downloads
bool validate_url_(const std::string &url)
LazyCallbackManager< void(float, size_t)> download_progress_callback_
CallbackManager< void(bool)> download_finished_callback_
std::vector< std::pair< std::string, TemplatableValue< std::string > > > request_headers_
void set_url(const std::string &url)
Set the URL to download the image from.
std::shared_ptr< http_request::HttpContainer > downloader_
CallbackManager< void()> download_error_callback_
void report_progress_(bool force)
Reports download progress, at most every PROGRESS_INTERVAL_MS unless force is set.
std::string last_modified_
The value of the Last-Modified HTTP header provided in the last response.
void release()
Release the buffer storing the image.
int decode_step()
Decode a little more from the source; see ImageDecoder::decode_step().
bool begin_decode(size_t expected_size=0, ImageFormat format=AUTO)
Begin decoding an image.
bool is_decoding() const
Check if decoding is currently in progress.
int begin_source(DecodeSource *source)
Decode from a source the decoder reads itself, in place of feed_data().
bool end_decode()
Complete the decoding process.
int feed_data(uint8_t *data, size_t len)
Feed data to the decoder.
bool is_decode_finished() const
Check if the decoder has finished processing all data.
bool decoder_knows_end() const
True while decoding and the decoder can tell a complete image from a cut-off one.
ImageFormat get_format() const
Get the image format.
bool decoder_supports_source() const
Whether the active decoder can read a DecodeSource itself (see begin_source()).
uint8_t type
const char * format
const char * get_mime_type_for_format(ImageFormat format)
Canonical MIME type for a format; "image/*" for AUTO/unknown.
std::optional< ImageFormat > get_format_for_mime_type(const char *mime_type)
Case-insensitive substring match of known media types; nullopt if none found.
ImageFormat
Image format types that can be decoded dynamically.
@ AUTO
Format is supplied per decode, e.g.
const LogString * get_format_name(ImageFormat format)
Human-readable name for a format; "UNKNOWN" if not recognized.
constexpr const char * decode_error_to_string(int error)
bool str_contains_ignore_case(const char *haystack, const char *needle)
Case-insensitive check if needle string is contained in haystack (no heap allocation).
Definition helpers.h:1166
const void size_t len
Definition hal.h:68
uint32_t IRAM_ATTR HOT millis()
Definition hal.cpp:25
Application App
Global storage of Application pointer - only one Application can exist.
ESPHOME_LOG_TAG(TAG, "online_image")
static void uint32_t