From 7f0d6a86968ab05d548e878671697d8133d676b9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 16:49:08 -0400 Subject: [PATCH] [sendspin] Add image platform for artwork (#17937) --- CODEOWNERS | 1 + esphome/components/sendspin/__init__.py | 61 +++- esphome/components/sendspin/image/__init__.py | 228 +++++++++++++++ .../components/sendspin/image/automation.h | 20 ++ .../sendspin/image/sendspin_image.cpp | 261 ++++++++++++++++++ .../sendspin/image/sendspin_image.h | 185 +++++++++++++ esphome/components/sendspin/sendspin_hub.cpp | 47 ++++ esphome/components/sendspin/sendspin_hub.h | 44 +++ tests/component_tests/sendspin/__init__.py | 0 tests/component_tests/sendspin/test_image.py | 114 ++++++++ tests/components/sendspin/common-image.yaml | 47 ++++ .../sendspin/test-image-lvgl.esp32-idf.yaml | 66 +++++ .../sendspin/test-image.esp32-idf.yaml | 3 + 13 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/image/__init__.py create mode 100644 esphome/components/sendspin/image/automation.h create mode 100644 esphome/components/sendspin/image/sendspin_image.cpp create mode 100644 esphome/components/sendspin/image/sendspin_image.h create mode 100644 tests/component_tests/sendspin/__init__.py create mode 100644 tests/component_tests/sendspin/test_image.py create mode 100644 tests/components/sendspin/common-image.yaml create mode 100644 tests/components/sendspin/test-image-lvgl.esp32-idf.yaml create mode 100644 tests/components/sendspin/test-image.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 253b0c05b1..9ddbca5c71 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -466,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index d0c2112ba9..bd889c2c92 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 @@ -20,12 +24,16 @@ CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["network"] DOMAIN = "sendspin" +CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +# Matches ARTWORK_MAX_SLOTS in sendspin-cpp. +MAX_ARTWORK_SLOTS = 4 + # sendspin-cpp library lives in the global `sendspin` namespace. sendspin_library_ns = cg.global_ns.namespace("sendspin") @@ -36,9 +44,20 @@ 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") + # 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") # MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement # preference for the player role's transfer buffers. @@ -76,6 +95,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -110,6 +130,22 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_artwork_preference(config: ConfigType) -> int: + """Register an artwork slot preference and return the slot it was given. + + A slot is a preference's position in the list, which is also the order the roles are + advertised to the server in. + """ + request_artwork_support() + preferences = _get_data().artwork_preferences + if len(preferences) >= MAX_ARTWORK_SLOTS: + raise cv.Invalid( + f"Too many Sendspin image slots. Maximum is {MAX_ARTWORK_SLOTS}." + ) + preferences.append(config) + return len(preferences) - 1 + + def register_player_config(config: ConfigType) -> None: """Register the player role config from the media source subcomponent.""" data = _get_data() @@ -211,6 +247,29 @@ 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) + + # require_frame_done is always on: SendspinImageSlot always acks a delivery, either + # immediately or from the transition_finished action. + preference_structs = [ + cg.StructInitializer( + ImageSlotPreference, + ("source", pref[CONF_SOURCE]), + ("format", pref[CONF_FORMAT]), + ("width", pref[CONF_WIDTH]), + ("height", pref[CONF_HEIGHT]), + ("require_frame_done", True), + ("display_offset_ms", pref[CONF_DISPLAY_OFFSET]), + ) + 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), + ) + 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/image/__init__.py b/esphome/components/sendspin/image/__init__.py new file mode 100644 index 0000000000..94d6e7cfca --- /dev/null +++ b/esphome/components/sendspin/image/__init__.py @@ -0,0 +1,228 @@ +"""Sendspin image platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_HEIGHT, + CONF_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_WIDTH, +) +from esphome.core import ID +from esphome.cpp_generator import TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_DISPLAY_OFFSET, + CONF_SENDSPIN_ID, + IMAGE_FORMAT_BMP, + IMAGE_FORMAT_JPEG, + IMAGE_FORMAT_PNG, + IMAGE_SOURCE_ALBUM, + IMAGE_SOURCE_ARTIST, + SendspinHub, + register_artwork_preference, + sendspin_ns, +) + +AUTO_LOAD = ["runtime_image"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +# runtime_image refuses to size a buffer beyond this, so anything larger fails at setup rather +# than at validation. The library's ImageSlotPreference width/height fields are uint16_t, which +# is the looser of the two bounds. +MAX_IMAGE_DIMENSION = 32767 + +# Sanity bound for display_offset; the library field is int32_t milliseconds and offsets beyond +# a few seconds around the track boundary are meaningless. +MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) +MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) + +CONF_SLOT = "slot" +CONF_CURRENT_IMAGE = "current_image" +CONF_TRANSITION_IMAGE = "transition_image" +CONF_ON_IMAGE_DISPLAY = "on_image_display" +CONF_ON_IMAGE_CLEAR = "on_image_clear" +CONF_ON_IMAGE_ERROR = "on_image_error" + +# Map runtime_image's validated format string to the sendspin library's SendspinImageFormat enum. +# runtime_image accepts "JPG" as an alias for JPEG, so both keys map to the JPEG enum. +_FORMAT_TO_SENDSPIN_ENUM = { + "JPEG": IMAGE_FORMAT_JPEG, + "JPG": IMAGE_FORMAT_JPEG, + "PNG": IMAGE_FORMAT_PNG, + "BMP": IMAGE_FORMAT_BMP, +} + +# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it +# would never receive artwork while still paying for two frame buffers, so it is not offered here. +IMAGE_SOURCES = { + "ALBUM": IMAGE_SOURCE_ALBUM, + "ARTIST": IMAGE_SOURCE_ARTIST, +} + +# The platform entry configures an artwork slot; the images it shows are declared inside it. The +# slot itself is the automation target (triggers and the transition_finished action). +SendspinImageSlot = sendspin_ns.class_( + "SendspinImageSlot", + cg.Component, + cg.Parented.template(SendspinHub), +) +ArtworkImageView = sendspin_ns.class_("ArtworkImageView", Image_) + +# A dict rather than a bare ID so per-image options can be added later without a new top-level key. +_IMAGE_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.declare_id(ArtworkImageView)}) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_IMAGE_DISPLAY, + "add_on_image_display_callback", + [(cg.uint32, "lateness_ms")], + ), + automation.CallbackAutomation(CONF_ON_IMAGE_CLEAR, "add_on_image_clear_callback"), + automation.CallbackAutomation(CONF_ON_IMAGE_ERROR, "add_on_image_error_callback"), +) + + +def _assign_slot_and_register(config: ConfigType) -> ConfigType: + """Register the artwork preference with the hub and record the slot it was given.""" + width, height = config[CONF_RESIZE] + if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION: + raise cv.Invalid( + f"'{CONF_RESIZE}' width and height must be {MAX_IMAGE_DIMENSION} or less", + path=[CONF_RESIZE], + ) + + config[CONF_SLOT] = register_artwork_preference( + { + CONF_SOURCE: config[CONF_SOURCE], + CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]], + CONF_WIDTH: width, + CONF_HEIGHT: height, + CONF_DISPLAY_OFFSET: config[CONF_DISPLAY_OFFSET].total_milliseconds, + } + ) + return config + + +# The format, type, resize, transparency, byte order and placeholder keys all describe the slot: +# they set what is requested from the server and how it is decoded, not either individual image. +# Only the IDs are per-image, so runtime_image_schema declares the slot itself. +CONFIG_SCHEMA = cv.All( + runtime_image.runtime_image_schema(SendspinImageSlot).extend( + { + cv.GenerateID(): cv.declare_id(SendspinImageSlot), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + # Narrow runtime_image's format list to what the library can request, so the + # accepted set and the enum map below cannot drift apart. + cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_RESIZE): cv.dimensions, + cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum( + IMAGE_SOURCES, upper=True + ), + # Positive fires on_image_display before the server's display timestamp (negative + # delays it), so a cross-fade can straddle the track boundary. + cv.Optional(CONF_DISPLAY_OFFSET, default="0ms"): cv.All( + cv.time_period, + # The library field is whole milliseconds; reject finer values rather than + # silently rounding them down to zero. + cv.time_period_in_milliseconds_, + cv.Range(min=MIN_DISPLAY_OFFSET, max=MAX_DISPLAY_OFFSET), + ), + cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_CLEAR): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation({}), + } + ), + 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) + + def make_view(view_id: ID) -> cg.MockObj: + # Views start with no frame; the slot points them at its buffers in setup(). The size is + # given up front so the view is well formed before then. LVGL picks it up from the first + # lvgl.image.update in on_image_display, not from the widget's initial src: at that point + # the view still has no frame, so its descriptor is empty. + view = cg.new_Pvariable( + view_id, + cg.nullptr, + settings.width, + settings.height, + settings.image_type_enum, + settings.transparent, + ) + add_metadata( + view_id, + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + return view + + current_image = make_view(config[CONF_CURRENT_IMAGE][CONF_ID]) + if settings.placeholder is not None: + cg.add(current_image.set_placeholder(settings.placeholder)) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_SLOT], + current_image, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + + if (transition_image := config.get(CONF_TRANSITION_IMAGE)) is not None: + cg.add(var.set_transition_image(make_view(transition_image[CONF_ID]))) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +SendspinImageTransitionFinishedAction = sendspin_ns.class_( + "SendspinImageTransitionFinishedAction", + automation.Action, + cg.Parented.template(SendspinImageSlot), +) + + +@automation.register_action( + "sendspin.image.transition_finished", + SendspinImageTransitionFinishedAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinImageSlot), + } + ) + ), + synchronous=True, +) +async def sendspin_image_transition_finished_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/sendspin/image/automation.h b/esphome/components/sendspin/image/automation.h new file mode 100644 index 0000000000..154e62a4b2 --- /dev/null +++ b/esphome/components/sendspin/image/automation.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/automation.h" +#include "sendspin_image.h" + +namespace esphome::sendspin_ { + +template +class SendspinImageTransitionFinishedAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->transition_finished(); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp new file mode 100644 index 0000000000..626d7966b7 --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -0,0 +1,261 @@ +#include "sendspin_image.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.image"; + +// How long a displayed frame may wait for sendspin.image.transition_finished before a warning +// names the missing ack. Generous next to a typical fade of a second or two. +static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000; + +// THREAD CONTEXT: Main loop. Children set up after the hub, so the artwork role already exists. +void SendspinImageSlot::setup() { + const size_t frame_size = this->decode_sink_.get_buffer_size(this->width_, this->height_); + if (frame_size == 0) { + // The sink would refuse a buffer of these dimensions, so every decode would fall back to + // allocating one of its own. Fail here instead, where the dimensions are already known. + ESP_LOGE(TAG, "Cannot decode artwork at %dx%d", this->width_, this->height_); + this->mark_failed(); + return; + } + + RAMAllocator allocator; + for (uint8_t *&buffer : this->buffers_) { + buffer = allocator.allocate(frame_size); + if (buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate %zu bytes for an artwork frame. Largest free block: %zu", frame_size, + allocator.get_max_free_block_size()); + for (uint8_t *&allocated : this->buffers_) { + allocator.deallocate(allocated, frame_size); + allocated = nullptr; + } + this->mark_failed(); + return; + } + // Both buffers start black, so a transition has something to fade from before any artwork + // has arrived. + memset(buffer, 0, frame_size); + } + + // Point both views at buffers_[current_index_] rather than the buffer the first decode writes + // into, so they name a frame that stays black until artwork arrives. + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + } + + this->parent_->add_image_decode_callback( + [this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat) { + if (slot == this->slot_) + this->on_decode_(data, length); + }); + this->parent_->add_image_display_callback([this](uint8_t slot, uint32_t lateness_ms) { + if (slot == this->slot_) + this->on_display_(lateness_ms); + }); + this->parent_->add_image_clear_callback([this](uint8_t slot) { + if (slot == this->slot_) + this->on_clear_(); + }); +} + +// THREAD CONTEXT: Dedicated artwork decode thread. The data pointer is valid only for this call. +void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { + uint8_t *target; + { + // The lock makes the main loop's last swap of current_index_ visible here. The frame_done gate + // is what guarantees the buffer it picks out is not still needed by the main loop. + LockGuard lock(this->pending_mutex_); + target = this->buffers_[this->current_index_ ^ 1]; + } + + // The server letterboxes artwork onto a canvas of exactly the requested dimensions, so the sink + // is pinned to them: a decode that asks for anything else is a malformed payload and drops the + // frame. + if (!this->decode_sink_.set_external_buffer(target, this->width_, this->height_)) { + // setup() rules this out, but decoding without the handover would allocate a frame-sized + // buffer on this thread, which is exactly what the permanent buffers exist to avoid. + this->report_error_(); + return; + } + + const bool decoded = this->decode_frame_(data, length, target); + // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is + // safe on every path. + this->decode_sink_.release(); + + if (!decoded) { + // The buffer keeps whatever the failed decode painted into it, but no view names it while a + // decode can run, so nothing shows it. + this->report_error_(); + return; + } + + LockGuard lock(this->pending_mutex_); + this->frame_pending_ = true; +} + +// THREAD CONTEXT: Artwork decode thread, with target already handed to the sink. +bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const uint8_t *target) { + if (!this->decode_sink_.begin_decode(length)) { + ESP_LOGE(TAG, "Could not start decode"); + return false; + } + + size_t total_consumed = 0; + while (total_consumed < length) { + int consumed = this->decode_sink_.feed_data(const_cast(data) + total_consumed, length - total_consumed); + if (consumed <= 0) { + // <0 is a decode error; 0 means the decoder cannot make progress (truncated/corrupt data). + ESP_LOGE(TAG, "Decode failed at offset %zu (result %d)", total_consumed, consumed); + return false; + } + total_consumed += consumed; + } + + if (!this->decode_sink_.end_decode()) { + ESP_LOGE(TAG, "Could not finalize decode"); + return false; + } + + // A decode that asked for other dimensions had the buffer taken away from it, so it painted + // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // regardless, so the frame is dropped here. + return this->decode_sink_.decoded_into(target); +} + +// THREAD CONTEXT: Main loop (fired once the slot's offset-shifted display deadline is reached). +void SendspinImageSlot::on_display_(uint32_t lateness_ms) { + bool frame_ready; + { + LockGuard lock(this->pending_mutex_); + frame_ready = this->frame_pending_; + this->frame_pending_ = false; + if (frame_ready) { + // The decoded frame becomes the current one; the frame it replaces becomes the outgoing + // frame, and the next decode target once the transition is acked. + this->current_index_ ^= 1; + } + } + if (!frame_ready) { + // The decode for this display failed, so there is nothing new to show. The delivery still owes + // its ack or the library would withhold every later frame for this slot. + this->parent_->artwork_frame_done(this->slot_); + return; + } + + // The frame this display replaces is only real artwork if something was already on screen. + const bool outgoing_is_artwork = this->showing_artwork_; + this->showing_artwork_ = true; + this->apply_frames_(outgoing_is_artwork); + + // Armed before the trigger fires so an automation that acks synchronously still counts, and armed + // for the first frame too so the contract stays uniform: one transition_finished per display. + this->transition_pending_ = this->transition_image_ != nullptr; + if (this->transition_pending_) { + // The library holds back further deliveries until the ack, with no timeout, so an automation + // that never reaches the action stalls the slot with nothing in the log. Name the cause after + // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame + // that was already acked and superseded. + this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + if (this->transition_pending_) { + ESP_LOGW(TAG, + "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " + "sendspin.image.transition_finished runs or the stream is cleared", + this->slot_); + } + }); + } + this->image_display_callback_.call(lateness_ms); + if (this->transition_image_ == nullptr) { + this->finish_transition_(); + } +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::finish_transition_() { + this->transition_pending_ = false; + if (this->transition_image_ != nullptr) { + // Move it off the buffer the next decode writes into. What it shows does not change: the + // buffer it moves to holds the artwork the transition just settled on. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(this->showing_artwork_); + } + // The ack wakes the decode thread, which may start writing buffers_[current_index_ ^ 1] straight + // away, so nothing may still name that buffer by the time this runs. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.image.transition_finished action). +void SendspinImageSlot::transition_finished() { + if (!this->transition_pending_) { + return; + } + this->finish_transition_(); +} + +// THREAD CONTEXT: Main loop (fired on stream end or clear for this slot). +void SendspinImageSlot::on_clear_() { + { + LockGuard lock(this->pending_mutex_); + // Drop a frame that was decoded but never displayed; its buffer stays the decode target. + this->frame_pending_ = false; + } + // No pixels are touched and the views keep naming the frames they had: a widget goes on drawing + // the last artwork until the automation points it elsewhere or hides it. Only the display lambda + // path stops drawing the artwork, falling back to the placeholder. + this->current_image_->set_showing_artwork(false); + if (this->transition_image_ != nullptr) { + // Point it away from the decode target, as at setup, so it cannot show a frame being decoded. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(false); + } + this->showing_artwork_ = false; + // Drops a running transition. Its automation cannot be cancelled here, so a late + // transition_finished() can ack the next stream's first frame early, showing it without its + // transition. The ack count stays right. + this->transition_pending_ = false; + this->image_clear_callback_.call(); + // A clear is itself a delivery owing exactly one ack, and it supersedes any un-acked frame -- + // including one whose transition never signalled transition_finished(), so a stalled slot + // recovers here. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::dump_config() { + ESP_LOGCONFIG(TAG, + "Artwork slot %u:\n" + " Dimensions: %dx%d\n" + " Frame buffers: 2 x %zu bytes\n" + " Transition image: %s", + this->slot_, this->width_, this->height_, + this->decode_sink_.get_buffer_size(this->width_, this->height_), + YESNO(this->transition_image_ != nullptr)); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::apply_frames_(bool transition_is_artwork) { + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->current_image_->set_showing_artwork(true); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_ ^ 1], this->width_, this->height_); + this->transition_image_->set_showing_artwork(transition_is_artwork); + } +} + +// THREAD CONTEXT: Artwork decode thread. Triggers must run on the main loop; defer() is thread-safe +// here because the hub enables wake_loop_threadsafe support. +void SendspinImageSlot::report_error_() { + this->defer([this]() { this->image_error_callback_.call(); }); +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h new file mode 100644 index 0000000000..2f6f4e8a4d --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -0,0 +1,185 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/components/image/image.h" +#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/core/helpers.h" + +#include + +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Decode-only RuntimeImage that decodes into a buffer owned by SendspinImageSlot. +/// +/// Runs exclusively on the sendspin library's artwork decode thread. RuntimeImage's decode path +/// overwrites the fields the display reads (data_start_/width_/height_), so it must never be the +/// object shown on screen. +class ArtworkDecodeSink : public runtime_image::RuntimeImage { + public: + using runtime_image::RuntimeImage::RuntimeImage; + + /// @brief True when the decode ended with the given buffer still in place. + /// + /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions + /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. + bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } +}; + +/// @brief A non-owning image::Image view over a buffer owned by SendspinImageSlot. +/// +/// Each slot publishes its frames through these: one for the artwork on screen, and optionally a +/// second for the outgoing frame during a cross-fade. A view always names a frame, black to begin +/// with, so LVGL can be given it as a widget source before any artwork exists. Main loop only. +class ArtworkImageView : public image::Image { + public: + using image::Image::Image; + + void set_frame(const uint8_t *data, int width, int height) { + this->data_start_ = data; + this->width_ = width; + this->height_ = height; +#ifdef USE_LVGL + // Keep the descriptor LVGL is handed in step with the frame. This does not redraw anything: + // only setting a widget's source invalidates it. + this->get_lv_image_dsc(); +#endif + } + + /// @brief Records whether the frame on show is real artwork rather than the black it starts as. + /// + /// Only changes what the display lambda path draws. The frame itself is left alone, so anything + /// reading the pixels directly (an LVGL widget) keeps drawing the last artwork until it is + /// pointed elsewhere. + void set_showing_artwork(bool showing_artwork) { this->showing_artwork_ = showing_artwork; } + + void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; } + + void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override { + if (!this->showing_artwork_) { + // Nothing worth showing yet: the placeholder if there is one, otherwise leave the area be + // rather than paint a blank frame over it. + if (this->placeholder_ != nullptr) { + this->placeholder_->draw(x, y, display, color_on, color_off); + } + return; + } + image::Image::draw(x, y, display, color_on, color_off); + } + + protected: + image::Image *placeholder_{nullptr}; + bool showing_artwork_{false}; +}; + +/// @brief A single artwork slot: owns the frame buffers and publishes them to its image views. +/// +/// BUFFERS: two buffers, allocated zeroed at setup and never freed. One holds the frame the current +/// image shows; the other holds the outgoing frame a transition shows, and is where the next +/// artwork is decoded. Each display swaps their roles. +/// +/// THREADING: the sendspin library decodes on a dedicated thread and fires display/clear on the +/// main loop. Decoding runs into decode_sink_, which writes into the buffer the current image is +/// not showing; the swap that puts it on screen happens on the main loop. Every slot enables the +/// library's require_frame_done gate, which withholds further deliveries for the slot (buffering +/// the newest payload, latest wins) until the hub's artwork_frame_done() runs. That gate is what +/// makes two buffers enough: no decode starts while the main loop still needs the outgoing frame. +/// +/// LVGL: publishing a frame to a view updates the descriptor LVGL was handed but does not +/// invalidate the widget, so every widget's source must be set again on each display. +class SendspinImageSlot : public SendspinChild { + public: + SendspinImageSlot(uint8_t slot, ArtworkImageView *current_image, int width, int height, + runtime_image::ImageFormat format, image::ImageType type, image::Transparency transparency, + bool is_big_endian) + : decode_sink_(format, type, transparency, nullptr, is_big_endian, width, height), + current_image_(current_image), + width_(width), + height_(height), + slot_(slot) {} + + void setup() override; + void dump_config() override; + + template void add_on_image_display_callback(F &&callback) { + this->image_display_callback_.add(std::forward(callback)); + } + template void add_on_image_clear_callback(F &&callback) { + this->image_clear_callback_.add(std::forward(callback)); + } + template void add_on_image_error_callback(F &&callback) { + this->image_error_callback_.add(std::forward(callback)); + } + + /// @brief Sets the optional view a transition draws the outgoing artwork from. + /// + /// It holds the outgoing frame while a transition is running and the current frame at any other + /// time, so it always names a picture and never the frame being decoded. + /// + /// Setting it is also what defers the library ack to transition_finished(): the ack releases the + /// outgoing frame to be decoded over, and this view is the only thing that still names it. + void set_transition_image(ArtworkImageView *transition_image) { this->transition_image_ = transition_image; } + + /// @brief Signals that the display transition for the last frame has finished. + /// + /// Acks the library so the next artwork can be delivered, which also hands the outgoing frame's + /// buffer over to be decoded into. Safe no-op when no transition is pending (e.g. no transition + /// image is configured, a clear already ended the transition, or the call is a duplicate). Must + /// run on the main loop thread; exposed as the sendspin.image.transition_finished action. + void transition_finished(); + + protected: + void on_decode_(const uint8_t *data, size_t length); + bool decode_frame_(const uint8_t *data, size_t length, const uint8_t *target); + void on_display_(uint32_t lateness_ms); + void on_clear_(); + void finish_transition_(); + void apply_frames_(bool transition_is_artwork); + void report_error_(); + + ArtworkDecodeSink decode_sink_; + + // The two frame buffers, allocated in setup() and never freed. Their contents are written on the + // decode thread and read by whatever draws the views, so only their roles are swapped, never the + // pointers themselves. + std::array buffers_{}; + + // pending_mutex_ guards the two fields below, the only state shared across threads. Everything + // after them is touched on the main loop only. + Mutex pending_mutex_; + // Index into buffers_ of the frame the current image shows. buffers_[current_index_ ^ 1] holds + // the outgoing frame and is the next decode target. Written on the main loop, read on the + // decode thread. + uint8_t current_index_{0}; + // Set on the decode thread once a frame is waiting in buffers_[current_index_ ^ 1]. + bool frame_pending_{false}; + + // True once artwork has been displayed, until the next clear; decides whether the outgoing frame + // is real artwork or the black the buffers start as. Main loop only. + bool showing_artwork_{false}; + // True while a displayed frame awaits transition_finished(); gates duplicate or stray calls + // so exactly one ack reaches the library per delivery. Main loop only. + bool transition_pending_{false}; + + ArtworkImageView *current_image_; + ArtworkImageView *transition_image_{nullptr}; + int width_; + int height_; + uint8_t slot_; + + LazyCallbackManager image_display_callback_{}; + LazyCallbackManager image_clear_callback_{}; + LazyCallbackManager image_error_callback_{}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04dbab0080..2d2f646382 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,12 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_SENDSPIN_ARTWORK +// Indexed by the library enums, which start at zero and are contiguous. +static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +#endif + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -37,6 +43,11 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_ARTWORK + this->artwork_role_ = &this->client_->add_artwork(this->artwork_config_); + this->artwork_role_->set_listener(this); +#endif + #ifdef USE_SENDSPIN_CONTROLLER this->controller_role_ = &this->client_->add_controller(); this->controller_role_->set_listener(this); @@ -67,6 +78,18 @@ void SendspinHub::dump_config() { " Client ID: %s\n" " Task stack in PSRAM: %s", get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + +#ifdef USE_SENDSPIN_ARTWORK + // Slot indices come from the order the image platform entries were declared, so the log is the + // only place the mapping from a slot to the artwork it asked for can be read back. + uint8_t slot = 0; + for (const auto &preference : this->artwork_config_.preferred_formats) { + ESP_LOGCONFIG(TAG, " Artwork slot %u: %s as %s, %ux%u, display offset %" PRId32 " ms", slot++, + IMAGE_SOURCE_NAMES[static_cast(preference.source)], + IMAGE_FORMAT_NAMES[static_cast(preference.format)], preference.width, preference.height, + preference.display_offset_ms); + } +#endif } // --- Delegating methods --- @@ -174,6 +197,30 @@ 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 slot's offset-shifted display +// deadline is reached; lateness_ms reports how far past the deadline the display slipped) +void SendspinHub::on_image_display(uint8_t slot, uint32_t lateness_ms) { + this->artwork_image_display_callbacks_.call(slot, lateness_ms); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop()) +void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); } + +// THREAD CONTEXT: Main loop (invoked from SendspinImageSlot once a delivery is fully presented) +void SendspinHub::artwork_frame_done(uint8_t slot) { + if (this->artwork_role_ != nullptr) { + this->artwork_role_->frame_done(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 c6b1ed97f7..a495fdcf37 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 @@ -69,6 +72,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 @@ -121,6 +127,27 @@ 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; } + + /// @brief Acknowledges the most recent artwork delivery (display or clear) for a slot. + /// + /// Every slot is configured with the library's require_frame_done gate, which withholds the + /// next delivery for the slot until this is called. Exactly one ack is owed per delivery; a + /// redundant call is a safe no-op in the library. Must be called from the main loop thread. + void artwork_frame_done(uint8_t slot); + + 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); @@ -171,6 +198,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, uint32_t lateness_ms) override; + + void on_image_clear(uint8_t slot) override; + + sendspin::ArtworkRoleConfig artwork_config_{}; + sendspin::ArtworkRole *artwork_role_{nullptr}; + + // Callback fan-out to child components; they filter by slot as needed. + 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/component_tests/sendspin/__init__.py b/tests/component_tests/sendspin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py new file mode 100644 index 0000000000..be3b7d6684 --- /dev/null +++ b/tests/component_tests/sendspin/test_image.py @@ -0,0 +1,114 @@ +"""Validation tests for the sendspin image platform. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import IMAGE_FORMAT_JPEG, MAX_ARTWORK_SLOTS, _get_data +from esphome.components.sendspin.image import CONFIG_SCHEMA, MAX_IMAGE_DIMENSION +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _slot_config(**overrides: Any) -> ConfigType: + """Build a minimal valid artwork slot config, allowing field overrides.""" + config: ConfigType = { + "id": "album_slot", + "format": "JPEG", + "type": "RGB565", + "resize": "240x240", + "current_image": {"id": "album_art"}, + } + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config()) + + assert config["slot"] == 0 + assert config["source"] == "ALBUM" + assert config["display_offset"].total_milliseconds == 0 + + +@pytest.mark.parametrize("image_format", ["JPEG", "JPG"]) +def test_jpeg_alias_maps_to_one_enum( + set_core_config: SetCoreConfigCallable, image_format: str +) -> None: + """runtime_image takes JPG as an alias for JPEG, so both spellings must reach the + library's single JPEG enum.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_slot_config(format=image_format)) + + assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG + + +def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" + set_core_config(PlatformFramework.ESP32_IDF) + + for slot in range(MAX_ARTWORK_SLOTS): + assert CONFIG_SCHEMA(_slot_config(id=f"slot_{slot}"))["slot"] == slot + + with pytest.raises(cv.Invalid, match="Too many Sendspin image slots"): + CONFIG_SCHEMA(_slot_config(id="one_too_many")) + + +@pytest.mark.parametrize( + "resize", + [f"{MAX_IMAGE_DIMENSION + 1}x240", f"240x{MAX_IMAGE_DIMENSION + 1}"], +) +def test_oversized_resize_rejected( + set_core_config: SetCoreConfigCallable, resize: str +) -> None: + """Either dimension past the decoder's limit is refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=f"must be {MAX_IMAGE_DIMENSION} or less"): + CONFIG_SCHEMA(_slot_config(resize=resize)) + + +def test_sub_millisecond_display_offset_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The library field is whole milliseconds, so finer values are refused + rather than silently rounded down to zero.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Maximum precision is milliseconds"): + CONFIG_SCHEMA(_slot_config(display_offset="500us")) + + +@pytest.mark.parametrize("display_offset", ["61s", "-61s"]) +def test_out_of_range_display_offset_rejected( + set_core_config: SetCoreConfigCallable, display_offset: str +) -> None: + """Offsets more than a minute either side of the boundary are refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="value must be at (most|least)"): + CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + +@pytest.mark.parametrize( + ("display_offset", "expected_ms"), [("250ms", 250), ("-2s", -2000)] +) +def test_display_offset_accepted( + set_core_config: SetCoreConfigCallable, display_offset: str, expected_ms: int +) -> None: + """Whole-millisecond offsets pass through in both directions.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + assert config["display_offset"].total_milliseconds == expected_ms diff --git a/tests/components/sendspin/common-image.yaml b/tests/components/sendspin/common-image.yaml new file mode 100644 index 0000000000..7c32a5e257 --- /dev/null +++ b/tests/components/sendspin/common-image.yaml @@ -0,0 +1,47 @@ +packages: + sendspin: !include common.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)); + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + - logger.log: + format: "Album art displayed (late by %u ms)" + args: ["(unsigned) lateness_ms"] + # Stand-in for a display transition; with a transition image every display must end + # with transition_finished so the library releases the next artwork frame. + - delay: 300ms + - sendspin.image.transition_finished: album_slot + on_image_clear: + - logger.log: "Album art cleared" + on_image_error: + - logger.log: "Album art error" + - platform: sendspin + id: artist_slot + format: PNG + type: RGB565 + resize: 96x96 + source: ARTIST + current_image: + id: artist_art diff --git a/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml new file mode 100644 index 0000000000..9084d77262 --- /dev/null +++ b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml @@ -0,0 +1,66 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common.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 + auto_clear_enabled: false + +lvgl: + displays: + - main_lcd + animations: + # Fades the top widget out to reveal the new artwork underneath. Starting it also snaps the + # widget back to full opacity, and on_stop acks the transition so the library can deliver the + # next artwork. + - id: album_art_crossfade + duration: 2s + widgets: + - id: outgoing_art + opa: + from: 100% + to: 0% + on_stop: + - sendspin.image.transition_finished: album_slot + widgets: + # Cross-fade pair: the bottom widget always shows the current artwork; the top widget is + # pointed at the outgoing frame on each display event and faded out over it. + - image: + id: incoming_art + src: album_art + - image: + id: outgoing_art + src: album_art + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + # Start the fade 1s before the track boundary so the 2s cross-fade straddles it. + display_offset: 1s + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + # A widget keeps drawing the buffer it was last pointed at until its source is set again, so + # both widgets are re-pointed on every display: the top widget at the outgoing frame + # (covering the bottom), the bottom widget at the new frame. The transition image is black + # before the first artwork, so the first fade needs no special case. + - lvgl.image.update: + id: outgoing_art + src: album_art_transition + - lvgl.image.update: + id: incoming_art + src: album_art + - lvgl.animation.start: album_art_crossfade diff --git a/tests/components/sendspin/test-image.esp32-idf.yaml b/tests/components/sendspin/test-image.esp32-idf.yaml new file mode 100644 index 0000000000..a4f9e492c6 --- /dev/null +++ b/tests/components/sendspin/test-image.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common-image.yaml