[epaper_spi] Only compile the driver sources the configured models need (#21228)

This commit is contained in:
Jesse Hills
2026-10-21 15:39:52 +13:00
committed by GitHub
parent 41c3bbff25
commit d25a925a9b
18 changed files with 313 additions and 2 deletions
+37
View File
@@ -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
)
+6 -1
View File
@@ -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:
@@ -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:
"""
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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,
@@ -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,
@@ -11,6 +11,11 @@ from . import EpaperModel
class WaveshareModel(EpaperModel):
source_files = (
"epaper_spi_mono.cpp",
"epaper_waveshare.cpp",
)
def __init__(
self,
name: str,
@@ -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)
@@ -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)
@@ -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)
@@ -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 <Parent>' 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",
}