mirror of
https://github.com/esphome/esphome.git
synced 2026-07-09 16:35:35 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f874b2749a | |||
| 963465a0a6 | |||
| 1ee49720c7 | |||
| c1a7a8ff55 | |||
| f1fd5f2f49 | |||
| 8d2c2e6adc | |||
| efebea3296 | |||
| e191fc5d47 | |||
| 1e5771a3fa | |||
| 5b7f8cf90d | |||
| 35e5c7c7c3 | |||
| 10ce6024bf | |||
| bf6c8568d3 | |||
| 88084f2ec7 | |||
| 6ef35b6d3d | |||
| 28dd935359 | |||
| 750cf1995b | |||
| a56b6d8993 | |||
| 6a527c7efc | |||
| e0b0c1e8d3 | |||
| 3786df71e5 | |||
| 4cd3fe8fa3 | |||
| 39fc31d1b9 | |||
| 16d47fb3ce | |||
| 0c8155e590 | |||
| c10916fe76 | |||
| aa34da0a55 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user