Compare commits

...

27 Commits

Author SHA1 Message Date
Kevin Ahrendt f874b2749a Merge branch 'dev' into sendspin-artwork 2026-06-15 08:42:00 -04:00
Clyde Stubbs 963465a0a6 [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) 2026-06-15 08:25:54 -04:00
Jesse Hills 1ee49720c7 [psram] Make schema extractable with per-variant options (#16949)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-06-15 19:55:21 +12:00
Jesse Hills c1a7a8ff55 Add PEP 572 walrus operator preference to coding conventions (#16951) 2026-06-15 16:01:44 +12:00
Clyde Stubbs f1fd5f2f49 [epaper_spi] Metadata, bug fixes, new model (#16950) 2026-06-15 12:10:58 +10:00
Jesse Hills 8d2c2e6adc Merge branch 'beta' into dev 2026-06-15 12:05:55 +12:00
Jonathan Swoboda efebea3296 [esp32] Add flash_mode and flash_frequency config options (#16920)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-06-15 10:56:18 +12:00
Jonathan Swoboda e191fc5d47 [core] Support platformio_options on the native ESP-IDF toolchain (#16917) 2026-06-15 10:56:03 +12:00
Jonathan Swoboda 1e5771a3fa [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) 2026-06-15 09:48:43 +12:00
Clyde Stubbs 5b7f8cf90d [mipi_spi] Implement automatic mapping of offsets (#16722)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-06-15 09:36:38 +12:00
guillempages 35e5c7c7c3 [runtime_image] Improve error logging (#16943) 2026-06-14 07:40:49 +10:00
Clyde Stubbs 10ce6024bf [lvgl] Fix schema extraction (#16895)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 08:21:38 -04:00
dependabot[bot] bf6c8568d3 Bump CodSpeedHQ/action from 4.17.0 to 4.17.5 (#16919)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 14:33:28 -04:00
dependabot[bot] 88084f2ec7 Bump ruff from 0.15.16 to 0.15.17 (#16918)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 14:32:51 -04:00
Tobiasz Jakubowski 6ef35b6d3d [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-06-11 19:50:51 -04:00
Dan Drown 28dd935359 [xpt2046] touchscreen driver enhancement (#16414)
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-06-11 12:35:44 -04:00
J. Nick Koston 750cf1995b [esp8266] Decode crash handler PC and backtrace in logs (#16911) 2026-06-11 08:47:50 -05:00
Jesse Hills a56b6d8993 Merge branch 'beta' into dev 2026-06-11 16:09:15 +12:00
Jesse Hills 6a527c7efc [tests] Mock target branch in memory-impact exclusion test (#16913) 2026-06-11 14:04:22 +12:00
Jesse Hills e0b0c1e8d3 Bump version to 2026.7.0-dev 2026-06-11 12:41:19 +12:00
Kevin Ahrendt 3786df71e5 Merge branch 'dev' into sendspin-artwork 2026-05-24 07:21:11 -04:00
Kevin Ahrendt 4cd3fe8fa3 Merge branch 'dev' into sendspin-artwork 2026-05-20 15:02:55 -04:00
Kevin Ahrendt 39fc31d1b9 Merge branch 'dev' into sendspin-artwork 2026-05-14 07:46:14 -04:00
Kevin Ahrendt 16d47fb3ce Merge branch 'dev' into sendspin-artwork 2026-05-07 07:24:22 -04:00
Kevin Ahrendt 0c8155e590 Merge branch 'dev' into sendspin-artwork 2026-05-04 06:37:51 -04:00
Kevin Ahrendt c10916fe76 update codeowners 2026-04-27 10:37:48 -04:00
Kevin Ahrendt aa34da0a55 Add a generic_image platform component and have Sendspin impelement it for artwork 2026-04-27 10:25:51 -04:00
38 changed files with 1076 additions and 120 deletions
+1 -1
View File
@@ -456,7 +456,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5
with:
run: |
. venv/bin/activate
+13
View File
@@ -59,6 +59,19 @@ This document provides essential context for AI models interacting with this pro
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
* **Python Idioms:**
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
```python
# Bad - looks up CONF_BLAH twice
if CONF_BLAH in config:
cg.add(var.set_blah(config[CONF_BLAH]))
# Good - single lookup, value bound inline
if (blah := config.get(CONF_BLAH)) is not None:
cg.add(var.set_blah(blah))
```
The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line.
* **C++ Field Visibility:**
* **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`.
* **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants:
+2
View File
@@ -192,6 +192,7 @@ esphome/components/ft5x06/* @clydebarrow
esphome/components/ft63x6/* @gpambrozio
esphome/components/gcja5/* @gcormier
esphome/components/gdk101/* @Szewcson
esphome/components/generic_image/* @kahrendt
esphome/components/gl_r01_i2c/* @pkejval
esphome/components/globals/* @esphome/core
esphome/components/gp2y1010au0f/* @zry98
@@ -447,6 +448,7 @@ esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/generic_image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.6.0b2
PROJECT_NUMBER = 2026.7.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+21 -1
View File
@@ -13,6 +13,7 @@ from esphome.components.mipi import (
import esphome.config_validation as cv
from esphome.config_validation import update_interval
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUSY_PIN,
CONF_CS_PIN,
CONF_DATA_RATE,
@@ -129,7 +130,23 @@ def customise_schema(config):
},
extra=cv.ALLOW_EXTRA,
)(config)
return model_schema(config)(config)
model = MODELS[config[CONF_MODEL]]
config = model_schema(config)(config)
width, height = model.get_dimensions(config)
display.add_metadata(
config[CONF_ID],
width,
height,
has_hardware_rotation=True,
byte_order=cv.UNDEFINED,
has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True
or config.get(CONF_PAGES) is not None
or config.get(CONF_LAMBDA) is not None
or config.get(CONF_SHOW_TEST_CARD) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=0,
)
return config
CONFIG_SCHEMA = customise_schema
@@ -197,6 +214,9 @@ async def to_code(config):
if busy_pin := config.get(CONF_BUSY_PIN):
busy = await cg.gpio_pin_expression(busy_pin)
cg.add(var.set_busy_pin(busy))
if enable_pin := config.get(CONF_ENABLE_PIN):
enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin]
cg.add(var.set_enable_pins(enable))
cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY]))
if CONF_RESET_DURATION in config:
cg.add(var.set_reset_duration(config[CONF_RESET_DURATION]))
@@ -38,6 +38,10 @@ bool EPaperBase::init_buffer_(size_t buffer_length) {
}
void EPaperBase::setup_pins_() const {
for (auto *pin : this->enable_pins_) {
pin->setup();
pin->digital_write(true);
}
this->dc_pin_->setup(); // OUTPUT
this->dc_pin_->digital_write(false);
@@ -50,6 +50,7 @@ class EPaperBase : public Display,
float get_setup_priority() const override;
void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; }
void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; }
void set_enable_pins(std::vector<GPIOPin *> enable_pins) { this->enable_pins_ = std::move(enable_pins); }
void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; }
void set_transform(uint8_t transform) {
this->transform_ = transform;
@@ -177,6 +178,7 @@ class EPaperBase : public Display,
GPIOPin *dc_pin_{};
GPIOPin *busy_pin_{};
GPIOPin *reset_pin_{};
std::vector<GPIOPin *> enable_pins_{};
bool waiting_for_idle_{};
uint32_t delay_until_{}; // timestamp until which to delay processing
uint16_t next_delay_{}; // milliseconds to delay before next state
@@ -10,11 +10,11 @@ class SSD1677(EpaperModel):
# fmt: off
def get_init_sequence(self, config: dict):
width, _height = self.get_dimensions(config)
_width, height = self.get_dimensions(config)
return (
(0x18, 0x80), # Select internal Temp sensor
(0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2
(0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit
(0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1)
(0x3C, 0x01), # Set border waveform
(0x11, 3), # Set transform
)
@@ -51,3 +51,16 @@ ssd1677.extend(
height=480,
mirror_x=True,
)
ssd1677.extend(
"seeed-reterminal-sticky",
width=800,
height=480,
mirror_x=True,
enable_pin=47,
cs_pin=15,
dc_pin=16,
reset_pin=17,
busy_pin=18,
data_rate="10MHz",
)
+29
View File
@@ -1,3 +1,4 @@
from collections.abc import Callable, Iterable
import contextlib
from dataclasses import dataclass
import itertools
@@ -6,6 +7,7 @@ import os
from pathlib import Path
import re
import subprocess
from typing import Any
from esphome import yaml_util
import esphome.codegen as cg
@@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_components
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.writer import clean_build, clean_cmake_cache
@@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None):
return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT]
def variant_filtered_enum(
by_variant: dict[str, Iterable[Any]], **kwargs: Any
) -> Callable[[Any], Any]:
"""Build a ``one_of`` validator whose valid set depends on the active variant.
``by_variant`` maps each ESP32 variant constant to the iterable of values that
are valid on that variant. At validation time the value is checked against the
set allowed for the current target variant. For schema extraction the inverted
``{value: [variants, ...]}`` map is returned instead, so the language-schema
dump can tag every option with the variants that accept it and frontends can
filter to the user's selected variant.
"""
by_value: dict[str, list[str]] = {}
for variant, values in by_variant.items():
for value in values:
by_value.setdefault(str(value), []).append(variant)
@schema_extractor("variant_enum")
def validator(value: Any) -> Any:
if value is SCHEMA_EXTRACT:
return by_value
return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value)
return validator
def get_board(core_obj=None):
return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD]
@@ -0,0 +1,2 @@
CODEOWNERS = ["@kahrendt"]
IS_PLATFORM_COMPONENT = True
@@ -71,6 +71,7 @@ DriverChip(
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0x01,),
(0x60, 0x71, 0x23, 0xa2),
(0x60, 0x71, 0x23, 0xa3),
(0x60, 0x71, 0x23, 0xa4),
@@ -1,4 +1,5 @@
#include "online_image.h"
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/log.h"
#include <algorithm>
@@ -181,7 +182,7 @@ void OnlineImage::loop() {
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
if (consumed < 0) {
ESP_LOGE(TAG, "Error decoding image: %d", consumed);
ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed));
this->end_connection_();
this->download_error_callback_.call();
return;
+37 -15
View File
@@ -16,6 +16,7 @@ from esphome.components.esp32 import (
add_idf_sdkconfig_option,
get_esp32_variant,
idf_version,
variant_filtered_enum,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -29,6 +30,7 @@ from esphome.const import (
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "psram"
@@ -70,6 +72,11 @@ SPIRAM_SPEEDS = {
VARIANT_ESP32P4: (20, 100, 200),
}
SPIRAM_SPEEDS_MHZ = {
variant: tuple(f"{speed}MHZ" for speed in speeds)
for variant, speeds in SPIRAM_SPEEDS.items()
}
def supported() -> bool:
if not CORE.is_esp32:
@@ -145,15 +152,23 @@ def validate_psram_mode(config):
return config
def get_config_schema(config):
def _set_variant_defaults(config: ConfigType) -> ConfigType:
"""Resolve variant-dependent defaults before the static schema validates.
The set of valid ``mode``/``speed`` values is variant-specific (enforced by
``variant_filtered_enum`` in the schema below); this only supplies the default
when the user omits the option. ``mode`` has no single default on chips that
support more than one mode, so selection is required there.
"""
variant = get_esp32_variant()
speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])]
if not speeds:
modes = SPIRAM_MODES.get(variant)
speeds = SPIRAM_SPEEDS.get(variant)
if not modes or not speeds:
raise cv.Invalid("PSRAM is not supported on this chip")
modes = SPIRAM_MODES[variant]
if CONF_MODE not in config and len(modes) != 1:
raise (
cv.Invalid(
config = config.copy()
if CONF_MODE not in config:
if len(modes) != 1:
raise cv.Invalid(
textwrap.dedent(
f"""
{variant} requires PSRAM mode selection; one of {", ".join(modes)}
@@ -161,20 +176,27 @@ def get_config_schema(config):
"""
)
)
)
return cv.Schema(
config[CONF_MODE] = modes[0]
if CONF_SPEED not in config:
config[CONF_SPEED] = f"{speeds[0]}MHZ"
return config
CONFIG_SCHEMA = cv.All(
_set_variant_defaults,
cv.Schema(
{
cv.GenerateID(): cv.declare_id(PsramComponent),
cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True),
cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True),
cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean,
cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True),
cv.Optional(CONF_SPEED): variant_filtered_enum(
SPIRAM_SPEEDS_MHZ, upper=True
),
cv.Optional(CONF_DISABLED, default=False): cv.boolean,
cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean,
}
)(config)
CONFIG_SCHEMA = get_config_schema
),
)
def _store_psram_guaranteed(config):
@@ -7,8 +7,24 @@ enum DecodeError : int {
DECODE_ERROR_INVALID_TYPE = -1,
DECODE_ERROR_UNSUPPORTED_FORMAT = -2,
DECODE_ERROR_OUT_OF_MEMORY = -3,
DECODE_ERROR_INTERNAL_DECODER_ERROR = -4,
};
constexpr const char *decode_error_to_string(int error) {
switch (error) {
case DECODE_ERROR_INVALID_TYPE:
return "Invalid type";
case DECODE_ERROR_UNSUPPORTED_FORMAT:
return "Unsupported format";
case DECODE_ERROR_OUT_OF_MEMORY:
return "Out of memory";
case DECODE_ERROR_INTERNAL_DECODER_ERROR:
return "Internal decoder error";
default:
return "Unknown error";
}
}
class RuntimeImage;
/**
@@ -89,9 +89,21 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (!this->jpeg_.decode(0, 0, 0)) {
ESP_LOGE(TAG, "Error while decoding.");
auto error = this->jpeg_.getLastError();
ESP_LOGE(TAG, "Error while decoding: %d", error);
this->jpeg_.close();
return DECODE_ERROR_UNSUPPORTED_FORMAT;
switch (error) {
case JPEG_ERROR_MEMORY:
return DECODE_ERROR_OUT_OF_MEMORY;
case JPEG_UNSUPPORTED_FEATURE:
return DECODE_ERROR_UNSUPPORTED_FORMAT;
case JPEG_INVALID_FILE:
case JPEG_INVALID_PARAMETER:
return DECODE_ERROR_INVALID_TYPE;
case JPEG_DECODE_ERROR:
default:
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
}
}
this->decoded_bytes_ = size;
this->jpeg_.close();
@@ -95,6 +95,7 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
auto fed = pngle_feed(this->pngle_, buffer, size);
if (fed < 0) {
ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_));
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
} else {
this->decoded_bytes_ += fed;
}
+46 -1
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from esphome import automation
import esphome.codegen as cg
@@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_SAMPLE_RATE,
CONF_SOURCE,
CONF_TASK_STACK_IN_PSRAM,
CONF_WIDTH,
)
from esphome.core import CORE, ID
from esphome.cpp_generator import TemplateArgsType
@@ -21,6 +25,7 @@ DEPENDENCIES = ["network"]
DOMAIN = "sendspin"
CONF_SENDSPIN_ID = "sendspin_id"
CONF_SLOT = "slot"
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay"
@@ -36,9 +41,21 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP")
SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True)
IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM")
IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST")
IMAGE_SOURCE_NONE = SendspinImageSource.enum("NONE")
# Library Structs
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
@@ -76,6 +93,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 +128,12 @@ def request_visualizer_support() -> None:
_get_data().visualizer_support = True
def register_artwork_preference(config: ConfigType) -> None:
"""Register an artwork slot preference from an image subcomponent."""
request_artwork_support()
_get_data().artwork_preferences.append(config)
def register_player_config(config: ConfigType) -> None:
"""Register the player role config from the media source subcomponent."""
data = _get_data()
@@ -210,6 +234,27 @@ async def to_code(config: ConfigType) -> None:
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
if data.artwork_support:
cg.add_define("USE_SENDSPIN_ARTWORK", True)
preference_structs = [
cg.StructInitializer(
ImageSlotPreference,
("slot", pref[CONF_SLOT]),
("source", pref[CONF_SOURCE]),
("format", pref[CONF_FORMAT]),
("width", pref[CONF_WIDTH]),
("height", pref[CONF_HEIGHT]),
)
for pref in data.artwork_preferences
]
artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM))
artwork_config = cg.StructInitializer(
ArtworkRoleConfig,
("preferred_formats", preference_structs),
("psram_stack", artwork_psram_stack),
("priority", 2),
)
cg.add(var.set_artwork_config(artwork_config))
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
@@ -0,0 +1,159 @@
"""Sendspin generic_image platform."""
from esphome import automation
import esphome.codegen as cg
from esphome.components import runtime_image
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
import esphome.config_validation as cv
from esphome.const import (
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_RESIZE,
CONF_SOURCE,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_WIDTH,
)
from esphome.core import CORE
from esphome.types import ConfigType
from .. import (
CONF_SENDSPIN_ID,
CONF_SLOT,
IMAGE_FORMAT_BMP,
IMAGE_FORMAT_JPEG,
IMAGE_FORMAT_PNG,
IMAGE_SOURCE_ALBUM,
IMAGE_SOURCE_ARTIST,
IMAGE_SOURCE_NONE,
SendspinHub,
register_artwork_preference,
sendspin_ns,
)
AUTO_LOAD = ["runtime_image"]
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
MAX_IMAGE_SLOTS = 4
_SLOT_COUNTER_KEY = "sendspin_image_slot_counter"
CONF_ON_IMAGE_DISPLAY = "on_image_display"
CONF_ON_IMAGE_ERROR = "on_image_error"
# Map runtime_image's format string to the sendspin library's SendspinImageFormat enum.
_FORMAT_TO_SENDSPIN_ENUM = {
"JPEG": IMAGE_FORMAT_JPEG,
"PNG": IMAGE_FORMAT_PNG,
"BMP": IMAGE_FORMAT_BMP,
}
IMAGE_SOURCES = {
"ALBUM": IMAGE_SOURCE_ALBUM,
"ARTIST": IMAGE_SOURCE_ARTIST,
"NONE": IMAGE_SOURCE_NONE,
}
SendspinImage = sendspin_ns.class_(
"SendspinImage",
runtime_image.RuntimeImage,
cg.Component,
)
SendspinImageDisplayTrigger = sendspin_ns.class_(
"SendspinImageDisplayTrigger", automation.Trigger.template()
)
SendspinImageErrorTrigger = sendspin_ns.class_(
"SendspinImageErrorTrigger", automation.Trigger.template()
)
def _assign_slot_and_register(config: ConfigType) -> ConfigType:
"""Auto-assign a slot, validate the max count, and register the artwork preference with the hub."""
current = CORE.data.get(_SLOT_COUNTER_KEY, 0)
if current >= MAX_IMAGE_SLOTS:
raise cv.Invalid(
f"Too many Sendspin generic_image components. Maximum is {MAX_IMAGE_SLOTS}."
)
CORE.data[_SLOT_COUNTER_KEY] = current + 1
config[CONF_SLOT] = current
width, height = config[CONF_RESIZE]
register_artwork_preference(
{
CONF_SLOT: current,
CONF_SOURCE: config[CONF_SOURCE],
CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]],
CONF_WIDTH: width,
CONF_HEIGHT: height,
}
)
return config
CONFIG_SCHEMA = cv.All(
runtime_image.runtime_image_schema(SendspinImage).extend(
{
cv.GenerateID(): cv.declare_id(SendspinImage),
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Required(CONF_RESIZE): cv.dimensions,
cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum(
IMAGE_SOURCES, upper=True
),
cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
SendspinImageDisplayTrigger
),
}
),
cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
SendspinImageErrorTrigger
),
}
),
}
),
runtime_image.validate_runtime_image_settings,
cv.only_on_esp32,
_assign_slot_and_register,
)
async def to_code(config: ConfigType) -> None:
settings = await runtime_image.process_runtime_image_config(config)
add_metadata(
config[CONF_ID],
settings.width,
settings.height,
config[CONF_TYPE],
config[CONF_TRANSPARENCY],
)
var = cg.new_Pvariable(
config[CONF_ID],
settings.width,
settings.height,
settings.format_enum,
settings.image_type_enum,
settings.transparent,
settings.byte_order_big_endian,
settings.placeholder,
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
cg.add(var.set_slot(config[CONF_SLOT]))
cg.add(var.set_image_source(IMAGE_SOURCES[config[CONF_SOURCE]]))
for conf in config.get(CONF_ON_IMAGE_DISPLAY, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_IMAGE_ERROR, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
@@ -0,0 +1,66 @@
#include "sendspin_generic_image.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/core/log.h"
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.generic_image";
SendspinImage::SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format,
image::ImageType type, image::Transparency transparency, bool is_big_endian,
image::Image *placeholder)
: runtime_image::RuntimeImage(format, type, transparency, placeholder, is_big_endian, fixed_width, fixed_height) {}
// THREAD CONTEXT: Main loop. The decode callback registered below fires on the artwork
// decode thread; the display and clear callbacks fire on the main loop.
void SendspinImage::setup() {
this->parent_->add_image_decode_callback(
[this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) {
if (slot == this->slot_)
this->on_decode_(data, length);
});
this->parent_->add_image_display_callback([this](uint8_t slot) {
if (slot == this->slot_)
this->on_display_();
});
this->parent_->add_image_clear_callback([this](uint8_t slot) {
if (slot == this->slot_)
this->on_clear_();
});
}
// THREAD CONTEXT: Dedicated artwork decode thread (via SendspinHub's decode callback).
// Decode synchronously into the back buffer; heavy CPU work is allowed here.
void SendspinImage::on_decode_(const uint8_t *data, size_t length) {
this->begin_decode(length);
size_t total_consumed = 0;
while (total_consumed < length) {
int consumed = this->feed_data(const_cast<uint8_t *>(data) + total_consumed, length - total_consumed);
if (consumed < 0) {
ESP_LOGE(TAG, "Error decoding image data at offset %zu", total_consumed);
this->image_error_callback_.call();
return;
}
total_consumed += consumed;
}
if (!this->end_decode()) {
ESP_LOGE(TAG, "Failed to finalize image after decoding");
this->image_error_callback_.call();
return;
}
}
// THREAD CONTEXT: Main loop (fired once the server display timestamp is reached)
void SendspinImage::on_display_() { this->image_display_callback_.call(); }
// THREAD CONTEXT: Main loop
void SendspinImage::on_clear_() {
this->release();
this->image_display_callback_.call();
}
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,63 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/components/runtime_image/runtime_image.h"
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/core/automation.h"
#include <sendspin/artwork_role.h>
namespace esphome::sendspin_ {
class SendspinImage : public SendspinChild, public runtime_image::RuntimeImage {
public:
SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format, image::ImageType type,
image::Transparency transparency, bool is_big_endian = false, image::Image *placeholder = nullptr);
void setup() override;
template<typename F> void add_on_image_display_callback(F &&callback) {
this->image_display_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_image_error_callback(F &&callback) {
this->image_error_callback_.add(std::forward<F>(callback));
}
void set_image_source(sendspin::SendspinImageSource source) { this->source_ = source; }
void set_slot(uint8_t slot) { this->slot_ = slot; }
protected:
// Artwork thread. Decodes encoded bytes synchronously; buffer is valid only for this call.
void on_decode_(const uint8_t *data, size_t length);
// Main loop thread. Trigger when art should be displayed.
void on_display_();
// Main loop thread. Releases the decoded image and refires the display trigger so listeners re-render.
void on_clear_();
LazyCallbackManager<void()> image_display_callback_{};
LazyCallbackManager<void()> image_error_callback_{};
sendspin::SendspinImageSource source_{sendspin::SendspinImageSource::ALBUM};
uint8_t slot_{0};
};
class SendspinImageDisplayTrigger : public Trigger<> {
public:
explicit SendspinImageDisplayTrigger(SendspinImage *parent) {
parent->add_on_image_display_callback([this]() { this->trigger(); });
}
};
class SendspinImageErrorTrigger : public Trigger<> {
public:
explicit SendspinImageErrorTrigger(SendspinImage *parent) {
parent->add_on_image_error_callback([this]() { this->trigger(); });
}
};
} // namespace esphome::sendspin_
#endif
@@ -37,6 +37,10 @@ void SendspinHub::setup() {
this->client_->set_network_provider(this);
this->client_->set_persistence_provider(this);
#ifdef USE_SENDSPIN_ARTWORK
this->client_->add_artwork(this->artwork_config_).set_listener(this);
#endif
#ifdef USE_SENDSPIN_CONTROLLER
this->controller_role_ = &this->client_->add_controller();
this->controller_role_->set_listener(this);
@@ -172,6 +176,20 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
// --- Sendspin role specific methods/overrides ---
#ifdef USE_SENDSPIN_ARTWORK
// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too
void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length,
sendspin::SendspinImageFormat format) {
this->artwork_image_decode_callbacks_.call(slot, data, length, format);
}
// THREAD CONTEXT: Main loop (fired from client_->loop() once the server display timestamp is reached)
void SendspinHub::on_image_display(uint8_t slot) { this->artwork_image_display_callbacks_.call(slot); }
// THREAD CONTEXT: Main loop (fired from client_->loop())
void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); }
#endif
#ifdef USE_SENDSPIN_CONTROLLER
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
@@ -13,6 +13,9 @@
#include <sendspin/config.h>
#include <sendspin/types.h>
#ifdef USE_SENDSPIN_ARTWORK
#include <sendspin/artwork_role.h>
#endif
#ifdef USE_SENDSPIN_CONTROLLER
#include <sendspin/controller_role.h>
#endif
@@ -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,20 @@ class SendspinHub final : public Component,
// --- Sendspin role specific methods ---
#ifdef USE_SENDSPIN_ARTWORK
void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; }
template<typename F> void add_image_decode_callback(F &&callback) {
this->artwork_image_decode_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_image_display_callback(F &&callback) {
this->artwork_image_display_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_image_clear_callback(F &&callback) {
this->artwork_image_clear_callbacks_.add(std::forward<F>(callback));
}
#endif
#ifdef USE_SENDSPIN_CONTROLLER
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
std::optional<bool> mute = std::nullopt);
@@ -171,6 +191,23 @@ class SendspinHub final : public Component,
// --- Sendspin role specific methods/overrides/member variables ---
#ifdef USE_SENDSPIN_ARTWORK
void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override;
void on_image_display(uint8_t slot) override;
void on_image_clear(uint8_t slot) override;
sendspin::ArtworkRoleConfig artwork_config_{};
// Callback fan-out to child components; they filter by slot as needed.
// decode and display fire from the library's artwork thread; clear fires from the main loop.
CallbackManager<void(uint8_t, const uint8_t *, size_t, sendspin::SendspinImageFormat)>
artwork_image_decode_callbacks_{};
CallbackManager<void(uint8_t)> artwork_image_display_callbacks_{};
CallbackManager<void(uint8_t)> artwork_image_clear_callbacks_{};
#endif
#ifdef USE_SENDSPIN_CONTROLLER
sendspin::ControllerRole *controller_role_{nullptr};
@@ -6,6 +6,13 @@
namespace esphome::xpt2046 {
static constexpr uint8_t XPT_READ_Z1 = 0xB0;
static constexpr uint8_t XPT_READ_Z2 = 0xC0;
static constexpr uint8_t XPT_READ_X = 0xD0;
static constexpr uint8_t XPT_READ_Y = 0x90;
static constexpr uint8_t XPT_ADC_ON = 0x01;
static constexpr uint8_t XPT_VREF_ON = 0x02;
static const char *const TAG = "xpt2046";
void XPT2046Component::setup() {
@@ -20,7 +27,7 @@ void XPT2046Component::setup() {
this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
this->spi_setup();
this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin
this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin
}
void XPT2046Component::update_touches() {
@@ -29,21 +36,22 @@ void XPT2046Component::update_touches() {
enable();
int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */);
int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */);
int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON);
int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON);
z_raw = touch_pressure_1 + 0xfff - touch_pressure_2;
ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw);
touch = (z_raw >= this->threshold_);
if (touch) {
read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy
data[0] = this->read_adc_(0x91 /* Y */);
data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements
data[2] = this->read_adc_(0x91 /* Y */);
data[3] = this->read_adc_(0xD1 /* X */);
data[4] = this->read_adc_(0x91 /* Y */);
read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy
// make 3 x-y measurements
data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON);
data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON);
data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
}
data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down
data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down
disable();
@@ -95,15 +103,16 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v
return reta;
}
int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT
uint8_t data[2];
int16_t XPT2046Component::read_adc_(uint8_t ctrl) {
uint8_t data[3];
this->write_byte(ctrl);
delay(1);
data[0] = this->read_byte();
data[1] = this->read_byte();
data[0] = ctrl;
data[1] = 0;
data[2] = 0;
return ((data[0] << 8) | data[1]) >> 3;
this->transfer_array(data, sizeof(data));
return ((data[1] << 8) | data[2]) >> 3;
}
} // namespace esphome::xpt2046
+1 -1
View File
@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.6.0b2"
__version__ = "2026.7.0-dev"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (
+1 -1
View File
@@ -1,6 +1,6 @@
pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.16 # also change in .pre-commit-config.yaml when updating
ruff==0.15.17 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
+9
View File
@@ -951,6 +951,15 @@ def convert(schema, config_var, path):
elif schema_type == "enum":
config_var[S_TYPE] = "enum"
config_var["values"] = dict.fromkeys(list(data.keys()))
elif schema_type == "variant_enum":
# Per-variant enum (e.g. psram mode/speed): each value carries the
# list of variants that accept it so clients can filter to the
# user's selected variant. Additive to the plain enum format —
# consumers that ignore the metadata still see every option.
config_var[S_TYPE] = "enum"
config_var["values"] = {
value: {"variants": variants} for value, variants in data.items()
}
elif schema_type == "maybe":
# maybe_simple_value: either a scalar shorthand (mapped to the key in
# data[1]) or the full wrapped schema. The wrapped schema is usually a
+38
View File
@@ -104,6 +104,44 @@ def set_component_config() -> Callable[[str, Any], None]:
return setter
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""Set the ESP32 variant to the first one on which all the given pins are valid.
For ESP32 only, since the other platforms do not have variants. The core
configuration must already have been set up for an ESP32 target.
Using local imports to avoid importing when ESP32 is not the target.
"""
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.const import CONF_INPUT, CONF_OUTPUT
from esphome.pins import gpio_pin_schema
def chooser(pins: list) -> None:
for variant in VARIANTS:
try:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
for pin in pins:
if pin is not None:
pin = gpio_pin_schema(
{
CONF_INPUT: True,
CONF_OUTPUT: True,
},
internal=True,
)(pin)
validate_gpio_pin(pin)
return
except cv.Invalid:
continue
raise cv.Invalid(
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
)
yield chooser
@pytest.fixture
def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]:
"""Return a function to get absolute paths relative to the component's fixtures directory."""
@@ -0,0 +1,24 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: ssd1677
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO23
cs_pin: GPIO5
enable_pin:
- GPIO25
- GPIO26
dimensions:
width: 200
height: 200
@@ -0,0 +1,156 @@
"""Tests for display metadata created by the epaper_spi component."""
from collections.abc import Callable
from pathlib import Path
from typing import Any
from esphome import config_validation as cv
from esphome.components.display import get_all_display_metadata, get_display_metadata
from esphome.components.epaper_spi.display import CONFIG_SCHEMA
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _base_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid ssd1677 config, allowing field overrides."""
config: ConfigType = {
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"dimensions": {"width": 200, "height": 300},
}
config.update(overrides)
return config
def test_metadata_dimensions_and_defaults(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Metadata picks up explicit dimensions and epaper_spi defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config())
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 200
assert meta.height == 300
# epaper_spi always reports full hardware rotation
assert meta.has_hardware_rotation is True
# epaper_spi does not declare a byte order
assert meta.byte_order is cv.UNDEFINED
assert meta.draw_rounding == 0
# no drawing methods configured -> no writer
assert meta.has_writer is False
def test_metadata_default_dimensions_from_model(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""A model with built-in dimensions reports those without explicit dimensions."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
# waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions
config = CONFIG_SCHEMA(
{
"id": "wave_display",
"model": "waveshare-4.26in",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
}
)
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 800
assert meta.height == 480
def test_metadata_has_writer_with_auto_clear(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""A display with auto_clear_enabled reports has_writer=True."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.has_writer is True
def test_metadata_rotation_propagated(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The configured rotation is stored in the metadata."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config(rotation=90))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.rotation == 90
def test_metadata_multiple_displays_independent(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Each display gets its own independent metadata entry."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300}))
CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480}))
all_meta = get_all_display_metadata()
assert all_meta["disp_a"].width == 200
assert all_meta["disp_a"].height == 300
assert all_meta["disp_b"].width == 400
assert all_meta["disp_b"].height == 480
def test_metadata_via_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Full code generation registers metadata for the configured display."""
generate_main(component_config_path("enable_pin_test.yaml"))
all_meta = get_all_display_metadata()
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
# enable_pin_test.yaml: ssd1677 at 200x200
assert meta.width == 200
assert meta.height == 200
assert meta.has_hardware_rotation is True
+151 -39
View File
@@ -1,6 +1,8 @@
"""Tests for epaper_spi configuration validation."""
from collections.abc import Callable
from pathlib import Path
import re
from typing import Any
import pytest
@@ -11,17 +13,13 @@ from esphome.components.epaper_spi.display import (
FINAL_VALIDATE_SCHEMA,
MODELS,
)
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.const import (
CONF_BUSY_PIN,
CONF_CS_PIN,
CONF_DC_PIN,
CONF_DIMENSIONS,
CONF_ENABLE_PIN,
CONF_HEIGHT,
CONF_INIT_SEQUENCE,
CONF_RESET_PIN,
@@ -31,6 +29,30 @@ from esphome.const import (
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
# Pin options whose values must be valid on the chosen ESP32 variant.
_PIN_CONF_KEYS = (
CONF_CS_PIN,
CONF_DC_PIN,
CONF_RESET_PIN,
CONF_BUSY_PIN,
CONF_ENABLE_PIN,
)
def _pins_for(model: Any, config: ConfigType) -> list:
"""Collect every GPIO the config will actually use (model defaults or injected)."""
pins: list = []
for key in _PIN_CONF_KEYS:
# An injected value in the config takes precedence over the model default.
value = config[key] if key in config else model.get_default(key)
if not value: # get_default returns False for pins the model omits
continue
if isinstance(value, list):
pins.extend(value)
else:
pins.append(value)
return pins
def run_schema_validation(
config: ConfigType, with_final_validate: bool = False
@@ -90,29 +112,20 @@ def test_basic_configuration_errors(
def test_all_predefined_models(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
choose_variant_with_pins: Callable[[list], None],
) -> None:
"""Test all predefined epaper models validate successfully with appropriate defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
# Test all models, providing default values where necessary
for name, model in MODELS.items():
# SEEED models are designed for ESP32-S3 hardware
if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
else:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = {"model": name}
# Add ID field
@@ -141,6 +154,10 @@ def test_all_predefined_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# Select an ESP32 variant on which all of this model's pins are valid
# (some models default to high-numbered pins only present on the S3).
choose_variant_with_pins(_pins_for(model, config))
run_schema_validation(config)
@@ -152,27 +169,19 @@ def test_individual_models(
model_name: str,
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
choose_variant_with_pins: Callable[[list], None],
) -> None:
"""Test each epaper model individually to ensure it validates correctly."""
# SEEED models are designed for ESP32-S3 hardware
if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
else:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
model = MODELS[model_name]
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
model = MODELS[model_name]
config: dict[str, Any] = {"model": model_name, "id": "test_display"}
# Add required fields based on model defaults
@@ -195,6 +204,10 @@ def test_individual_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# Select an ESP32 variant on which all of this model's pins are valid
# (some models default to high-numbered pins only present on the S3).
choose_variant_with_pins(_pins_for(model, config))
# This should not raise any exceptions
run_schema_validation(config)
@@ -342,3 +355,102 @@ def test_busy_pin_input_mode_ssd1677(
reset_pin_config = result[CONF_RESET_PIN]
assert "mode" in reset_pin_config
assert reset_pin_config["mode"]["output"] is True
def test_enable_pin_single(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test that a single enable_pin is accepted and normalised to a list of output pins."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
result = run_schema_validation(
{
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"enable_pin": 25,
"dimensions": {
"width": 200,
"height": 200,
},
}
)
# A single pin is normalised to a list by cv.ensure_list
assert CONF_ENABLE_PIN in result
enable_pins = result[CONF_ENABLE_PIN]
assert isinstance(enable_pins, list)
assert len(enable_pins) == 1
# enable pins are configured as outputs
assert enable_pins[0]["mode"]["output"] is True
def test_enable_pin_multiple(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test that a list of enable_pins is accepted."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
result = run_schema_validation(
{
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"enable_pin": [25, 26],
"dimensions": {
"width": 200,
"height": 200,
},
}
)
assert CONF_ENABLE_PIN in result
enable_pins = result[CONF_ENABLE_PIN]
assert isinstance(enable_pins, list)
assert len(enable_pins) == 2
assert all(pin["mode"]["output"] is True for pin in enable_pins)
def test_enable_pin_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that enable_pins are wired up in the generated C++ code."""
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
# Derive the auto-generated pin variable names from the set_pin() lines
# rather than hard-coding them, so the test does not break when unrelated
# codegen details shift the generated IDs.
def pin_var_for(gpio_num: int) -> str:
match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp)
assert match is not None, (
f"GPIO_NUM_{gpio_num} pin not set up in generated code"
)
return match.group(1)
pin_25 = pin_var_for(25)
pin_26 = pin_var_for(26)
# Both pin objects must be passed to the display via set_enable_pins() as a
# std::vector initializer list, in the configured order.
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
+1 -38
View File
@@ -1,16 +1,10 @@
"""Tests for mpip_spi configuration validation."""
from collections.abc import Callable, Generator
from unittest import mock
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.const import CONF_INPUT, CONF_OUTPUT
from esphome.core import CORE
from esphome.pins import gpio_pin_schema
# choose_variant_with_pins is provided by the shared parent conftest.
@pytest.fixture(autouse=True)
@@ -21,34 +15,3 @@ def mock_spi_final_validate():
return_value=lambda config: None,
):
yield
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""
Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms
do not have variants.
"""
def chooser(pins: list) -> None:
for variant in VARIANTS:
try:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
for pin in pins:
if pin is not None:
pin = gpio_pin_schema(
{
CONF_INPUT: True,
CONF_OUTPUT: True,
},
internal=True,
)(pin)
validate_gpio_pin(pin)
return
except cv.Invalid:
continue
raise cv.Invalid(
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
)
yield chooser
+48
View File
@@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants(
FINAL_VALIDATE_SCHEMA(config)
def test_psram_applies_single_mode_default(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On a single-mode variant the omitted mode/speed fall back to defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
config = CONFIG_SCHEMA({})
assert config["mode"] == "quad"
assert config["speed"] == "40MHZ"
assert config["disabled"] is False
assert config["ignore_not_found"] is True
def test_psram_requires_mode_on_multi_mode_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A variant with multiple modes requires an explicit mode selection."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"):
CONFIG_SCHEMA({})
def test_psram_rejects_mode_invalid_for_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A mode not supported by the active variant is rejected by the schema."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"):
CONFIG_SCHEMA({"mode": "octal"})
def _setup_psram_final_validation_test(
esp32_config: dict,
set_core_config: SetCoreConfigCallable,
@@ -0,0 +1,5 @@
# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses
# octal; this exercises the other branch of the per-variant mode enum (quad) and
# lets speed fall back to its 40MHz default.
psram:
mode: quad
@@ -0,0 +1,4 @@
# Config-only: with no options the single-mode ESP32 resolves mode -> quad and
# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here,
# so this only runs through `esphome config`.
psram:
@@ -0,0 +1,4 @@
# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz).
# With no options it resolves mode -> hex and speed -> 20MHz, exercising the
# P4-specific default branch of the per-variant enums.
psram:
@@ -0,0 +1,35 @@
<<: !include common.yaml
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
display:
- platform: ili9xxx
spi_id: spi_bus
id: main_lcd
model: ili9342
cs_pin: 20
dc_pin: 13
reset_pin: 21
invert_colors: true
lambda: |-
it.fill(Color(0, 0, 0));
it.image(0, 0, id(album_art));
generic_image:
- platform: sendspin
id: album_art
format: JPEG
type: RGB565
resize: 240x240
source: ALBUM
on_image_display:
- logger.log: "Album art displayed"
on_image_error:
- logger.log: "Album art error"
- platform: sendspin
id: artist_art
format: PNG
type: RGB565
resize: 96x96
source: ARTIST
@@ -0,0 +1 @@
<<: !include common-generic_image.yaml
@@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None:
assert "foo" in config_var["schema"]["config_vars"]
def test_convert_emits_variant_enum() -> None:
"""A per-variant enum is dumped with each value tagged by its variants."""
from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANT_ESP32S3,
variant_filtered_enum,
)
validator = variant_filtered_enum(
{VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")},
lower=True,
)
config_var: dict = {}
_bls.convert(validator, config_var, "/test")
assert config_var["type"] == "enum"
assert config_var["values"] == {
"quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]},
"octal": {"variants": [VARIANT_ESP32S3]},
}
def test_convert_keys_emits_heuristic_sensitive_marker() -> None:
converted: dict = {}
_bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root")