mirror of
https://github.com/esphome/esphome.git
synced 2026-10-21 21:36:14 +00:00
[epaper_spi] Only compile the driver sources the configured models need (#21228)
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user