mirror of
https://github.com/esphome/esphome.git
synced 2026-10-09 12:26:02 +00:00
221 lines
8.0 KiB
Python
221 lines
8.0 KiB
Python
"""Sendspin image platform."""
|
|
|
|
from typing import Any
|
|
|
|
from esphome import automation
|
|
import esphome.codegen as cg
|
|
from esphome.components import runtime_image
|
|
from esphome.components.const import CONF_SLOT
|
|
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.types import ConfigType
|
|
|
|
from .. import (
|
|
CONF_DISPLAY_OFFSET,
|
|
CONF_SENDSPIN_ID,
|
|
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_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,
|
|
}
|
|
|
|
|
|
# Remove before 2027.4.0
|
|
def _reject_bmp(value: Any) -> Any:
|
|
if isinstance(value, str) and value.upper() == "BMP":
|
|
raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.")
|
|
return value
|
|
|
|
|
|
# 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.All(
|
|
_reject_bmp, 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)
|
|
|
|
|
|
automation.register_apply_action(
|
|
"sendspin.image.transition_finished",
|
|
automation.maybe_simple_id(
|
|
cv.Schema(
|
|
{
|
|
cv.GenerateID(): cv.use_id(SendspinImageSlot),
|
|
}
|
|
)
|
|
),
|
|
automation.ApplyCall("transition_finished()"),
|
|
)
|