mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 02:47:52 +08:00
[sendspin] Add image platform for artwork (#17937)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user