mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00: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/sen5x/* @martgras
|
||||||
esphome/components/sen6x/* @martgras @mebner86 @tuct
|
esphome/components/sen6x/* @martgras @mebner86 @tuct
|
||||||
esphome/components/sendspin/* @kahrendt
|
esphome/components/sendspin/* @kahrendt
|
||||||
|
esphome/components/sendspin/image/* @kahrendt
|
||||||
esphome/components/sendspin/media_player/* @kahrendt
|
esphome/components/sendspin/media_player/* @kahrendt
|
||||||
esphome/components/sendspin/media_source/* @kahrendt
|
esphome/components/sendspin/media_source/* @kahrendt
|
||||||
esphome/components/sendspin/sensor/* @kahrendt
|
esphome/components/sendspin/sensor/* @kahrendt
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from esphome import automation
|
from esphome import automation
|
||||||
import esphome.codegen as cg
|
import esphome.codegen as cg
|
||||||
@@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi
|
|||||||
import esphome.config_validation as cv
|
import esphome.config_validation as cv
|
||||||
from esphome.const import (
|
from esphome.const import (
|
||||||
CONF_BUFFER_SIZE,
|
CONF_BUFFER_SIZE,
|
||||||
|
CONF_FORMAT,
|
||||||
|
CONF_HEIGHT,
|
||||||
CONF_ID,
|
CONF_ID,
|
||||||
CONF_SAMPLE_RATE,
|
CONF_SAMPLE_RATE,
|
||||||
|
CONF_SOURCE,
|
||||||
CONF_TASK_STACK_IN_PSRAM,
|
CONF_TASK_STACK_IN_PSRAM,
|
||||||
|
CONF_WIDTH,
|
||||||
)
|
)
|
||||||
from esphome.core import CORE, ID
|
from esphome.core import CORE, ID
|
||||||
from esphome.cpp_generator import TemplateArgsType
|
from esphome.cpp_generator import TemplateArgsType
|
||||||
@@ -20,12 +24,16 @@ CODEOWNERS = ["@kahrendt"]
|
|||||||
DEPENDENCIES = ["network"]
|
DEPENDENCIES = ["network"]
|
||||||
DOMAIN = "sendspin"
|
DOMAIN = "sendspin"
|
||||||
|
|
||||||
|
CONF_DISPLAY_OFFSET = "display_offset"
|
||||||
CONF_SENDSPIN_ID = "sendspin_id"
|
CONF_SENDSPIN_ID = "sendspin_id"
|
||||||
|
|
||||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||||
CONF_FIXED_DELAY = "fixed_delay"
|
CONF_FIXED_DELAY = "fixed_delay"
|
||||||
CONF_DECODE_MEMORY = "decode_memory"
|
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-cpp library lives in the global `sendspin` namespace.
|
||||||
sendspin_library_ns = cg.global_ns.namespace("sendspin")
|
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_PCM = SendspinCodecFormat.enum("PCM")
|
||||||
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
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
|
# Library Structs
|
||||||
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
|
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
|
||||||
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
|
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
|
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
|
||||||
# preference for the player role's transfer buffers.
|
# preference for the player role's transfer buffers.
|
||||||
@@ -76,6 +95,7 @@ class SendspinConfiguration:
|
|||||||
player_support: bool = False
|
player_support: bool = False
|
||||||
visualizer_support: bool = False
|
visualizer_support: bool = False
|
||||||
|
|
||||||
|
artwork_preferences: list[ConfigType] = field(default_factory=list)
|
||||||
player_config: ConfigType | None = None
|
player_config: ConfigType | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +130,22 @@ def request_visualizer_support() -> None:
|
|||||||
_get_data().visualizer_support = True
|
_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:
|
def register_player_config(config: ConfigType) -> None:
|
||||||
"""Register the player role config from the media source subcomponent."""
|
"""Register the player role config from the media source subcomponent."""
|
||||||
data = _get_data()
|
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_*).
|
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
|
||||||
if data.artwork_support:
|
if data.artwork_support:
|
||||||
cg.add_define("USE_SENDSPIN_ARTWORK", True)
|
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:
|
else:
|
||||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
|
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";
|
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() {
|
void SendspinHub::setup() {
|
||||||
auto config = this->build_client_config_();
|
auto config = this->build_client_config_();
|
||||||
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(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_network_provider(this);
|
||||||
this->client_->set_persistence_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
|
#ifdef USE_SENDSPIN_CONTROLLER
|
||||||
this->controller_role_ = &this->client_->add_controller();
|
this->controller_role_ = &this->client_->add_controller();
|
||||||
this->controller_role_->set_listener(this);
|
this->controller_role_->set_listener(this);
|
||||||
@@ -67,6 +78,18 @@ void SendspinHub::dump_config() {
|
|||||||
" Client ID: %s\n"
|
" Client ID: %s\n"
|
||||||
" Task stack in PSRAM: %s",
|
" Task stack in PSRAM: %s",
|
||||||
get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
|
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 ---
|
// --- Delegating methods ---
|
||||||
@@ -174,6 +197,30 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
|
|||||||
|
|
||||||
// --- Sendspin role specific methods/overrides ---
|
// --- 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
|
#ifdef USE_SENDSPIN_CONTROLLER
|
||||||
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
|
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
|
||||||
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
|
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
#include <sendspin/config.h>
|
#include <sendspin/config.h>
|
||||||
#include <sendspin/types.h>
|
#include <sendspin/types.h>
|
||||||
|
|
||||||
|
#ifdef USE_SENDSPIN_ARTWORK
|
||||||
|
#include <sendspin/artwork_role.h>
|
||||||
|
#endif
|
||||||
#ifdef USE_SENDSPIN_CONTROLLER
|
#ifdef USE_SENDSPIN_CONTROLLER
|
||||||
#include <sendspin/controller_role.h>
|
#include <sendspin/controller_role.h>
|
||||||
#endif
|
#endif
|
||||||
@@ -69,6 +72,9 @@ struct StaticDelayPref {
|
|||||||
/// (for services the library pulls; e.g., persistence, network readiness).
|
/// (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.
|
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
|
||||||
class SendspinHub final : public Component,
|
class SendspinHub final : public Component,
|
||||||
|
#ifdef USE_SENDSPIN_ARTWORK
|
||||||
|
public sendspin::ArtworkRoleListener,
|
||||||
|
#endif
|
||||||
#ifdef USE_SENDSPIN_CONTROLLER
|
#ifdef USE_SENDSPIN_CONTROLLER
|
||||||
public sendspin::ControllerRoleListener,
|
public sendspin::ControllerRoleListener,
|
||||||
#endif
|
#endif
|
||||||
@@ -121,6 +127,27 @@ class SendspinHub final : public Component,
|
|||||||
|
|
||||||
// --- Sendspin role specific methods ---
|
// --- 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
|
#ifdef USE_SENDSPIN_CONTROLLER
|
||||||
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
|
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
|
||||||
std::optional<bool> mute = std::nullopt);
|
std::optional<bool> mute = std::nullopt);
|
||||||
@@ -171,6 +198,23 @@ class SendspinHub final : public Component,
|
|||||||
|
|
||||||
// --- Sendspin role specific methods/overrides/member variables ---
|
// --- 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
|
#ifdef USE_SENDSPIN_CONTROLLER
|
||||||
sendspin::ControllerRole *controller_role_{nullptr};
|
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