Add a generic_image platform component and have Sendspin impelement it for artwork

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