From d25a925a9b2d2abd714c6bf913255ee71e8951e3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:39:52 +1300 Subject: [PATCH] [epaper_spi] Only compile the driver sources the configured models need (#21228) --- esphome/components/epaper_spi/__init__.py | 37 ++++ esphome/components/epaper_spi/display.py | 7 +- .../components/epaper_spi/models/__init__.py | 15 +- .../components/epaper_spi/models/inkplate2.py | 2 + .../epaper_spi/models/inkplate6color.py | 5 + .../components/epaper_spi/models/jd79660.py | 2 + .../epaper_spi/models/spectra_e6.py | 5 + .../components/epaper_spi/models/ssd1677.py | 10 + .../components/epaper_spi/models/ssd1683.py | 5 + .../components/epaper_spi/models/t133a01.py | 2 + .../components/epaper_spi/models/uc8151.py | 2 + .../components/epaper_spi/models/uc8179.py | 5 + .../components/epaper_spi/models/uc8279.py | 5 + .../components/epaper_spi/models/waveshare.py | 5 + .../epaper_spi/models/waveshare_b.py | 5 + .../epaper_spi/models/waveshare_bwr.py | 2 + .../components/epaper_spi/models/weact_bwr.py | 2 + .../epaper_spi/test_source_filter.py | 199 ++++++++++++++++++ 18 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/epaper_spi/test_source_filter.py diff --git a/esphome/components/epaper_spi/__init__.py b/esphome/components/epaper_spi/__init__.py index da3685c263e..6aa8241b21c 100644 --- a/esphome/components/epaper_spi/__init__.py +++ b/esphome/components/epaper_spi/__init__.py @@ -1,2 +1,39 @@ +from collections.abc import Iterable +from dataclasses import dataclass, field +from pathlib import Path + +from esphome.core import CORE + CODEOWNERS = ["@esphome/core"] DOMAIN = "epaper_spi" + +# The source file that every model needs. +BASE_SOURCE_FILE = "epaper_spi.cpp" + + +@dataclass +class EpaperSpiData: + needed_sources: set[str] = field(default_factory=set) + + +def _get_data() -> EpaperSpiData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = EpaperSpiData() + return CORE.data[DOMAIN] + + +def register_sources(sources: Iterable[str]) -> None: + """Record the source files that a configured model's C++ driver class needs.""" + _get_data().needed_sources.update(sources) + + +def FILTER_SOURCE_FILES() -> list[str]: # noqa: N802 + """Exclude the driver sources that no configured model needs.""" + if not (needed := _get_data().needed_sources): + return [] + keep = needed | {BASE_SOURCE_FILE} + return sorted( + path.name + for path in Path(__file__).parent.glob("*.cpp") + if path.name not in keep + ) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 1f4f1e057c1..f799e60fc0a 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -42,7 +42,11 @@ from esphome.const import ( ) from esphome.cpp_generator import RawExpression -from . import models +from . import ( # pylint: disable=unused-import + FILTER_SOURCE_FILES, # noqa: F401 + models, + register_sources, +) AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] @@ -219,6 +223,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): model = MODELS[config[CONF_MODEL]] + register_sources(model.source_files) init_sequence = config.get(CONF_INIT_SEQUENCE) if init_sequence is None: diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index ba8ebb6c873..50aaca319ab 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -18,17 +18,24 @@ class EpaperModel: # Whether the driver manages chip-select itself instead of via the SPI bus. manages_cs: bool = False + # The .cpp files this model's driver class needs: its own and those of every parent class + # except epaper_spi.cpp, which is always built. Subclasses override this next to their class_name. + source_files: tuple[str, ...] = () + def __init__( self, name: str, class_name: str, initsequence=(), + source_files: tuple[str, ...] | None = None, **defaults, ): name = name.upper() self.name = name self.class_name = class_name self.initsequence = initsequence + if source_files is not None: + self.source_files = source_files self.defaults = defaults EpaperModel.models[name] = self @@ -112,7 +119,13 @@ class EpaperModel: initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() defaults.update(kwargs) - return self.__class__(name, initsequence=tuple(initsequence), **defaults) + model = self.__class__(name, initsequence=tuple(initsequence), **defaults) + # Subclass constructors do not all take these, so carry them over unless overridden + if "class_name" not in kwargs: + model.class_name = self.class_name + if "source_files" not in kwargs: + model.source_files = self.source_files + return model def check_requirements(self) -> None: """ diff --git a/esphome/components/epaper_spi/models/inkplate2.py b/esphome/components/epaper_spi/models/inkplate2.py index f1a952ce105..91974027cab 100644 --- a/esphome/components/epaper_spi/models/inkplate2.py +++ b/esphome/components/epaper_spi/models/inkplate2.py @@ -4,6 +4,8 @@ from . import EpaperModel class Inkplate2Model(EpaperModel): + source_files = ("epaper_spi_inkplate2.cpp",) + def __init__(self, name, class_name="EPaperInkplate2", **kwargs): super().__init__(name, class_name, **kwargs) diff --git a/esphome/components/epaper_spi/models/inkplate6color.py b/esphome/components/epaper_spi/models/inkplate6color.py index 33fecab8489..30338127be7 100644 --- a/esphome/components/epaper_spi/models/inkplate6color.py +++ b/esphome/components/epaper_spi/models/inkplate6color.py @@ -6,6 +6,11 @@ from . import EpaperModel class Inkplate6ColorModel(EpaperModel): + source_files = ( + "epaper_spi_4bpp.cpp", + "epaper_spi_inkplate6color.cpp", + ) + def __init__(self, name, class_name="EPaperInkplate6Color", **kwargs): super().__init__(name, class_name, **kwargs) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index 783dc8953a5..8965632cb3a 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -9,6 +9,8 @@ from . import EpaperModel class JD79660(EpaperModel): + source_files = ("epaper_spi_jd79660.cpp",) + def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs): # Only a fast_update sequence lets the driver do anything but a full refresh kwargs.setdefault("partial_update", fast_update is not None) diff --git a/esphome/components/epaper_spi/models/spectra_e6.py b/esphome/components/epaper_spi/models/spectra_e6.py index 58015f486ee..35246af9fae 100644 --- a/esphome/components/epaper_spi/models/spectra_e6.py +++ b/esphome/components/epaper_spi/models/spectra_e6.py @@ -4,6 +4,11 @@ from . import EpaperModel class SpectraE6(EpaperModel): + source_files = ( + "epaper_spi_4bpp.cpp", + "epaper_spi_spectra_e6.cpp", + ) + def __init__(self, name, class_name="EPaperSpectraE6", **defaults): super().__init__(name, class_name, **defaults) diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index 2249529611e..803eff01f9f 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -14,6 +14,11 @@ MONOCHROME = "monochrome" class SSD1677(EpaperModel): + source_files = ( + "epaper_spi_mono.cpp", + "epaper_spi_ssd1677.cpp", + ) + def __init__( self, name: str, @@ -116,6 +121,11 @@ seeed_sticky = ssd1677.extend( seeed_sticky.extend( "seeed-reterminal-sticky-gray4", class_name="EPaperSSD1677Gray4", + source_files=( + "epaper_spi_mono.cpp", + "epaper_spi_ssd1677.cpp", + "epaper_spi_ssd1677_gray4.cpp", + ), border_waveform=0x00, partial_update=MONOCHROME, # each plane byte is built from two whole buffer bytes diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py index eb51ba1f2af..f62a7082190 100644 --- a/esphome/components/epaper_spi/models/ssd1683.py +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -4,6 +4,11 @@ from . import EpaperModel class SSD1683(EpaperModel): + source_files = ( + "epaper_spi_mono.cpp", + "epaper_spi_ssd1683.cpp", + ) + def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults): defaults[CONF_DATA_RATE] = data_rate defaults.setdefault("partial_update", True) diff --git a/esphome/components/epaper_spi/models/t133a01.py b/esphome/components/epaper_spi/models/t133a01.py index 0a57b957953..76c79f8e093 100644 --- a/esphome/components/epaper_spi/models/t133a01.py +++ b/esphome/components/epaper_spi/models/t133a01.py @@ -22,6 +22,8 @@ CONF_CS1_PIN = "cs1_pin" class T133A01Model(EpaperModel): """EpaperModel subclass for T133A01-based 6-color e-paper displays.""" + source_files = ("epaper_spi_t133a01.cpp",) + # The driver drives CS and CS1 directly for the dual-CS protocol. manages_cs = True diff --git a/esphome/components/epaper_spi/models/uc8151.py b/esphome/components/epaper_spi/models/uc8151.py index 49d0d6818b4..520dc82e144 100644 --- a/esphome/components/epaper_spi/models/uc8151.py +++ b/esphome/components/epaper_spi/models/uc8151.py @@ -22,6 +22,8 @@ MAX_GATES = 296 class UC8151(EpaperModel): """EpaperModel class for black/white/red displays using the UC8151D controller.""" + source_files = ("epaper_waveshare_bwr.cpp",) + def __init__(self, name: str, **defaults: Any) -> None: super().__init__(name, "EPaperWaveshareBWR", **defaults) diff --git a/esphome/components/epaper_spi/models/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py index 84b736081fb..404a14ab102 100644 --- a/esphome/components/epaper_spi/models/uc8179.py +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -24,6 +24,11 @@ from . import EpaperModel, ultrachip_resolution class UC8179(EpaperModel): """EpaperModel class for monochrome displays using the UC8179 controller.""" + source_files = ( + "epaper_spi_ultrachip.cpp", + "epaper_spi_uc8179.cpp", + ) + def __init__( self, name: str, diff --git a/esphome/components/epaper_spi/models/uc8279.py b/esphome/components/epaper_spi/models/uc8279.py index deb487883de..1a4dcfff072 100644 --- a/esphome/components/epaper_spi/models/uc8279.py +++ b/esphome/components/epaper_spi/models/uc8279.py @@ -19,6 +19,11 @@ from . import EpaperModel, ultrachip_resolution class UC8279(EpaperModel): + source_files = ( + "epaper_spi_ultrachip.cpp", + "epaper_spi_uc8279.cpp", + ) + def __init__( self, name: str, diff --git a/esphome/components/epaper_spi/models/waveshare.py b/esphome/components/epaper_spi/models/waveshare.py index 7594c9d0d0d..e7f3e50885e 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -11,6 +11,11 @@ from . import EpaperModel class WaveshareModel(EpaperModel): + source_files = ( + "epaper_spi_mono.cpp", + "epaper_waveshare.cpp", + ) + def __init__( self, name: str, diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py index 67a0cf5f233..1342eff585e 100644 --- a/esphome/components/epaper_spi/models/waveshare_b.py +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -2,6 +2,11 @@ from . import EpaperModel class WaveshareB(EpaperModel): + source_files = ( + "epaper_waveshare_b.cpp", + "epaper_weact_3c.cpp", + ) + def __init__(self, name, **defaults): super().__init__(name, "EpaperWaveshareB", **defaults) diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py index 18c673fbeee..0e239bbd3ef 100644 --- a/esphome/components/epaper_spi/models/waveshare_bwr.py +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -14,6 +14,8 @@ from . import EpaperModel class WaveshareBWR(EpaperModel): """EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller.""" + source_files = ("epaper_waveshare_bwr.cpp",) + def __init__(self, name, **defaults): super().__init__(name, "EPaperWaveshareBWR", **defaults) diff --git a/esphome/components/epaper_spi/models/weact_bwr.py b/esphome/components/epaper_spi/models/weact_bwr.py index 21a015b08d9..8e2d04de94a 100644 --- a/esphome/components/epaper_spi/models/weact_bwr.py +++ b/esphome/components/epaper_spi/models/weact_bwr.py @@ -15,6 +15,8 @@ from . import EpaperModel class WeActBWR(EpaperModel): """Base EpaperModel class for WeAct Black/White/Red displays using SSD1683 controller.""" + source_files = ("epaper_weact_3c.cpp",) + def __init__(self, name, **defaults): super().__init__(name, "EPaperWeAct3C", **defaults) diff --git a/tests/component_tests/epaper_spi/test_source_filter.py b/tests/component_tests/epaper_spi/test_source_filter.py new file mode 100644 index 00000000000..a3cf3f8d725 --- /dev/null +++ b/tests/component_tests/epaper_spi/test_source_filter.py @@ -0,0 +1,199 @@ +"""Tests for the epaper_spi source file filtering.""" + +from collections.abc import Callable +from pathlib import Path +import re +from typing import Any + +import pytest + +from esphome.components import epaper_spi +from esphome.components.epaper_spi import ( + BASE_SOURCE_FILE, + DOMAIN, + FILTER_SOURCE_FILES, + register_sources, +) +from esphome.components.epaper_spi.models import EpaperModel +from esphome.config import iter_components +from esphome.core import CORE + +COMPONENT_DIR = Path(epaper_spi.__file__).parent + +TWO_MODELS_YAML = """ +esphome: + name: test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + +psram: + mode: octal + speed: 80MHz + +spi: + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + id: display_a + model: seeed-reterminal-sticky-gray4 + - platform: epaper_spi + id: display_b + model: seeed-reterminal-e1001 +""" + + +def _resource_names() -> set[str]: + """Names of the epaper_spi sources the build would copy, merged over all manifests.""" + return { + res.resource + for _, component in iter_components(CORE.config) + for res in component.resources + if res.package == "esphome.components.epaper_spi" + } + + +def _cpp_names(names: set[str]) -> set[str]: + return {name for name in names if name.endswith(".cpp")} + + +FIX_HINT = ( + "Set source_files on the model in esphome/components/epaper_spi/models/<...> to include " + "its class's .cpp and every parent class's .cpp, excluding epaper_spi.cpp." +) + +CLASS_RE = re.compile(r"^class\s+(\w+)\s+(?:final\s+)?:\s*public\s+(\w+)", re.MULTILINE) + + +def _class_table() -> dict[str, tuple[str, str]]: + """Map each driver class to its header stem and parent class, read from the headers.""" + table: dict[str, tuple[str, str]] = {} + for header in COMPONENT_DIR.glob("*.h"): + for name, parent in CLASS_RE.findall(header.read_text()): + table[name] = (header.stem, parent) + return table + + +def _expected_sources(class_name: str, table: dict[str, tuple[str, str]]) -> set[str]: + """The .cpp files of a class and its parents, up to but excluding epaper_spi.cpp.""" + sources: set[str] = set() + current = class_name + while current != "EPaperBase": + assert current in table, ( + f"No 'class {current} : public ' declaration found in the headers " + f"in {COMPONENT_DIR}. {FIX_HINT}" + ) + stem, current = table[current] + sources.add(f"{stem}.cpp") + return sources - {BASE_SOURCE_FILE} + + +@pytest.mark.parametrize("model_name", sorted(EpaperModel.models)) +def test_model_source_files_match_headers(model_name: str) -> None: + """A model must list exactly the sources its class hierarchy needs.""" + model = EpaperModel.models[model_name] + + expected = _expected_sources(model.class_name, _class_table()) + + assert expected == set(model.source_files), ( + f"Model {model_name} ({model.class_name}) needs {sorted(expected)} but lists " + f"{sorted(model.source_files)}. {FIX_HINT}" + ) + + +def test_every_source_file_is_claimed_by_a_model() -> None: + on_disk = {p.name for p in COMPONENT_DIR.glob("*.cpp")} - {BASE_SOURCE_FILE} + claimed = {f for m in EpaperModel.models.values() for f in m.source_files} + + assert not on_disk - claimed, ( + f"Source files no model lists: {sorted(on_disk - claimed)}. {FIX_HINT}" + ) + + +def test_listed_source_files_exist() -> None: + on_disk = {p.name for p in COMPONENT_DIR.glob("*.cpp")} + listed = {f for m in EpaperModel.models.values() for f in m.source_files} + + assert not listed - on_disk, ( + f"Listed source files that do not exist: {sorted(listed - on_disk)}. {FIX_HINT}" + ) + + +def test_missing_class_declaration_is_reported() -> None: + with pytest.raises(AssertionError, match="No 'class EPaperGone"): + _expected_sources("EPaperGone", _class_table()) + + +def test_extend_keeps_class_name_and_source_files() -> None: + + class _Model(EpaperModel): + def __init__(self, name: str, **kwargs: Any) -> None: + super().__init__(name, "EPaperX", **kwargs) + + base = _Model("test-extend-base", source_files=("x.cpp",)) + try: + assert base.extend("test-extend-child").source_files == ("x.cpp",) + assert EpaperModel.models["TEST-EXTEND-CHILD"].class_name == "EPaperX" + override = base.extend("test-extend-over", source_files=("y.cpp",)) + assert override.source_files == ("y.cpp",) + finally: + for key in ("TEST-EXTEND-BASE", "TEST-EXTEND-CHILD", "TEST-EXTEND-OVER"): + EpaperModel.models.pop(key, None) + + +def test_filter_excludes_nothing_when_no_sources_recorded() -> None: + assert FILTER_SOURCE_FILES() == [] + + +def test_filter_excludes_unrecorded_sources() -> None: + register_sources(["epaper_spi_jd79660.cpp"]) + + excluded = FILTER_SOURCE_FILES() + + assert excluded == sorted(excluded) + assert "epaper_spi_jd79660.cpp" not in excluded + assert BASE_SOURCE_FILE not in excluded + assert "epaper_spi_mono.cpp" in excluded + + +def test_filter_data_lives_in_core_data() -> None: + register_sources(["epaper_spi_mono.cpp"]) + + assert CORE.data[DOMAIN].needed_sources == {"epaper_spi_mono.cpp"} + + +def test_sources_filtered_via_code_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The real to_code records the model, and every manifest honours the filter.""" + generate_main(component_config_path("ssd1677_gray4_test.yaml")) + + assert _cpp_names(_resource_names()) == { + BASE_SOURCE_FILE, + "epaper_spi_ssd1677_gray4.cpp", + "epaper_spi_ssd1677.cpp", + "epaper_spi_mono.cpp", + } + + +def test_sources_filtered_for_two_models( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + config_path = tmp_path / "two_models.yaml" + config_path.write_text(TWO_MODELS_YAML) + + generate_main(config_path) + + assert _cpp_names(_resource_names()) == { + BASE_SOURCE_FILE, + "epaper_spi_ssd1677_gray4.cpp", + "epaper_spi_ssd1677.cpp", + "epaper_spi_mono.cpp", + "epaper_spi_ultrachip.cpp", + "epaper_spi_uc8179.cpp", + }