diff --git a/CODEOWNERS b/CODEOWNERS
index 609f3ddd03..92e69e66cb 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -302,7 +302,7 @@ esphome/components/noblex/* @AGalfra
esphome/components/npi19/* @bakerkj
esphome/components/number/* @esphome/core
esphome/components/one_wire/* @ssieb
-esphome/components/online_image/* @guillempages
+esphome/components/online_image/* @clydebarrow @guillempages
esphome/components/opentherm/* @olegtarasov
esphome/components/ota/* @esphome/core
esphome/components/output/* @esphome/core
diff --git a/README.md b/README.md
index da1b2b3650..8e3d8f71aa 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,11 @@
# ESPHome [](https://discord.gg/KhAMKrd) [](https://GitHub.com/esphome/esphome/releases/)
-[](https://esphome.io/)
+
+
+
+
+
+
**Documentation:** https://esphome.io/
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 0bb558d35e..429f5c4a1f 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -29,7 +29,7 @@ RUN \
# Use pinned versions so that we get updates with build caching
&& apt-get install -y --no-install-recommends \
python3-pip=23.0.1+dfsg-1 \
- python3-setuptools=66.1.1-1 \
+ python3-setuptools=66.1.1-1+deb12u1 \
python3-venv=3.11.2-1+b1 \
python3-wheel=0.38.4-2 \
iputils-ping=3:20221126-1+deb12u1 \
diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py
index 21a82649f0..f73b8ef08f 100644
--- a/esphome/components/animation/__init__.py
+++ b/esphome/components/animation/__init__.py
@@ -1,28 +1,10 @@
import logging
-from esphome import automation, core
+from esphome import automation
import esphome.codegen as cg
import esphome.components.image as espImage
-from esphome.components.image import (
- CONF_USE_TRANSPARENCY,
- LOCAL_SCHEMA,
- SOURCE_LOCAL,
- SOURCE_WEB,
- WEB_SCHEMA,
-)
import esphome.config_validation as cv
-from esphome.const import (
- CONF_FILE,
- CONF_ID,
- CONF_PATH,
- CONF_RAW_DATA_ID,
- CONF_REPEAT,
- CONF_RESIZE,
- CONF_SOURCE,
- CONF_TYPE,
- CONF_URL,
-)
-from esphome.core import CORE, HexInt
+from esphome.const import CONF_ID, CONF_REPEAT
_LOGGER = logging.getLogger(__name__)
@@ -30,6 +12,7 @@ AUTO_LOAD = ["image"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
MULTI_CONF = True
+MULTI_CONF_NO_DEFAULT = True
CONF_LOOP = "loop"
CONF_START_FRAME = "start_frame"
@@ -51,86 +34,19 @@ SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
)
-TYPED_FILE_SCHEMA = cv.typed_schema(
+CONFIG_SCHEMA = espImage.IMAGE_SCHEMA.extend(
{
- SOURCE_LOCAL: LOCAL_SCHEMA,
- SOURCE_WEB: WEB_SCHEMA,
- },
- key=CONF_SOURCE,
-)
-
-
-def _file_schema(value):
- if isinstance(value, str):
- return validate_file_shorthand(value)
- return TYPED_FILE_SCHEMA(value)
-
-
-FILE_SCHEMA = cv.Schema(_file_schema)
-
-
-def validate_file_shorthand(value):
- value = cv.string_strict(value)
- if value.startswith("http://") or value.startswith("https://"):
- return FILE_SCHEMA(
+ cv.Required(CONF_ID): cv.declare_id(Animation_),
+ cv.Optional(CONF_LOOP): cv.All(
{
- CONF_SOURCE: SOURCE_WEB,
- CONF_URL: value,
+ cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
+ cv.Optional(CONF_END_FRAME): cv.positive_int,
+ cv.Optional(CONF_REPEAT): cv.positive_int,
}
- )
- return FILE_SCHEMA(
- {
- CONF_SOURCE: SOURCE_LOCAL,
- CONF_PATH: value,
- }
- )
-
-
-def validate_cross_dependencies(config):
- """
- Validate fields whose possible values depend on other fields.
- For example, validate that explicitly transparent image types
- have "use_transparency" set to True.
- Also set the default value for those kind of dependent fields.
- """
- image_type = config[CONF_TYPE]
- is_transparent_type = image_type in ["TRANSPARENT_BINARY", "RGBA"]
- # If the use_transparency option was not specified, set the default depending on the image type
- if CONF_USE_TRANSPARENCY not in config:
- config[CONF_USE_TRANSPARENCY] = is_transparent_type
-
- if is_transparent_type and not config[CONF_USE_TRANSPARENCY]:
- raise cv.Invalid(f"Image type {image_type} must always be transparent.")
-
- return config
-
-
-ANIMATION_SCHEMA = cv.Schema(
- cv.All(
- {
- cv.Required(CONF_ID): cv.declare_id(Animation_),
- cv.Required(CONF_FILE): FILE_SCHEMA,
- cv.Optional(CONF_RESIZE): cv.dimensions,
- cv.Optional(CONF_TYPE, default="BINARY"): cv.enum(
- espImage.IMAGE_TYPE, upper=True
- ),
- # Not setting default here on purpose; the default depends on the image type,
- # and thus will be set in the "validate_cross_dependencies" validator.
- cv.Optional(CONF_USE_TRANSPARENCY): cv.boolean,
- cv.Optional(CONF_LOOP): cv.All(
- {
- cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
- cv.Optional(CONF_END_FRAME): cv.positive_int,
- cv.Optional(CONF_REPEAT): cv.positive_int,
- }
- ),
- cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
- },
- validate_cross_dependencies,
- )
+ ),
+ },
)
-CONFIG_SCHEMA = ANIMATION_SCHEMA
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
{
@@ -164,180 +80,26 @@ async def animation_action_to_code(config, action_id, template_arg, args):
async def to_code(config):
- from PIL import Image
+ (
+ prog_arr,
+ width,
+ height,
+ image_type,
+ trans_value,
+ frame_count,
+ ) = await espImage.write_image(config, all_frames=True)
- conf_file = config[CONF_FILE]
- if conf_file[CONF_SOURCE] == SOURCE_LOCAL:
- path = CORE.relative_config_path(conf_file[CONF_PATH])
- elif conf_file[CONF_SOURCE] == SOURCE_WEB:
- path = espImage.compute_local_image_path(conf_file).as_posix()
- else:
- raise core.EsphomeError(f"Unknown animation source: {conf_file[CONF_SOURCE]}")
-
- try:
- image = Image.open(path)
- except Exception as e:
- raise core.EsphomeError(f"Could not load image file {path}: {e}")
-
- width, height = image.size
- frames = image.n_frames
- if CONF_RESIZE in config:
- new_width_max, new_height_max = config[CONF_RESIZE]
- ratio = min(new_width_max / width, new_height_max / height)
- width, height = int(width * ratio), int(height * ratio)
- elif width > 500 or height > 500:
- _LOGGER.warning(
- 'The image "%s" you requested is very big. Please consider'
- " using the resize parameter.",
- path,
- )
-
- transparent = config[CONF_USE_TRANSPARENCY]
-
- if config[CONF_TYPE] == "GRAYSCALE":
- data = [0 for _ in range(height * width * frames)]
- pos = 0
- for frameIndex in range(frames):
- image.seek(frameIndex)
- frame = image.convert("LA", dither=Image.Dither.NONE)
- if CONF_RESIZE in config:
- frame = frame.resize([width, height])
- pixels = list(frame.getdata())
- if len(pixels) != height * width:
- raise core.EsphomeError(
- f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})"
- )
- for pix, a in pixels:
- if transparent:
- if pix == 1:
- pix = 0
- if a < 0x80:
- pix = 1
-
- data[pos] = pix
- pos += 1
-
- elif config[CONF_TYPE] == "RGBA":
- data = [0 for _ in range(height * width * 4 * frames)]
- pos = 0
- for frameIndex in range(frames):
- image.seek(frameIndex)
- frame = image.convert("RGBA")
- if CONF_RESIZE in config:
- frame = frame.resize([width, height])
- pixels = list(frame.getdata())
- if len(pixels) != height * width:
- raise core.EsphomeError(
- f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})"
- )
- for pix in pixels:
- data[pos] = pix[0]
- pos += 1
- data[pos] = pix[1]
- pos += 1
- data[pos] = pix[2]
- pos += 1
- data[pos] = pix[3]
- pos += 1
-
- elif config[CONF_TYPE] == "RGB24":
- data = [0 for _ in range(height * width * 3 * frames)]
- pos = 0
- for frameIndex in range(frames):
- image.seek(frameIndex)
- frame = image.convert("RGBA")
- if CONF_RESIZE in config:
- frame = frame.resize([width, height])
- pixels = list(frame.getdata())
- if len(pixels) != height * width:
- raise core.EsphomeError(
- f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})"
- )
- for r, g, b, a in pixels:
- if transparent:
- if r == 0 and g == 0 and b == 1:
- b = 0
- if a < 0x80:
- r = 0
- g = 0
- b = 1
-
- data[pos] = r
- pos += 1
- data[pos] = g
- pos += 1
- data[pos] = b
- pos += 1
-
- elif config[CONF_TYPE] in ["RGB565", "TRANSPARENT_IMAGE"]:
- bytes_per_pixel = 3 if transparent else 2
- data = [0 for _ in range(height * width * bytes_per_pixel * frames)]
- pos = 0
- for frameIndex in range(frames):
- image.seek(frameIndex)
- frame = image.convert("RGBA")
- if CONF_RESIZE in config:
- frame = frame.resize([width, height])
- pixels = list(frame.getdata())
- if len(pixels) != height * width:
- raise core.EsphomeError(
- f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height * width})"
- )
- for r, g, b, a in pixels:
- R = r >> 3
- G = g >> 2
- B = b >> 3
- rgb = (R << 11) | (G << 5) | B
- data[pos] = rgb >> 8
- pos += 1
- data[pos] = rgb & 0xFF
- pos += 1
- if transparent:
- data[pos] = a
- pos += 1
-
- elif config[CONF_TYPE] in ["BINARY", "TRANSPARENT_BINARY"]:
- width8 = ((width + 7) // 8) * 8
- data = [0 for _ in range((height * width8 // 8) * frames)]
- for frameIndex in range(frames):
- image.seek(frameIndex)
- if transparent:
- alpha = image.split()[-1]
- has_alpha = alpha.getextrema()[0] < 0xFF
- else:
- has_alpha = False
- frame = image.convert("1", dither=Image.Dither.NONE)
- if CONF_RESIZE in config:
- frame = frame.resize([width, height])
- if transparent:
- alpha = alpha.resize([width, height])
- for x, y in [(i, j) for i in range(width) for j in range(height)]:
- if transparent and has_alpha:
- if not alpha.getpixel((x, y)):
- continue
- elif frame.getpixel((x, y)):
- continue
-
- pos = x + y * width8 + (height * width8 * frameIndex)
- data[pos // 8] |= 0x80 >> (pos % 8)
- else:
- raise core.EsphomeError(
- f"Animation f{config[CONF_ID]} has not supported type {config[CONF_TYPE]}."
- )
-
- rhs = [HexInt(x) for x in data]
- prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
var = cg.new_Pvariable(
config[CONF_ID],
prog_arr,
width,
height,
- frames,
- espImage.IMAGE_TYPE[config[CONF_TYPE]],
+ frame_count,
+ image_type,
+ trans_value,
)
- cg.add(var.set_transparency(transparent))
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
- end = loop_config.get(CONF_END_FRAME, frames)
+ end = loop_config.get(CONF_END_FRAME, frame_count)
count = loop_config.get(CONF_REPEAT, -1)
cg.add(var.set_loop(start, end, count))
diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp
index 1375dfe07e..6db6f1a7bd 100644
--- a/esphome/components/animation/animation.cpp
+++ b/esphome/components/animation/animation.cpp
@@ -6,8 +6,8 @@ namespace esphome {
namespace animation {
Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count,
- image::ImageType type)
- : Image(data_start, width, height, type),
+ image::ImageType type, image::Transparency transparent)
+ : Image(data_start, width, height, type, transparent),
animation_data_start_(data_start),
current_frame_(0),
animation_frame_count_(animation_frame_count),
diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h
index 272c5153d1..c44e0060af 100644
--- a/esphome/components/animation/animation.h
+++ b/esphome/components/animation/animation.h
@@ -8,7 +8,8 @@ namespace animation {
class Animation : public image::Image {
public:
- Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type);
+ Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type,
+ image::Transparency transparent);
uint32_t get_animation_frame_count() const;
int get_current_frame() const;
diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp
index cbd4249d92..7d25bf5472 100644
--- a/esphome/components/debug/debug_component.cpp
+++ b/esphome/components/debug/debug_component.cpp
@@ -50,6 +50,10 @@ void DebugComponent::dump_config() {
this->reset_reason_->publish_state(get_reset_reason_());
}
#endif // USE_TEXT_SENSOR
+
+#ifdef USE_ESP32
+ this->log_partition_info_(); // Log partition information for ESP32
+#endif // USE_ESP32
}
void DebugComponent::loop() {
diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h
index 2b54406603..608addb4a3 100644
--- a/esphome/components/debug/debug_component.h
+++ b/esphome/components/debug/debug_component.h
@@ -55,6 +55,20 @@ class DebugComponent : public PollingComponent {
#endif // USE_ESP32
#endif // USE_SENSOR
+#ifdef USE_ESP32
+ /**
+ * @brief Logs information about the device's partition table.
+ *
+ * This function iterates through the ESP32's partition table and logs details
+ * about each partition, including its name, type, subtype, starting address,
+ * and size. The information is useful for diagnosing issues related to flash
+ * memory or verifying the partition configuration dynamically at runtime.
+ *
+ * Only available when compiled for ESP32 platforms.
+ */
+ void log_partition_info_();
+#endif // USE_ESP32
+
#ifdef USE_TEXT_SENSOR
text_sensor::TextSensor *device_info_{nullptr};
text_sensor::TextSensor *reset_reason_{nullptr};
diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp
index 5f7b9cdbb0..69ae7e3678 100644
--- a/esphome/components/debug/debug_esp32.cpp
+++ b/esphome/components/debug/debug_esp32.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#if defined(USE_ESP32_VARIANT_ESP32)
#include
@@ -28,6 +29,19 @@ namespace debug {
static const char *const TAG = "debug";
+void DebugComponent::log_partition_info_() {
+ ESP_LOGCONFIG(TAG, "Partition table:");
+ ESP_LOGCONFIG(TAG, " %-12s %-4s %-8s %-10s %-10s", "Name", "Type", "Subtype", "Address", "Size");
+ esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
+ while (it != NULL) {
+ const esp_partition_t *partition = esp_partition_get(it);
+ ESP_LOGCONFIG(TAG, " %-12s %-4d %-8d 0x%08X 0x%08X", partition->label, partition->type, partition->subtype,
+ partition->address, partition->size);
+ it = esp_partition_next(it);
+ }
+ esp_partition_iterator_release(it);
+}
+
std::string DebugComponent::get_reset_reason_() {
std::string reset_reason;
switch (esp_reset_reason()) {
@@ -276,6 +290,19 @@ void DebugComponent::get_device_info_(std::string &device_info) {
device_info += " Cores:" + to_string(info.cores);
device_info += " Revision:" + to_string(info.revision);
+ // Framework detection
+ device_info += "|Framework: ";
+#ifdef USE_ARDUINO
+ ESP_LOGD(TAG, "Framework: Arduino");
+ device_info += "Arduino";
+#elif defined(USE_ESP_IDF)
+ ESP_LOGD(TAG, "Framework: ESP-IDF");
+ device_info += "ESP-IDF";
+#else
+ ESP_LOGW(TAG, "Framework: UNKNOWN");
+ device_info += "UNKNOWN";
+#endif
+
ESP_LOGD(TAG, "ESP-IDF Version: %s", esp_get_idf_version());
device_info += "|ESP-IDF: ";
device_info += esp_get_idf_version();
diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp
index 98c3e91e46..70bd42e1a5 100644
--- a/esphome/components/dfplayer/dfplayer.cpp
+++ b/esphome/components/dfplayer/dfplayer.cpp
@@ -159,6 +159,15 @@ void DFPlayer::loop() {
}
break;
case 9: // End byte
+#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
+ char byte_sequence[100];
+ byte_sequence[0] = '\0';
+ for (size_t i = 0; i < this->read_pos_ + 1; ++i) {
+ snprintf(byte_sequence + strlen(byte_sequence), sizeof(byte_sequence) - strlen(byte_sequence), "%02X ",
+ this->read_buffer_[i]);
+ }
+ ESP_LOGVV(TAG, "Received byte sequence: %s", byte_sequence);
+#endif
if (byte != 0xEF) {
ESP_LOGW(TAG, "Expected end byte 0xEF, got %#02x", byte);
this->read_pos_ = 0;
@@ -238,13 +247,17 @@ void DFPlayer::loop() {
this->ack_set_is_playing_ = false;
this->ack_reset_is_playing_ = false;
break;
+ case 0x3C:
+ ESP_LOGV(TAG, "Playback finished (USB drive)");
+ this->is_playing_ = false;
+ this->on_finished_playback_callback_.call();
case 0x3D:
- ESP_LOGV(TAG, "Playback finished");
+ ESP_LOGV(TAG, "Playback finished (SD card)");
this->is_playing_ = false;
this->on_finished_playback_callback_.call();
break;
default:
- ESP_LOGV(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument);
+ ESP_LOGE(TAG, "Received unknown cmd %#02x arg %#04x", cmd, argument);
}
this->sent_cmd_ = 0;
this->read_pos_ = 0;
diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp
index 48c8b2b04d..ff8e663ec1 100644
--- a/esphome/components/esp32/core.cpp
+++ b/esphome/components/esp32/core.cpp
@@ -58,7 +58,11 @@ uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); }
#else
uint32_t arch_get_cpu_cycle_count() { return cpu_hal_get_cycle_count(); }
#endif
-uint32_t arch_get_cpu_freq_hz() { return rtc_clk_apb_freq_get(); }
+uint32_t arch_get_cpu_freq_hz() {
+ rtc_cpu_freq_config_t config;
+ rtc_clk_cpu_freq_get_config(&config);
+ return config.freq_mhz * 1000000U;
+}
#ifdef USE_ESP_IDF
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py
index 67a0e31461..64104bb6de 100644
--- a/esphome/components/esp32_rmt_led_strip/light.py
+++ b/esphome/components/esp32_rmt_led_strip/light.py
@@ -1,4 +1,5 @@
from dataclasses import dataclass
+import logging
from esphome import pins
import esphome.codegen as cg
@@ -15,6 +16,9 @@ from esphome.const import (
CONF_RMT_CHANNEL,
CONF_RMT_SYMBOLS,
)
+from esphome.core import CORE
+
+_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@jesserockz"]
DEPENDENCIES = ["esp32"]
@@ -64,13 +68,53 @@ CONF_RESET_HIGH = "reset_high"
CONF_RESET_LOW = "reset_low"
+class OptionalForIDF5(cv.SplitDefault):
+ @property
+ def default(self):
+ if not esp32_rmt.use_new_rmt_driver():
+ return cv.UNDEFINED
+ return super().default
+
+ @default.setter
+ def default(self, value):
+ # Ignore default set from vol.Optional
+ pass
+
+
+def only_with_new_rmt_driver(obj):
+ if not esp32_rmt.use_new_rmt_driver():
+ raise cv.Invalid(
+ "This feature is only available for the IDF framework version 5."
+ )
+ return obj
+
+
+def not_with_new_rmt_driver(obj):
+ if esp32_rmt.use_new_rmt_driver():
+ raise cv.Invalid(
+ "This feature is not available for the IDF framework version 5."
+ )
+ return obj
+
+
def final_validation(config):
- if not esp32_rmt.use_new_rmt_driver() and CONF_RMT_CHANNEL not in config:
- raise cv.Invalid("rmt_channel is a required option.")
+ if not esp32_rmt.use_new_rmt_driver():
+ if CONF_RMT_CHANNEL not in config:
+ if CORE.using_esp_idf:
+ raise cv.Invalid(
+ "rmt_channel is a required option for IDF version < 5."
+ )
+ raise cv.Invalid(
+ "rmt_channel is a required option for the Arduino framework."
+ )
+ _LOGGER.warning(
+ "RMT_LED_STRIP support for IDF version < 5 is deprecated and will be removed soon."
+ )
FINAL_VALIDATE_SCHEMA = final_validation
+
CONFIG_SCHEMA = cv.All(
light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
@@ -79,9 +123,9 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True),
cv.Optional(CONF_RMT_CHANNEL): cv.All(
- cv.only_with_arduino, esp32_rmt.validate_rmt_channel(tx=True)
+ not_with_new_rmt_driver, esp32_rmt.validate_rmt_channel(tx=True)
),
- cv.SplitDefault(
+ OptionalForIDF5(
CONF_RMT_SYMBOLS,
esp32_idf=64,
esp32_s2_idf=64,
@@ -89,7 +133,7 @@ CONFIG_SCHEMA = cv.All(
esp32_c3_idf=48,
esp32_c6_idf=48,
esp32_h2_idf=48,
- ): cv.All(cv.only_with_esp_idf, cv.int_range(min=2)),
+ ): cv.All(only_with_new_rmt_driver, cv.int_range(min=2)),
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_IS_RGBW, default=False): cv.boolean,
diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py
index 4669a3418a..801b05e160 100644
--- a/esphome/components/image/__init__.py
+++ b/esphome/components/image/__init__.py
@@ -6,7 +6,7 @@ import logging
from pathlib import Path
import re
-import puremagic
+from PIL import Image, UnidentifiedImageError
from esphome import core, external_files
import esphome.codegen as cg
@@ -29,21 +29,236 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = "image"
DEPENDENCIES = ["display"]
-MULTI_CONF = True
-MULTI_CONF_NO_DEFAULT = True
image_ns = cg.esphome_ns.namespace("image")
ImageType = image_ns.enum("ImageType")
+
+CONF_OPAQUE = "opaque"
+CONF_CHROMA_KEY = "chroma_key"
+CONF_ALPHA_CHANNEL = "alpha_channel"
+CONF_INVERT_ALPHA = "invert_alpha"
+
+TRANSPARENCY_TYPES = (
+ CONF_OPAQUE,
+ CONF_CHROMA_KEY,
+ CONF_ALPHA_CHANNEL,
+)
+
+
+def get_image_type_enum(type):
+ return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}")
+
+
+def get_transparency_enum(transparency):
+ return getattr(TransparencyType, f"TRANSPARENCY_{transparency.upper()}")
+
+
+class ImageEncoder:
+ """
+ Superclass of image type encoders
+ """
+
+ # Control which transparency options are available for a given type
+ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE}
+
+ # All imageencoder types are valid
+ @staticmethod
+ def validate(value):
+ return value
+
+ def __init__(self, width, height, transparency, dither, invert_alpha):
+ """
+ :param width: The image width in pixels
+ :param height: The image height in pixels
+ :param transparency: Transparency type
+ :param dither: Dither method
+ :param invert_alpha: True if the alpha channel should be inverted; for monochrome formats inverts the colours.
+ """
+ self.transparency = transparency
+ self.width = width
+ self.height = height
+ self.data = [0 for _ in range(width * height)]
+ self.dither = dither
+ self.index = 0
+ self.invert_alpha = invert_alpha
+
+ def convert(self, image):
+ """
+ Convert the image format
+ :param image: Input image
+ :return: converted image
+ """
+ return image
+
+ def encode(self, pixel):
+ """
+ Encode a single pixel
+ """
+
+ def end_row(self):
+ """
+ Marks the end of a pixel row
+ :return:
+ """
+
+
+class ImageBinary(ImageEncoder):
+ allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY}
+
+ def __init__(self, width, height, transparency, dither, invert_alpha):
+ self.width8 = (width + 7) // 8
+ super().__init__(self.width8, height, transparency, dither, invert_alpha)
+ self.bitno = 0
+
+ def convert(self, image):
+ return image.convert("1", dither=self.dither)
+
+ def encode(self, pixel):
+ if self.invert_alpha:
+ pixel = not pixel
+ if pixel:
+ self.data[self.index] |= 0x80 >> (self.bitno % 8)
+ self.bitno += 1
+ if self.bitno == 8:
+ self.bitno = 0
+ self.index += 1
+
+ def end_row(self):
+ """
+ Pad rows to a byte boundary
+ """
+ if self.bitno != 0:
+ self.bitno = 0
+ self.index += 1
+
+
+class ImageGrayscale(ImageEncoder):
+ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_INVERT_ALPHA, CONF_OPAQUE}
+
+ def convert(self, image):
+ return image.convert("LA")
+
+ def encode(self, pixel):
+ b, a = pixel
+ if self.transparency == CONF_CHROMA_KEY:
+ if b == 1:
+ b = 0
+ if a != 0xFF:
+ b = 1
+ if self.invert_alpha:
+ b ^= 0xFF
+ if self.transparency == CONF_ALPHA_CHANNEL:
+ if a != 0xFF:
+ b = a
+ self.data[self.index] = b
+ self.index += 1
+
+
+class ImageRGB565(ImageEncoder):
+ def __init__(self, width, height, transparency, dither, invert_alpha):
+ stride = 3 if transparency == CONF_ALPHA_CHANNEL else 2
+ super().__init__(
+ width * stride,
+ height,
+ transparency,
+ dither,
+ invert_alpha,
+ )
+
+ def convert(self, image):
+ return image.convert("RGBA")
+
+ def encode(self, pixel):
+ r, g, b, a = pixel
+ r = r >> 3
+ g = g >> 2
+ b = b >> 3
+ if self.transparency == CONF_CHROMA_KEY:
+ if r == 0 and g == 1 and b == 0:
+ g = 0
+ elif a < 128:
+ r = 0
+ g = 1
+ b = 0
+ rgb = (r << 11) | (g << 5) | b
+ self.data[self.index] = rgb >> 8
+ self.index += 1
+ self.data[self.index] = rgb & 0xFF
+ self.index += 1
+ if self.transparency == CONF_ALPHA_CHANNEL:
+ if self.invert_alpha:
+ a ^= 0xFF
+ self.data[self.index] = a
+ self.index += 1
+
+
+class ImageRGB(ImageEncoder):
+ def __init__(self, width, height, transparency, dither, invert_alpha):
+ stride = 4 if transparency == CONF_ALPHA_CHANNEL else 3
+ super().__init__(
+ width * stride,
+ height,
+ transparency,
+ dither,
+ invert_alpha,
+ )
+
+ def convert(self, image):
+ return image.convert("RGBA")
+
+ def encode(self, pixel):
+ r, g, b, a = pixel
+ if self.transparency == CONF_CHROMA_KEY:
+ if r == 0 and g == 1 and b == 0:
+ g = 0
+ elif a < 128:
+ r = 0
+ g = 1
+ b = 0
+ self.data[self.index] = r
+ self.index += 1
+ self.data[self.index] = g
+ self.index += 1
+ self.data[self.index] = b
+ self.index += 1
+ if self.transparency == CONF_ALPHA_CHANNEL:
+ if self.invert_alpha:
+ a ^= 0xFF
+ self.data[self.index] = a
+ self.index += 1
+
+
+class ReplaceWith:
+ """
+ Placeholder class to provide feedback on deprecated features
+ """
+
+ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE}
+
+ def __init__(self, replace_with):
+ self.replace_with = replace_with
+
+ def validate(self, value):
+ raise cv.Invalid(
+ f"Image type {value} is removed; replace with {self.replace_with}"
+ )
+
+
IMAGE_TYPE = {
- "BINARY": ImageType.IMAGE_TYPE_BINARY,
- "TRANSPARENT_BINARY": ImageType.IMAGE_TYPE_BINARY,
- "GRAYSCALE": ImageType.IMAGE_TYPE_GRAYSCALE,
- "RGB565": ImageType.IMAGE_TYPE_RGB565,
- "RGB24": ImageType.IMAGE_TYPE_RGB24,
- "RGBA": ImageType.IMAGE_TYPE_RGBA,
+ "BINARY": ImageBinary,
+ "GRAYSCALE": ImageGrayscale,
+ "RGB565": ImageRGB565,
+ "RGB": ImageRGB,
+ "TRANSPARENT_BINARY": ReplaceWith(
+ "'type: BINARY' and 'use_transparency: chroma_key'"
+ ),
+ "RGB24": ReplaceWith("'type: RGB'"),
+ "RGBA": ReplaceWith("'type: RGB' and 'use_transparency: alpha_channel'"),
}
+TransparencyType = image_ns.enum("TransparencyType")
+
CONF_USE_TRANSPARENCY = "use_transparency"
# If the MDI file cannot be downloaded within this time, abort.
@@ -53,17 +268,11 @@ SOURCE_LOCAL = "local"
SOURCE_MDI = "mdi"
SOURCE_WEB = "web"
-
Image_ = image_ns.class_("Image")
-def _compute_local_icon_path(value: dict) -> Path:
- base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi"
- return base_dir / f"{value[CONF_ICON]}.svg"
-
-
-def compute_local_image_path(value: dict) -> Path:
- url = value[CONF_URL]
+def compute_local_image_path(value) -> Path:
+ url = value[CONF_URL] if isinstance(value, dict) else value
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
@@ -71,30 +280,38 @@ def compute_local_image_path(value: dict) -> Path:
return base_dir / key
-def download_mdi(value):
- validate_cairosvg_installed(value)
+def local_path(value):
+ value = value[CONF_PATH] if isinstance(value, dict) else value
+ return str(CORE.relative_config_path(value))
- mdi_id = value[CONF_ICON]
- path = _compute_local_icon_path(value)
+
+def download_file(url, path):
+ external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
+ return str(path)
+
+
+def download_mdi(value):
+ mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
+ base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi"
+ path = base_dir / f"{mdi_id}.svg"
url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg"
-
- external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
-
- return value
+ return download_file(url, path)
def download_image(value):
- url = value[CONF_URL]
- path = compute_local_image_path(value)
-
- external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
-
- return value
+ value = value[CONF_URL] if isinstance(value, dict) else value
+ return download_file(value, compute_local_image_path(value))
-def validate_cairosvg_installed(value):
- """Validate that cairosvg is installed"""
+def is_svg_file(file):
+ if not file:
+ return False
+ with open(file, "rb") as f:
+ return "