From ca9636514362b5f270dc98c896d04c9da98edb00 Mon Sep 17 00:00:00 2001 From: guillempages Date: Mon, 24 Aug 2026 22:07:01 +0200 Subject: [PATCH] [runtime_image] Add MIME types to image formats (#16361) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- .../components/online_image/online_image.cpp | 62 +++++-------------- .../components/runtime_image/image_format.cpp | 44 +++++++++++++ .../components/runtime_image/image_format.h | 7 +++ .../runtime_image/runtime_image.cpp | 1 - .../runtime_image/test_mime_types.cpp | 61 ++++++++++++++++++ 5 files changed, 127 insertions(+), 48 deletions(-) create mode 100644 esphome/components/runtime_image/image_format.cpp create mode 100644 tests/components/runtime_image/test_mime_types.cpp diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index a2662ff0e3..3f2382accd 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -3,6 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include static const char *const TAG = "online_image"; static const char *const CONTENT_TYPE_HEADER_NAME = "content-type"; @@ -62,30 +63,11 @@ void OnlineImage::update() { headers.push_back({IF_MODIFIED_SINCE_HEADER_NAME, this->last_modified_}); } - // Add Accept header based on image format - const char *accept_mime_type; runtime_image::ImageFormat format = this->get_format(); - switch (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}); + // Accept: ",*/*;q=0.8"; 32 covers the longest MIME type plus the suffix + char accept_header[32]; + snprintf(accept_header, sizeof(accept_header), "%s,*/*;q=0.8", runtime_image::get_mime_type_for_format(format)); + headers.push_back({"Accept", accept_header}); // User headers last so they can override any of the above for (auto &header : this->request_headers_) { @@ -122,32 +104,18 @@ void OnlineImage::update() { if (format == runtime_image::AUTO) { // Try to auto-detect format from Content-Type header - auto content_type_header = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME); - const char *content_type = content_type_header.c_str(); - ESP_LOGV(TAG, "Content-Type: %s", content_type); - // Includes aliases seen from real servers (older IIS, CDNs, S3) - if (str_contains_ignore_case(content_type, "image/bmp") || - str_contains_ignore_case(content_type, "image/x-ms-bmp") || - str_contains_ignore_case(content_type, "image/x-bmp")) { - format = runtime_image::BMP; - } else if (str_contains_ignore_case(content_type, "image/jpeg") || - str_contains_ignore_case(content_type, "image/jpg")) { - format = runtime_image::JPEG; - } else if (str_contains_ignore_case(content_type, "image/png") || - str_contains_ignore_case(content_type, "image/x-png")) { - format = runtime_image::PNG; - } else if (str_contains_ignore_case(content_type, "image/")) { - ESP_LOGW(TAG, "Unsupported image type: '%s'", content_type); - this->end_connection_(); - this->download_error_callback_.call(); - return; + auto content_type = this->downloader_->get_response_header(CONTENT_TYPE_HEADER_NAME); + ESP_LOGV(TAG, "Content-Type: %s", content_type.c_str()); + auto mime_format = esphome::runtime_image::get_format_for_mime_type(content_type.c_str()); + if (mime_format.has_value()) { + format = *mime_format; } else { - // TODO: implement auto-detection in runtime_image by sniffing the first few bytes of the image data - if (content_type_header.empty()) { - ESP_LOGW(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly"); + if (content_type.empty()) { + ESP_LOGE(TAG, "Server sent no Content-Type header; cannot determine image format. Set `format:` explicitly"); + } else if (str_contains_ignore_case(content_type.c_str(), "image/")) { + ESP_LOGE(TAG, "Image format '%s' not supported.", content_type.c_str()); } else { - ESP_LOGE(TAG, "Could not determine image format from Content-Type: '%s'. Set `format:` explicitly", - content_type); + ESP_LOGE(TAG, "Server did not return an image (Content-Type: '%s')", content_type.c_str()); } this->end_connection_(); this->download_error_callback_.call(); diff --git a/esphome/components/runtime_image/image_format.cpp b/esphome/components/runtime_image/image_format.cpp new file mode 100644 index 0000000000..3ba8871862 --- /dev/null +++ b/esphome/components/runtime_image/image_format.cpp @@ -0,0 +1,44 @@ +#include "esphome/core/helpers.h" +#include "image_format.h" +#include "image_decoder.h" + +namespace esphome::runtime_image { + +struct MimeLookup { + const char *mime_type; + ImageFormat format; +}; + +// The first entry per format is its canonical MIME type; the rest are aliases +// seen from real servers (older IIS, CDNs, S3) +static constexpr MimeLookup MIME_LOOKUP_TABLE[] = { +#ifdef USE_RUNTIME_IMAGE_BMP + {"image/bmp", ImageFormat::BMP}, {"image/x-ms-bmp", ImageFormat::BMP}, {"image/x-bmp", ImageFormat::BMP}, +#endif +#ifdef USE_RUNTIME_IMAGE_JPEG + {"image/jpeg", ImageFormat::JPEG}, {"image/jpg", ImageFormat::JPEG}, +#endif +#ifdef USE_RUNTIME_IMAGE_PNG + {"image/png", ImageFormat::PNG}, {"image/x-png", ImageFormat::PNG}, +#endif +}; + +const char *get_mime_type_for_format(ImageFormat format) { + for (const auto &entry : MIME_LOOKUP_TABLE) { + if (entry.format == format) { + return entry.mime_type; + } + } + return "image/*"; // AUTO or compiled-out format +} + +std::optional get_format_for_mime_type(const char *mime_type) { + for (const auto &entry : MIME_LOOKUP_TABLE) { + if (str_contains_ignore_case(mime_type, entry.mime_type)) { + return entry.format; + } + } + return std::nullopt; +} + +} // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/image_format.h b/esphome/components/runtime_image/image_format.h index ca6e0782b9..aff0c026b9 100644 --- a/esphome/components/runtime_image/image_format.h +++ b/esphome/components/runtime_image/image_format.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace esphome::runtime_image { /** @@ -17,4 +19,9 @@ enum ImageFormat { BMP, }; +/// Canonical MIME type for a format; "image/*" for AUTO/unknown +const char *get_mime_type_for_format(ImageFormat format); +/// Case-insensitive substring match of known media types; nullopt if none found +std::optional get_format_for_mime_type(const char *mime_type); + } // namespace esphome::runtime_image diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 254624caf4..f7417c2c8e 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -1,7 +1,6 @@ #include "runtime_image.h" #include "image_decoder.h" #include "esphome/core/log.h" -#include "esphome/core/helpers.h" #include #include #include diff --git a/tests/components/runtime_image/test_mime_types.cpp b/tests/components/runtime_image/test_mime_types.cpp new file mode 100644 index 0000000000..de8bbc76be --- /dev/null +++ b/tests/components/runtime_image/test_mime_types.cpp @@ -0,0 +1,61 @@ +#include + +#include + +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::testing { + +TEST(RuntimeImageMime, FormatForKnownMimeTypes) { + EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP); + EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP); + EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP); + EXPECT_EQ(get_format_for_mime_type("image/png"), PNG); + EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG); +#ifdef USE_RUNTIME_IMAGE_JPEG + EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG); + EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG); +#endif // USE_RUNTIME_IMAGE_JPEG +} + +TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) { + EXPECT_EQ(get_format_for_mime_type("Image/PNG"), PNG); + EXPECT_EQ(get_format_for_mime_type("IMAGE/BMP"), BMP); +} + +TEST(RuntimeImageMime, FormatMatchesContentTypeWithParameters) { + // Content-Type headers may carry parameters after the media type + EXPECT_EQ(get_format_for_mime_type("image/png; charset=binary"), PNG); + EXPECT_EQ(get_format_for_mime_type("image/bmp;name=\"a.bmp\""), BMP); +} + +TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) { + EXPECT_EQ(get_format_for_mime_type("text/html"), std::nullopt); + EXPECT_EQ(get_format_for_mime_type("application/octet-stream"), std::nullopt); + EXPECT_EQ(get_format_for_mime_type("image/*"), std::nullopt); + EXPECT_EQ(get_format_for_mime_type(""), std::nullopt); + EXPECT_EQ(get_format_for_mime_type(nullptr), std::nullopt); +} + +TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) { + EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp"); + EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png"); +#ifdef USE_RUNTIME_IMAGE_JPEG + EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg"); +#endif // USE_RUNTIME_IMAGE_JPEG + // AUTO has no single MIME type and falls back to the wildcard + EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*"); + + // Every decodable format must resolve back to itself through its MIME type + for (ImageFormat format : { + BMP, + PNG, +#ifdef USE_RUNTIME_IMAGE_JPEG + JPEG, +#endif // USE_RUNTIME_IMAGE_JPEG + }) { + EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format; + } +} + +} // namespace esphome::runtime_image::testing