diff --git a/esphome/components/generic_image/__init__.py b/esphome/components/generic_image/__init__.py new file mode 100644 index 00000000000..8b142a3ff1e --- /dev/null +++ b/esphome/components/generic_image/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@kahrendt"] +IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 58687ae8389..b8933908a1a 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg @@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_FORMAT, + CONF_HEIGHT, CONF_ID, CONF_SAMPLE_RATE, + CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_WIDTH, ) from esphome.core import CORE, ID from esphome.cpp_generator import TemplateArgsType @@ -21,6 +25,7 @@ DEPENDENCIES = ["network"] DOMAIN = "sendspin" CONF_SENDSPIN_ID = "sendspin_id" +CONF_SLOT = "slot" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" @@ -35,9 +40,21 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) +IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") +IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") +IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") + +SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) +IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") +IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST") +IMAGE_SOURCE_NONE = SendspinImageSource.enum("NONE") + # Library Structs AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") +ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig") +ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference") # Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace. # Analysis tools strip the trailing underscore (same pattern as `template_`). @@ -63,6 +80,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -97,6 +115,12 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_artwork_preference(config: ConfigType) -> None: + """Register an artwork slot preference from an image subcomponent.""" + request_artwork_support() + _get_data().artwork_preferences.append(config) + + def register_player_config(config: ConfigType) -> None: """Register the player role config from the media source subcomponent.""" data = _get_data() @@ -203,6 +227,27 @@ async def to_code(config: ConfigType) -> None: # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). if data.artwork_support: cg.add_define("USE_SENDSPIN_ARTWORK", True) + + preference_structs = [ + cg.StructInitializer( + ImageSlotPreference, + ("slot", pref[CONF_SLOT]), + ("source", pref[CONF_SOURCE]), + ("format", pref[CONF_FORMAT]), + ("width", pref[CONF_WIDTH]), + ("height", pref[CONF_HEIGHT]), + ) + for pref in data.artwork_preferences + ] + + artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM)) + artwork_config = cg.StructInitializer( + ArtworkRoleConfig, + ("preferred_formats", preference_structs), + ("psram_stack", artwork_psram_stack), + ("priority", 2), + ) + cg.add(var.set_artwork_config(artwork_config)) else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) diff --git a/esphome/components/sendspin/generic_image/__init__.py b/esphome/components/sendspin/generic_image/__init__.py new file mode 100644 index 00000000000..11068c2359d --- /dev/null +++ b/esphome/components/sendspin/generic_image/__init__.py @@ -0,0 +1,159 @@ +"""Sendspin generic_image platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_HEIGHT, + CONF_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TRIGGER_ID, + CONF_TYPE, + CONF_WIDTH, +) +from esphome.core import CORE +from esphome.types import ConfigType + +from .. import ( + CONF_SENDSPIN_ID, + CONF_SLOT, + IMAGE_FORMAT_BMP, + IMAGE_FORMAT_JPEG, + IMAGE_FORMAT_PNG, + IMAGE_SOURCE_ALBUM, + IMAGE_SOURCE_ARTIST, + IMAGE_SOURCE_NONE, + SendspinHub, + register_artwork_preference, + sendspin_ns, +) + +AUTO_LOAD = ["runtime_image"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +MAX_IMAGE_SLOTS = 4 +_SLOT_COUNTER_KEY = "sendspin_image_slot_counter" + +CONF_ON_IMAGE_DISPLAY = "on_image_display" +CONF_ON_IMAGE_ERROR = "on_image_error" + +# Map runtime_image's format string to the sendspin library's SendspinImageFormat enum. +_FORMAT_TO_SENDSPIN_ENUM = { + "JPEG": IMAGE_FORMAT_JPEG, + "PNG": IMAGE_FORMAT_PNG, + "BMP": IMAGE_FORMAT_BMP, +} + +IMAGE_SOURCES = { + "ALBUM": IMAGE_SOURCE_ALBUM, + "ARTIST": IMAGE_SOURCE_ARTIST, + "NONE": IMAGE_SOURCE_NONE, +} + +SendspinImage = sendspin_ns.class_( + "SendspinImage", + runtime_image.RuntimeImage, + cg.Component, +) + +SendspinImageDisplayTrigger = sendspin_ns.class_( + "SendspinImageDisplayTrigger", automation.Trigger.template() +) +SendspinImageErrorTrigger = sendspin_ns.class_( + "SendspinImageErrorTrigger", automation.Trigger.template() +) + + +def _assign_slot_and_register(config: ConfigType) -> ConfigType: + """Auto-assign a slot, validate the max count, and register the artwork preference with the hub.""" + current = CORE.data.get(_SLOT_COUNTER_KEY, 0) + if current >= MAX_IMAGE_SLOTS: + raise cv.Invalid( + f"Too many Sendspin generic_image components. Maximum is {MAX_IMAGE_SLOTS}." + ) + CORE.data[_SLOT_COUNTER_KEY] = current + 1 + config[CONF_SLOT] = current + + width, height = config[CONF_RESIZE] + register_artwork_preference( + { + CONF_SLOT: current, + CONF_SOURCE: config[CONF_SOURCE], + CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]], + CONF_WIDTH: width, + CONF_HEIGHT: height, + } + ) + return config + + +CONFIG_SCHEMA = cv.All( + runtime_image.runtime_image_schema(SendspinImage).extend( + { + cv.GenerateID(): cv.declare_id(SendspinImage), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Required(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum( + IMAGE_SOURCES, upper=True + ), + cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + SendspinImageDisplayTrigger + ), + } + ), + cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + SendspinImageErrorTrigger + ), + } + ), + } + ), + runtime_image.validate_runtime_image_settings, + cv.only_on_esp32, + _assign_slot_and_register, +) + + +async def to_code(config: ConfigType) -> None: + settings = await runtime_image.process_runtime_image_config(config) + + add_metadata( + config[CONF_ID], + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + + var = cg.new_Pvariable( + config[CONF_ID], + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.byte_order_big_endian, + settings.placeholder, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + + cg.add(var.set_slot(config[CONF_SLOT])) + cg.add(var.set_image_source(IMAGE_SOURCES[config[CONF_SOURCE]])) + + for conf in config.get(CONF_ON_IMAGE_DISPLAY, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + + for conf in config.get(CONF_ON_IMAGE_ERROR, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) diff --git a/esphome/components/sendspin/generic_image/sendspin_generic_image.cpp b/esphome/components/sendspin/generic_image/sendspin_generic_image.cpp new file mode 100644 index 00000000000..c8f3c66d9de --- /dev/null +++ b/esphome/components/sendspin/generic_image/sendspin_generic_image.cpp @@ -0,0 +1,66 @@ +#include "sendspin_generic_image.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/log.h" + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.generic_image"; + +SendspinImage::SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format, + image::ImageType type, image::Transparency transparency, bool is_big_endian, + image::Image *placeholder) + : runtime_image::RuntimeImage(format, type, transparency, placeholder, is_big_endian, fixed_width, fixed_height) {} + +// THREAD CONTEXT: Main loop. The decode callback registered below fires on the artwork +// decode thread; the display and clear callbacks fire on the main loop. +void SendspinImage::setup() { + this->parent_->add_image_decode_callback( + [this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) { + if (slot == this->slot_) + this->on_decode_(data, length); + }); + this->parent_->add_image_display_callback([this](uint8_t slot) { + if (slot == this->slot_) + this->on_display_(); + }); + this->parent_->add_image_clear_callback([this](uint8_t slot) { + if (slot == this->slot_) + this->on_clear_(); + }); +} + +// THREAD CONTEXT: Dedicated artwork decode thread (via SendspinHub's decode callback). +// Decode synchronously into the back buffer; heavy CPU work is allowed here. +void SendspinImage::on_decode_(const uint8_t *data, size_t length) { + this->begin_decode(length); + size_t total_consumed = 0; + while (total_consumed < length) { + int consumed = this->feed_data(const_cast(data) + total_consumed, length - total_consumed); + if (consumed < 0) { + ESP_LOGE(TAG, "Error decoding image data at offset %zu", total_consumed); + this->image_error_callback_.call(); + return; + } + total_consumed += consumed; + } + if (!this->end_decode()) { + ESP_LOGE(TAG, "Failed to finalize image after decoding"); + this->image_error_callback_.call(); + return; + } +} + +// THREAD CONTEXT: Main loop (fired once the server display timestamp is reached) +void SendspinImage::on_display_() { this->image_display_callback_.call(); } + +// THREAD CONTEXT: Main loop +void SendspinImage::on_clear_() { + this->release(); + this->image_display_callback_.call(); +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/generic_image/sendspin_generic_image.h b/esphome/components/sendspin/generic_image/sendspin_generic_image.h new file mode 100644 index 00000000000..b21188abec7 --- /dev/null +++ b/esphome/components/sendspin/generic_image/sendspin_generic_image.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/core/automation.h" + +#include + +namespace esphome::sendspin_ { + +class SendspinImage : public SendspinChild, public runtime_image::RuntimeImage { + public: + SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format, image::ImageType type, + image::Transparency transparency, bool is_big_endian = false, image::Image *placeholder = nullptr); + + void setup() override; + + template void add_on_image_display_callback(F &&callback) { + this->image_display_callback_.add(std::forward(callback)); + } + template void add_on_image_error_callback(F &&callback) { + this->image_error_callback_.add(std::forward(callback)); + } + + void set_image_source(sendspin::SendspinImageSource source) { this->source_ = source; } + void set_slot(uint8_t slot) { this->slot_ = slot; } + + protected: + // Artwork thread. Decodes encoded bytes synchronously; buffer is valid only for this call. + void on_decode_(const uint8_t *data, size_t length); + // Main loop thread. Trigger when art should be displayed. + void on_display_(); + // Main loop thread. Releases the decoded image and refires the display trigger so listeners re-render. + void on_clear_(); + + LazyCallbackManager image_display_callback_{}; + LazyCallbackManager image_error_callback_{}; + + sendspin::SendspinImageSource source_{sendspin::SendspinImageSource::ALBUM}; + uint8_t slot_{0}; +}; + +class SendspinImageDisplayTrigger : public Trigger<> { + public: + explicit SendspinImageDisplayTrigger(SendspinImage *parent) { + parent->add_on_image_display_callback([this]() { this->trigger(); }); + } +}; + +class SendspinImageErrorTrigger : public Trigger<> { + public: + explicit SendspinImageErrorTrigger(SendspinImage *parent) { + parent->add_on_image_error_callback([this]() { this->trigger(); }); + } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index d27c5672eb6..e8fa47a2ad0 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -34,6 +34,10 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_ARTWORK + this->client_->add_artwork(this->artwork_config_).set_listener(this); +#endif + #ifdef USE_SENDSPIN_CONTROLLER this->controller_role_ = &this->client_->add_controller(); this->controller_role_->set_listener(this); @@ -157,6 +161,20 @@ std::optional SendspinHub::load_last_server_hash() { // --- Sendspin role specific methods/overrides --- +#ifdef USE_SENDSPIN_ARTWORK +// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too +void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length, + sendspin::SendspinImageFormat format) { + this->artwork_image_decode_callbacks_.call(slot, data, length, format); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop() once the server display timestamp is reached) +void SendspinHub::on_image_display(uint8_t slot) { this->artwork_image_display_callbacks_.call(slot); } + +// THREAD CONTEXT: Main loop (fired from client_->loop()) +void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); } +#endif + #ifdef USE_SENDSPIN_CONTROLLER // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 12fbf156ea6..69b8c5ea2b2 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -13,6 +13,9 @@ #include #include +#ifdef USE_SENDSPIN_ARTWORK +#include +#endif #ifdef USE_SENDSPIN_CONTROLLER #include #endif @@ -67,6 +70,9 @@ struct StaticDelayPref { /// (for services the library pulls; e.g., persistence, network readiness). /// - User -> library communication uses exposed functions on the client and role objects that the user calls. class SendspinHub final : public Component, +#ifdef USE_SENDSPIN_ARTWORK + public sendspin::ArtworkRoleListener, +#endif #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, #endif @@ -119,6 +125,20 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods --- +#ifdef USE_SENDSPIN_ARTWORK + void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; } + + template void add_image_decode_callback(F &&callback) { + this->artwork_image_decode_callbacks_.add(std::forward(callback)); + } + template void add_image_display_callback(F &&callback) { + this->artwork_image_display_callbacks_.add(std::forward(callback)); + } + template void add_image_clear_callback(F &&callback) { + this->artwork_image_clear_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_CONTROLLER void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); @@ -165,6 +185,23 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods/overrides/member variables --- +#ifdef USE_SENDSPIN_ARTWORK + void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override; + + void on_image_display(uint8_t slot) override; + + void on_image_clear(uint8_t slot) override; + + sendspin::ArtworkRoleConfig artwork_config_{}; + + // Callback fan-out to child components; they filter by slot as needed. + // decode and display fire from the library's artwork thread; clear fires from the main loop. + CallbackManager + artwork_image_decode_callbacks_{}; + CallbackManager artwork_image_display_callbacks_{}; + CallbackManager artwork_image_clear_callbacks_{}; +#endif + #ifdef USE_SENDSPIN_CONTROLLER sendspin::ControllerRole *controller_role_{nullptr}; diff --git a/tests/components/sendspin/common-generic_image.yaml b/tests/components/sendspin/common-generic_image.yaml new file mode 100644 index 00000000000..74e67bf98e2 --- /dev/null +++ b/tests/components/sendspin/common-generic_image.yaml @@ -0,0 +1,35 @@ +<<: !include common.yaml + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(album_art)); + +generic_image: + - platform: sendspin + id: album_art + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + on_image_display: + - logger.log: "Album art displayed" + on_image_error: + - logger.log: "Album art error" + - platform: sendspin + id: artist_art + format: PNG + type: RGB565 + resize: 96x96 + source: ARTIST diff --git a/tests/components/sendspin/test-generic_image.esp32-idf.yaml b/tests/components/sendspin/test-generic_image.esp32-idf.yaml new file mode 100644 index 00000000000..451fc7169c8 --- /dev/null +++ b/tests/components/sendspin/test-generic_image.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-generic_image.yaml