[runtime_image] Add MIME types to image formats (#16361)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
guillempages
2026-08-24 15:07:01 -05:00
committed by GitHub
co-authored by J. Nick Koston pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> J. Nick Koston
parent 3b6c6e3dad
commit ca96365143
5 changed files with 127 additions and 48 deletions
@@ -3,6 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstdio>
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: "<mime>,*/*;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();
@@ -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<ImageFormat> 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
@@ -1,5 +1,7 @@
#pragma once
#include <optional>
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<ImageFormat> get_format_for_mime_type(const char *mime_type);
} // namespace esphome::runtime_image
@@ -1,7 +1,6 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
#include <algorithm>
#include <cstdint>
#include <cstring>
@@ -0,0 +1,61 @@
#include <gtest/gtest.h>
#include <optional>
#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