[online_image] Report an error when a download can never complete (#20594)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: guillempages <guillempages@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Remco van Essen
2026-10-16 23:58:12 +00:00
committed by GitHub
co-authored by J. Nick Koston guillempages J. Nick Koston
parent b3f5789dc6
commit b3ef5cf2f3
11 changed files with 566 additions and 30 deletions
@@ -1,5 +1,6 @@
#include "online_image.h"
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
@@ -51,6 +52,13 @@ void OnlineImage::update() {
return;
}
// A failed allocation at boot, or while growing the buffer for a JPEG, leaves no download buffer
if (this->download_buffer_.size() == 0 && this->download_buffer_.resize(this->download_buffer_initial_size_) == 0) {
// resize() logged the failed allocation
this->download_error_callback_.call();
return;
}
ESP_LOGD(TAG, "Updating image from %s", this->url_.c_str());
std::vector<http_request::Header> headers;
@@ -133,12 +141,17 @@ void OnlineImage::update() {
}
// JPEG requires the complete image in the download buffer before decoding
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size()) {
this->download_buffer_.resize(total_size);
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
this->download_buffer_.resize(total_size) == 0) {
// resize() logged the failed allocation; the next update() allocates the configured buffer again
this->end_connection_();
this->download_error_callback_.call();
return;
}
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
this->start_time_ = millis();
this->last_data_time_ = this->start_time_;
this->enable_loop();
}
@@ -161,18 +174,7 @@ void OnlineImage::loop() {
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 " ms", this->downloader_->get_bytes_read(),
millis() - 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_();
this->finish_download_();
return;
}
@@ -184,6 +186,7 @@ void OnlineImage::loop() {
auto len = this->downloader_->read(this->download_buffer_.append(), available);
if (len > 0) {
this->last_data_time_ = App.get_loop_component_start_time();
this->download_buffer_.write(len);
// Feed data to decoder
@@ -204,25 +207,76 @@ void OnlineImage::loop() {
this->end_connection_();
this->download_error_callback_.call();
return;
} else if (this->downloader_->is_read_complete()) {
// The response has ended, so data left in the buffer can never be completed
size_t unread = this->download_buffer_.unread();
if (unread == 0 && this->downloader_->get_bytes_read() > 0) {
// A decoder that counts pixels knows the image was cut off; PNG cannot tell, so it passes
if (this->is_decode_finished() || !this->decoder_knows_end()) {
this->finish_download_();
return;
}
ESP_LOGE(TAG, "Download ended before the image was complete");
} else if (unread == 0) {
ESP_LOGE(TAG, "Server sent an empty response");
} else {
ESP_LOGE(TAG, "Download ended with %zu bytes the decoder could not use", unread);
}
this->end_connection_();
this->download_error_callback_.call();
return;
} else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) {
// ESP-IDF keeps returning 0 after the server closes the connection early
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
this->end_connection_();
this->download_error_callback_.call();
return;
}
} 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->last_data_time_ = App.get_loop_component_start_time();
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");
} else if (App.get_loop_component_start_time() - this->last_data_time_ >= this->parent_->get_timeout()) {
// The decoder needs more data than the buffer can hold, so it can never make progress. The
// no-data timeout gives a decoder that only had to wait the chance to show otherwise.
ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
this->download_buffer_.size());
this->end_connection_();
this->download_error_callback_.call();
return;
}
}
}
void OnlineImage::finish_download_() {
// Must be read before end_decode() ends the decoding session
bool decoder_finished = this->is_decode_finished();
this->end_decode();
ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(),
millis() - this->start_time_);
// Only cache an image the decoder confirmed as complete; otherwise a cut-off image would be kept by 304 responses
if (decoder_finished) {
this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME);
this->last_modified_ = this->downloader_->get_response_header(LAST_MODIFIED_HEADER_NAME);
} else {
ESP_LOGW(TAG, "Decoder could not confirm the image is complete; not caching it");
this->etag_.clear();
this->last_modified_.clear();
}
this->download_finished_callback_.call(false);
this->end_connection_();
}
void OnlineImage::end_connection_() {
// Abort any in-progress decode; the decoder object is kept warm for the next decode.
// Use RuntimeImage::release() directly to avoid recursion with OnlineImage::release().
@@ -80,6 +80,7 @@ class OnlineImage final : public PollingComponent,
protected:
bool validate_url_(const std::string &url);
void end_connection_();
void finish_download_();
CallbackManager<void(bool)> download_finished_callback_{};
CallbackManager<void()> download_error_callback_{};
@@ -107,6 +108,7 @@ class OnlineImage final : public PollingComponent,
std::string last_modified_;
uint32_t start_time_{0};
uint32_t last_data_time_{0};
};
} // namespace esphome::online_image
@@ -26,13 +26,10 @@ class BmpDecoder : public ImageDecoder {
void reset() override;
int HOT decode(uint8_t *buffer, size_t size) override;
/// The header gives the pixel count, so the end is known once it has been parsed
bool knows_end() const override { return this->bits_per_pixel_ != 0; }
bool is_finished() const override {
if (this->bits_per_pixel_ == 0) {
// header not yet received, so dimensions not yet determined
return false;
}
// BMP is finished when we've decoded all pixel data
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
return this->knows_end() && this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
}
protected:
@@ -126,6 +126,9 @@ class ImageDecoder {
return false;
}
/// Whether is_finished() can tell a complete image from a cut-off one
virtual bool knows_end() const { return this->expected_size_ > 0; }
protected:
/// The image's pixel buffer, for decoders that write their output straight into it
uint8_t *image_buffer_() const;
@@ -25,13 +25,10 @@ class QoiDecoder : public ImageDecoder {
void reset() override;
int HOT decode(uint8_t *buffer, size_t size) override;
/// The header gives the pixel count, so the end is known once it has been parsed
bool knows_end() const override { return this->bits_per_pixel_ != 0; }
bool is_finished() const override {
if (this->bits_per_pixel_ == 0) {
// header not yet received, so dimensions not yet determined
return false;
}
// QOI is finished when we've decoded all pixel data
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
return this->knows_end() && this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
}
protected:
@@ -262,6 +262,8 @@ bool RuntimeImage::end_decode() {
bool RuntimeImage::is_decode_finished() const { return this->is_decoding() && this->decoder_->is_finished(); }
bool RuntimeImage::decoder_knows_end() const { return this->is_decoding() && this->decoder_->knows_end(); }
void RuntimeImage::release() {
this->release_buffer_();
// End any active decode session; decoders free the format-specific working buffers
@@ -96,6 +96,9 @@ class RuntimeImage : public image::Image {
*/
bool is_decode_finished() const;
/// True while decoding and the decoder can tell a complete image from a cut-off one
bool decoder_knows_end() const;
/**
* @brief Check if an image is currently loaded.
*/
+14
View File
@@ -0,0 +1,14 @@
from esphome.components.runtime_image import get_format
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The real to_code needs an image config. The unit tests decode BMP and PNG, and use
# JPEG for the download buffer resize, so compile in those three decoders.
async def to_code_testing(config: ConfigType) -> None:
get_format("BMP").actions()
get_format("JPEG").actions()
get_format("PNG").actions()
manifest.to_code = to_code_testing
@@ -0,0 +1,289 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstring>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "esphome/components/http_request/http_request.h"
#include "esphome/components/online_image/online_image.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::online_image::testing {
// Serves a fixed body, handing out at most `available` bytes of it.
class FakeContainer : public http_request::HttpContainer {
public:
FakeContainer(std::vector<uint8_t> body, size_t available, bool chunked)
: body_(std::move(body)), available_(std::min(available, this->body_.size())) {
this->status_code = 200;
this->content_length = chunked ? 0 : this->body_.size();
this->set_chunked(chunked);
}
int read(uint8_t *buf, size_t max_len) override {
size_t len = std::min(max_len, this->available_ - this->bytes_read_);
std::memcpy(buf, this->body_.data() + this->bytes_read_, len);
this->bytes_read_ += len;
return static_cast<int>(len);
}
void end() override {}
// Like ESP-IDF: a chunked response is complete once its last chunk has been read
bool is_read_complete() const override {
if (this->is_chunked_)
return this->bytes_read_ >= this->body_.size();
return HttpContainer::is_read_complete();
}
void set_content_length(size_t length) { this->content_length = length; }
void add_response_header(const std::string &name, const std::string &value) {
this->response_headers_.push_back({name, value});
}
protected:
std::vector<uint8_t> body_;
size_t available_;
};
class FakeHttpRequest : public http_request::HttpRequestComponent {
public:
std::shared_ptr<http_request::HttpContainer> next;
std::vector<http_request::Header> sent_headers;
bool sent_header(const std::string &name) const {
return std::any_of(this->sent_headers.begin(), this->sent_headers.end(),
[&name](const http_request::Header &header) { return header.name == name; });
}
protected:
std::shared_ptr<http_request::HttpContainer> perform(const char *url, const char *method, const std::string &body,
const std::vector<http_request::Header> &request_headers,
const std::vector<std::string> &collect_headers,
uint32_t timeout) override {
this->sent_headers = request_headers;
return std::move(this->next);
}
};
// A 24-bit BMP, 2x2 pixels: 54 header bytes, then two rows of 6 pixel bytes and 2 padding bytes.
static constexpr size_t BMP_HEADER_SIZE = 54;
static constexpr size_t BMP_ROW_SIZE = 8;
static std::vector<uint8_t> make_bmp() {
std::vector<uint8_t> bmp(BMP_HEADER_SIZE + 2 * BMP_ROW_SIZE, 0);
auto put32 = [&bmp](size_t offset, uint32_t value) {
for (size_t i = 0; i < 4; i++)
bmp[offset + i] = (value >> (8 * i)) & 0xFF;
};
bmp[0] = 'B';
bmp[1] = 'M';
put32(2, bmp.size());
put32(10, BMP_HEADER_SIZE);
put32(14, 40); // DIB header size
put32(18, 2); // width
put32(22, 2); // height
bmp[26] = 1; // color planes
bmp[28] = 24; // bits per pixel
return bmp;
}
// An 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078, past a 256 byte buffer.
static std::vector<uint8_t> make_palette_bmp() {
constexpr size_t data_offset = 14 + 40 + 256 * 4;
std::vector<uint8_t> bmp(data_offset + 8 * 8, 0);
auto put32 = [&bmp](size_t offset, uint32_t value) {
for (size_t i = 0; i < 4; i++)
bmp[offset + i] = (value >> (8 * i)) & 0xFF;
};
bmp[0] = 'B';
bmp[1] = 'M';
put32(2, bmp.size());
put32(10, data_offset);
put32(14, 40); // DIB header size
put32(18, 8); // width
put32(22, 8); // height
bmp[26] = 1; // color planes
bmp[28] = 8; // bits per pixel
put32(34, 64); // image size
put32(46, 256); // colors in the table
return bmp;
}
// A 1x1 RGB PNG: 8 signature bytes, a 25 byte IHDR chunk, a 24 byte IDAT chunk and a 12 byte IEND chunk.
static constexpr size_t PNG_HEADER_SIZE = 8 + 25;
static constexpr std::array<uint8_t, 69> PNG_1X1{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
0x0C, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xC9,
0xFE, 0x92, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82};
static void set_loop_time(uint32_t now) { LoopBlockingGuard clock(nullptr, LOG_STR("test"), now); }
class OnlineImageDownloadEnd : public ::testing::Test {
protected:
std::unique_ptr<OnlineImage> make_image_(runtime_image::ImageFormat format) {
auto img = std::make_unique<OnlineImage>("http://example.com/image", 0, 0, format, image::IMAGE_TYPE_RGB,
image::TRANSPARENCY_OPAQUE, nullptr, 256);
img->set_parent(&this->http_);
return img;
}
void start_(OnlineImage &image, const std::shared_ptr<FakeContainer> &container) {
image.add_on_finished_callback([this](bool) { this->finished_++; });
image.add_on_error_callback([this]() { this->errors_++; });
this->http_.next = container;
image.update();
}
FakeHttpRequest http_;
int finished_{0};
int errors_{0};
};
// A chunked stream can end without the decoder reporting it is finished. PNG cannot tell a
// complete image from a cut-off one, so when the decoder used every byte the download succeeds,
// but the image is not cached: a 304 response must not keep it.
TEST_F(OnlineImageDownloadEnd, ChunkedPngCutOffFinishesWithoutCaching) {
std::vector<uint8_t> png(PNG_1X1.begin(), PNG_1X1.begin() + PNG_HEADER_SIZE); // ends after IHDR
auto container = std::make_shared<FakeContainer>(png, png.size(), true);
container->add_response_header("etag", "\"abc\"");
container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT");
auto image = this->make_image_(runtime_image::PNG);
this->start_(*image, container);
for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++)
image->loop();
EXPECT_EQ(this->finished_, 1);
EXPECT_EQ(this->errors_, 0);
this->http_.next = std::make_shared<FakeContainer>(png, 0, false);
image->update();
EXPECT_FALSE(this->http_.sent_header("if-none-match"));
EXPECT_FALSE(this->http_.sent_header("if-modified-since"));
}
// BMP knows its pixel count from the header, so a chunked BMP cut off at a row boundary is an error.
TEST_F(OnlineImageDownloadEnd, ChunkedBmpCutOffAtRowReportsError) {
std::vector<uint8_t> bmp = make_bmp();
bmp.resize(BMP_HEADER_SIZE + BMP_ROW_SIZE); // only the first row
auto container = std::make_shared<FakeContainer>(bmp, bmp.size(), true);
auto image = this->make_image_(runtime_image::BMP);
this->start_(*image, container);
for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++)
image->loop();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->finished_, 0);
}
// An image the decoder confirmed as complete is cached.
TEST_F(OnlineImageDownloadEnd, CompleteImageIsCached) {
std::vector<uint8_t> bmp = make_bmp();
auto container = std::make_shared<FakeContainer>(bmp, bmp.size(), false);
container->add_response_header("etag", "\"abc\"");
container->add_response_header("last-modified", "Mon, 12 Oct 2026 10:00:00 GMT");
auto image = this->make_image_(runtime_image::BMP);
this->start_(*image, container);
for (int i = 0; i < 4 && this->finished_ + this->errors_ == 0; i++)
image->loop();
ASSERT_EQ(this->finished_, 1);
this->http_.next = std::make_shared<FakeContainer>(bmp, 0, false);
image->update();
EXPECT_TRUE(this->http_.sent_header("if-none-match"));
EXPECT_TRUE(this->http_.sent_header("if-modified-since"));
}
// ESP-IDF keeps returning 0 after the server closes the connection early.
TEST_F(OnlineImageDownloadEnd, NoDataForTimeoutReportsError) {
this->http_.set_timeout(100);
auto container = std::make_shared<FakeContainer>(make_bmp(), 20, false);
auto image = this->make_image_(runtime_image::BMP);
this->start_(*image, container);
const uint32_t now = millis();
set_loop_time(now);
image->loop();
ASSERT_EQ(container->get_bytes_read(), 20u);
set_loop_time(now + 99);
image->loop();
EXPECT_EQ(this->errors_, 0);
set_loop_time(now + 100);
image->loop();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->finished_, 0);
}
// The time spent connecting and reading the headers must not count as time without data.
TEST_F(OnlineImageDownloadEnd, NoDataTimeoutStartsAfterRequest) {
this->http_.set_timeout(100);
auto container = std::make_shared<FakeContainer>(make_bmp(), 0, false);
auto image = this->make_image_(runtime_image::BMP);
// update() runs in a dispatch that started before the blocking request, here one second earlier
set_loop_time(millis() - 1000);
this->start_(*image, container);
const uint32_t now = millis();
set_loop_time(now);
image->loop();
EXPECT_EQ(this->errors_, 0);
set_loop_time(now + 100);
image->loop();
EXPECT_EQ(this->errors_, 1);
}
// A full buffer the decoder cannot use waits for the no-data timeout before it fails, so a decoder
// that only had to wait is not cut off early.
TEST_F(OnlineImageDownloadEnd, FullBufferFailsAfterTimeout) {
this->http_.set_timeout(100);
auto bmp = make_palette_bmp();
auto container = std::make_shared<FakeContainer>(bmp, bmp.size(), false);
auto image = this->make_image_(runtime_image::BMP);
this->start_(*image, container);
const uint32_t now = millis();
set_loop_time(now);
for (int i = 0; i < 8; i++)
image->loop();
EXPECT_EQ(this->errors_, 0);
set_loop_time(now + 99);
image->loop();
EXPECT_EQ(this->errors_, 0);
set_loop_time(now + 100);
image->loop();
EXPECT_EQ(this->errors_, 1);
EXPECT_EQ(this->finished_, 0);
}
// A JPEG too big for the heap must fail, and the next download must get the configured buffer back.
TEST_F(OnlineImageDownloadEnd, JpegBufferAllocationFailureReportsError) {
auto image = this->make_image_(runtime_image::JPEG);
auto huge = std::make_shared<FakeContainer>(std::vector<uint8_t>(16), 16, false);
huge->set_content_length(SIZE_MAX / 2);
this->start_(*image, huge);
EXPECT_EQ(this->errors_, 1);
// A chunked response does not resize the buffer. The body is not a real JPEG, so the decoder
// rejects it, but only once it has been read into the buffer.
auto next = std::make_shared<FakeContainer>(std::vector<uint8_t>(100, 0xAA), 100, true);
this->http_.next = next;
image->update();
image->loop();
EXPECT_EQ(next->get_bytes_read(), 100u);
EXPECT_EQ(this->errors_, 2);
EXPECT_EQ(this->finished_, 0);
}
} // namespace esphome::online_image::testing
@@ -0,0 +1,54 @@
esphome:
name: online-image-stalled
host:
http_request:
timeout: 500ms
display:
image:
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/truncated.bmp
id: truncated_img
format: BMP
type: RGB
on_download_finished:
logger.log: "download finished: truncated"
on_error:
logger.log: "download error: truncated"
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/large_header.bmp
id: large_header_img
format: BMP
type: RGB
buffer_size: 256
on_download_finished:
logger.log: "download finished: large_header"
on_error:
logger.log: "download error: large_header"
- platform: online_image
url: http://127.0.0.1:HTTP_PORT/empty.bmp
id: empty_img
format: BMP
type: RGB
on_download_finished:
logger.log: "download finished: empty"
on_error:
logger.log: "download error: empty"
api:
actions:
- action: fetch_truncated
then:
- component.update: truncated_img
- action: fetch_large_header
then:
- component.update: large_header_img
- action: fetch_empty
then:
- component.update: empty_img
logger:
level: DEBUG
@@ -0,0 +1,121 @@
"""Downloads that can never complete must report an error instead of waiting forever."""
from __future__ import annotations
import asyncio
import struct
import pytest
from .online_image_utils import BMP_IMAGE
from .types import APIClientConnectedFactory, RunCompiledFunction
# 24-bit BMP cut off one byte into the sixth pixel: the decoder keeps that byte,
# waiting for the rest of the pixel, but the response has already ended.
TRUNCATED_BMP = BMP_IMAGE[: 54 + 5 * 3 + 1]
def _make_palette_bmp() -> bytes:
"""Build an 8x8, 8-bit BMP whose 256-color table puts the pixels at offset 1078."""
width = height = 8
data_offset = 14 + 40 + 256 * 4
file_size = data_offset + width * height
file_header = b"BM" + struct.pack("<IHHI", file_size, 0, 0, data_offset)
dib_header = struct.pack(
"<IiiHHIIiiII", 40, width, height, 1, 8, 0, width * height, 0, 0, 256, 0
)
return file_header + dib_header + bytes(256 * 4) + bytes(width * height)
# The pixel data starts beyond a 256 byte download buffer, so the decoder can
# never get past the header with the data that fits in the buffer.
LARGE_HEADER_BMP = _make_palette_bmp()
IMAGES = {
"/truncated.bmp": TRUNCATED_BMP,
"/large_header.bmp": LARGE_HEADER_BMP,
"/empty.bmp": b"",
}
async def handle_http(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
try:
async with asyncio.timeout(1.0):
request_line = await reader.readuntil(b"\r\n")
await reader.readuntil(b"\r\n\r\n")
path = request_line.split(b" ")[1].decode()
body = IMAGES[path]
writer.write(
b"\r\n".join(
[
b"HTTP/1.1 200 OK",
b"Content-Length: %d" % len(body),
b"Content-Type: image/bmp",
b"Connection: close",
b"",
b"",
]
)
+ body
)
await writer.drain()
finally:
writer.close()
@pytest.mark.asyncio
async def test_online_image_stalled_download(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Esphome should report an error when a download can never complete."""
loop = asyncio.get_running_loop()
expected_logs = {
"truncated_reason": "Download ended with 1 bytes the decoder could not use",
"truncated_error": "download error: truncated",
"large_header_reason": "Image does not fit in the 256 byte download buffer",
"large_header_error": "download error: large_header",
"empty_reason": "Server sent an empty response",
"empty_error": "download error: empty",
}
log_futures = {key: loop.create_future() for key in expected_logs}
finished_lines: list[str] = []
def check_output(line: str) -> None:
for key, text in expected_logs.items():
if text in line and not log_futures[key].done():
log_futures[key].set_result(True)
if "download finished" in line:
finished_lines.append(line)
server = await asyncio.start_server(handle_http, "127.0.0.1", 0)
http_server_port = server.sockets[0].getsockname()[1]
config = yaml_config.replace("HTTP_PORT", str(http_server_port))
async with (
server,
run_compiled(config, line_callback=check_output),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
actions = {s.name: s for s in services}
await client.execute_service(actions["fetch_truncated"], {})
async with asyncio.timeout(2.0):
await log_futures["truncated_reason"]
await log_futures["truncated_error"]
await client.execute_service(actions["fetch_large_header"], {})
async with asyncio.timeout(2.0):
await log_futures["large_header_reason"]
await log_futures["large_header_error"]
await client.execute_service(actions["fetch_empty"], {})
async with asyncio.timeout(2.0):
await log_futures["empty_reason"]
await log_futures["empty_error"]
assert not finished_lines, f"Unexpected successful download: {finished_lines}"