[sendspin] Add image platform for artwork (#17937)

This commit is contained in:
Kevin Ahrendt
2026-08-11 15:49:08 -05:00
committed by GitHub
parent 4a7de87bff
commit 7f0d6a8696
13 changed files with 1076 additions and 1 deletions
+1
View File
@@ -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
+60 -1
View File
@@ -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)
@@ -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
@@ -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<typename... Ts>
class SendspinImageTransitionFinishedAction final : public Action<Ts...>, public Parented<SendspinImageSlot> {
public:
void play(const Ts &...x) override { this->parent_->transition_finished(); }
};
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,261 @@
#include "sendspin_image.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/core/log.h"
#include <cstring>
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<uint8_t> 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<uint8_t *>(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
@@ -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 <sendspin/artwork_role.h>
#include <array>
#include <cstdint>
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<typename F> void add_on_image_display_callback(F &&callback) {
this->image_display_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_image_clear_callback(F &&callback) {
this->image_clear_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_image_error_callback(F &&callback) {
this->image_error_callback_.add(std::forward<F>(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<uint8_t *, 2> 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<void(uint32_t)> image_display_callback_{};
LazyCallbackManager<void()> image_clear_callback_{};
LazyCallbackManager<void()> image_error_callback_{};
};
} // namespace esphome::sendspin_
#endif
@@ -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<sendspin::SendspinClient>(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<uint8_t>(preference.source)],
IMAGE_FORMAT_NAMES[static_cast<uint8_t>(preference.format)], preference.width, preference.height,
preference.display_offset_ms);
}
#endif
}
// --- Delegating methods ---
@@ -174,6 +197,30 @@ std::optional<uint32_t> 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<uint8_t> volume,
@@ -13,6 +13,9 @@
#include <sendspin/config.h>
#include <sendspin/types.h>
#ifdef USE_SENDSPIN_ARTWORK
#include <sendspin/artwork_role.h>
#endif
#ifdef USE_SENDSPIN_CONTROLLER
#include <sendspin/controller_role.h>
#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<typename F> void add_image_decode_callback(F &&callback) {
this->artwork_image_decode_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_image_display_callback(F &&callback) {
this->artwork_image_display_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_image_clear_callback(F &&callback) {
this->artwork_image_clear_callbacks_.add(std::forward<F>(callback));
}
#endif
#ifdef USE_SENDSPIN_CONTROLLER
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
std::optional<bool> 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<void(uint8_t, const uint8_t *, size_t, sendspin::SendspinImageFormat)>
artwork_image_decode_callbacks_{};
CallbackManager<void(uint8_t, uint32_t)> artwork_image_display_callbacks_{};
CallbackManager<void(uint8_t)> artwork_image_clear_callbacks_{};
#endif
#ifdef USE_SENDSPIN_CONTROLLER
sendspin::ControllerRole *controller_role_{nullptr};
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
sendspin: !include common-image.yaml