From cf5ec2d27722ea19dbe88ea052b5aa02259c7c3a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:42:32 -0400 Subject: [PATCH 1/3] [ci] Remove the remaining max-parallel caps (#18762) --- .github/workflows/ci-docker.yml | 2 -- .github/workflows/ci.yml | 1 - 2 files changed, 3 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f3f7cb30eb..42be51cdd9 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -182,8 +182,6 @@ jobs: contents: read # actions/checkout to load the test configs strategy: fail-fast: false - # Modest cap so this smoke test leaves room on the shared runner pool. - max-parallel: 8 matrix: # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) # share a toolchain bundle, so esp32 is exercised on the base variant diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da0937555..a2762faa4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -946,7 +946,6 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: ${{ needs.determine-jobs.outputs.release-pr == 'true' && 32 || 16 }} matrix: batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: From 150f75d8f6f88f158f7df80349f943e4607da0fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 17:29:57 -0500 Subject: [PATCH 2/3] [esp8266] Add linker-script surgery and board build metadata for the native toolchain (#18555) --- esphome/components/esp8266/__init__.py | 40 +++-- esphome/components/esp8266/boards.py | 136 +++++++++++++++- esphome/components/esp8266/build_surgery.py | 123 +++++++++++++++ esphome/components/esp8266/const.py | 5 + .../components/esp8266/test_boards.py | 33 ++++ .../components/esp8266/test_build_surgery.py | 145 ++++++++++++++++++ 6 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 esphome/components/esp8266/build_surgery.py create mode 100644 tests/unit_tests/components/esp8266/test_boards.py create mode 100644 tests/unit_tests/components/esp8266/test_build_surgery.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 3dd9750c6f..75483c5293 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS, ESP8266_LD_SCRIPTS +from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script from .const import ( CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, @@ -44,6 +44,7 @@ from .const import ( KEY_BOARD, KEY_ESP8266, KEY_FLASH_SIZE, + KEY_LDSCRIPT, KEY_PIN_INITIAL_STATES, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, @@ -276,6 +277,31 @@ def check_rosetta() -> None: ) +def _choose_ld_script(board: str, ver: cv.Version) -> str | None: + """The flash ld to pin for this board and core, or None for cores + without ld-script support.""" + board_data = BOARDS[board] + ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]] + if ver <= cv.Version(2, 3, 0): + # No ld script support + return None + if ver <= cv.Version(2, 4, 2): + # Old ld script path; the modern per-board override names do not + # exist in this core's SDK, so the override cannot be honored. + # Substituting the size default would move _FS_end and the + # preferences sector, wiping flash-backed state on flash. + if KEY_LDSCRIPT in board_data: + raise EsphomeError( + f"Board {board} requires its {board_data[KEY_LDSCRIPT]} " + f"flash layout, which Arduino core {ver} cannot honor; " + "use a core newer than 2.4.2" + ) + return ld_scripts[0] + # A per-board override preserves a layout the board shipped with + # (see d1_wroom_02 in boards.py) + return board_ld_script(board_data) + + @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: cg.add(esp8266_ns.setup_preferences()) @@ -397,17 +423,7 @@ async def to_code(config: ConfigType) -> None: ) if config[CONF_BOARD] in BOARDS: - flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE] - ld_scripts = ESP8266_LD_SCRIPTS[flash_size] - - if ver <= cv.Version(2, 3, 0): - # No ld script support - ld_script = None - elif ver <= cv.Version(2, 4, 2): - # Old ld script path - ld_script = ld_scripts[0] - else: - ld_script = ld_scripts[1] + ld_script = _choose_ld_script(config[CONF_BOARD], ver) if ld_script is not None: cg.add_platformio_option("board_build.ldscript", ld_script) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 02bfa9e662..268c6b50aa 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -1,3 +1,5 @@ +from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT + FLASH_SIZE_1_MB = 2**20 FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2 FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB @@ -164,7 +166,8 @@ ESP8266_BOARD_PINS = { } """ -BOARDS generate with: +BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as +d1_wroom_02; the recipe emits only name/flash_size): git clone https://github.com/platformio/platform-espressif8266 for x in platform-espressif8266/boards/*.json; do @@ -182,6 +185,19 @@ for x in platform-espressif8266/boards/*.json; do done | sort """ + +def board_ld_script(board_data: dict) -> str: + """The modern (core > 2.4.2) flash linker script for a board: its + shipped-layout override, else the size default (the no-FS layout). + + Single source of truth for the PlatformIO pinning in __init__ and the + native generator's fallback, so the per-board rule cannot drift. + """ + return board_data.get( + KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1] + ) + + BOARDS = { "agruminolemon": { "name": "Lifely Agrumino Lemon v4", @@ -199,6 +215,15 @@ BOARDS = { "name": "WeMos D1 mini Pro", "flash_size": FLASH_SIZE_16_MB, }, + "d1_wroom_02": { + "name": "WeMos D1 ESP-WROOM-02", + "flash_size": FLASH_SIZE_2_MB, + # This board joined BOARDS after shipping with the manifest default + # (64 KB filesystem region); the flash-size default (2m.ld) would + # move _FS_end and with it the preferences sector, wiping existing + # devices' flash-backed state on update. + KEY_LDSCRIPT: "eagle.flash.2m64.ld", + }, "d1": { "name": "WEMOS D1 R1", "flash_size": FLASH_SIZE_4_MB, @@ -360,3 +385,112 @@ BOARDS = { "flash_size": FLASH_SIZE_4_MB, }, } + + +# Per-board variant dir + identity defines from platform-espressif8266 4.x +# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added +# by the generator. +# +# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the +# native toolchain mirrors; regenerate against the tag when bumping it): +# +# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 +# python3 - <<'EOF' +# import json, glob, os +# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): +# b = json.load(open(f))["build"] +# extra = b["extra_flags"] +# extra = extra.split() if isinstance(extra, str) else extra +# defines = [ +# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") +# ] +# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") +# board = os.path.splitext(os.path.basename(f))[0] +# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') +# EOF +ESP8266_BOARD_BUILD = { + "agruminolemon": { + "variant": "agruminolemonv4", + "defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",), + }, + "d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)}, + "d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)}, + "d1_mini_lite": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",), + }, + "d1_mini_pro": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",), + }, + "d1_wroom_02": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",), + }, + "eduinowifi": { + "variant": "eduinowifi", + "defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",), + }, + "esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)}, + "esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp_wroom_02": { + "variant": "nodemcu", + "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",), + }, + "espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)}, + "espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espmxdevkit": { + "variant": "esp8285", + "defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"), + }, + "espresso_lite_v1": { + "variant": "espresso_lite_v1", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",), + }, + "espresso_lite_v2": { + "variant": "espresso_lite_v2", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",), + }, + "gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)}, + "heltec_wifi_kit_8": { + "variant": "wifi_kit_8", + "defines": ("ARDUINO_wifi_kit_8",), + }, + "huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)}, + "inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)}, + "modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)}, + "nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)}, + "nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)}, + "oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)}, + "phoenix_v1": { + "variant": "phoenix_v1", + "defines": ("ARDUINO_ESP8266_PHOENIX_V1",), + }, + "phoenix_v2": { + "variant": "phoenix_v2", + "defines": ("ARDUINO_ESP8266_PHOENIX_V2",), + }, + "sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)}, + "sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)}, + "sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)}, + "sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)}, + "sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)}, + "wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)}, + "wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)}, + "wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)}, + "wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)}, + "wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)}, + "xinabox_cw01": { + "variant": "xinabox", + "defines": ("ARDUINO_ESP8266_XINABOX_CW01",), + }, +} diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py new file mode 100644 index 0000000000..eb6ed1b91b --- /dev/null +++ b/esphome/components/esp8266/build_surgery.py @@ -0,0 +1,123 @@ +"""Linker-script surgery shared with the native (PlatformIO-free) toolchain. + +These mirror the PlatformIO extra scripts in this directory +(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run +inside SCons and must stay self-contained. The native build generator applies +the same patches to the linker scripts it generates, so the logic lives here +as plain functions. Keep both in sync when changing either. +``segment_length`` is native-toolchain-only and has no script twin. +""" + +from __future__ import annotations + +from collections.abc import Collection +import hashlib +import re + +# Move the NONOS SDK wifi rate tables from flash to DRAM; see +# relocate_ratetable.py.script for the full background (NONOS SDK issue 320). +RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +_RATETABLE_COMMENT = ( + "/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" +) +# Match the whole line: "_data_start" is also a substring of the +# "_dport0_data_start" line in the earlier .dport0.data section +_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) + +# Memory sizes for testing mode (allow larger builds for CI component grouping) +TESTING_IRAM_SIZE = "0x200000" # 2MB +TESTING_DRAM_SIZE = "0x200000" # 2MB +TESTING_FLASH_SIZE = "0x2000000" # 32MB + + +def relocate_ratetable(content: str) -> str: + """Insert the rate-table DRAM rule into a generated common linker script.""" + if RATETABLE_RULE in content: + return content + match = _RATETABLE_ANCHOR.search(content) + if match is None: + raise RuntimeError( + "'_data_start' anchor not found in the generated linker script; " + "cannot apply wifi rate table DRAM relocation " + "(has the Arduino core linker script changed?)" + ) + insert_pos = match.end() + return ( + content[:insert_pos] + + f"\n {_RATETABLE_COMMENT}" + + f"\n {RATETABLE_RULE}" + + content[insert_pos:] + ) + + +_TESTING_SEGMENT_SIZES = { + "iram1_0_seg": TESTING_IRAM_SIZE, + "dram0_0_seg": TESTING_DRAM_SIZE, + "irom0_0_seg": TESTING_FLASH_SIZE, +} + + +def _segment_line_re(segment_name: str) -> re.Pattern[str]: + """The MEMORY line for one segment: `` : org = 0x..., len = 0x...``. + + Anchored to the start of the line so a name never matches inside a + longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size + group stops at the hex digits, leaving any ``ul`` suffix (from the + preprocessed ``MMU_IRAM_SIZE``) in place. + """ + return re.compile( + rf"(^[ \t]*{re.escape(segment_name)}" + r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" + r"(0x[0-9a-fA-F]+)", + re.MULTILINE, + ) + + +def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str: + """Enlarge the named memory segments so grouped CI test builds can link. + + Each caller passes the segments its linker script defines: the + generated common ld carries ``iram1_0_seg``; the flash ld carries + ``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match + raises, since a silently kept real memory limit would fail grouped + builds far from the cause. + """ + for segment in _TESTING_SEGMENT_SIZES: + if segment not in segments and _segment_line_re(segment).search(content): + raise RuntimeError( + f"Testing-mode segment {segment} is present in the linker " + "script but was not selected for patching" + ) + for segment in segments: + if segment not in _TESTING_SEGMENT_SIZES: + raise RuntimeError(f"Unknown testing-mode segment {segment!r}") + content, count = _segment_line_re(segment).subn( + rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content + ) + if count == 0: + raise RuntimeError( + f"Testing-mode memory patch failed: segment {segment} " + "not found (has the Arduino core linker script changed?)" + ) + return content + + +def segment_length(content: str, segment_name: str) -> int | None: + """Read a memory segment's length from linker script content. + + Returns None for an absent segment OR an unparsable line; callers must + treat None as "no usable budget" and warn (as the Flash summary does), + never as "no limit". + """ + match = _segment_line_re(segment_name).search(content) + return int(match.group(2), 16) if match else None + + +def surgery_fingerprint() -> str: + """Hash of this module's source; linker-script caches include it so an + edit here invalidates them.""" + import inspect + import sys + + source = inspect.getsource(sys.modules[__name__]) + return hashlib.sha256(source.encode()).hexdigest() diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 3e89ab989f..50f103ed2d 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -15,6 +15,11 @@ CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" +# Set for the native (non-PlatformIO) toolchain's build generator +KEY_FLASH_MODE = "flash_mode" +KEY_SCANF_FLOAT = "scanf_float" +# Per-board flash-layout override consumed by board_ld_script() +KEY_LDSCRIPT = "ldscript" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/tests/unit_tests/components/esp8266/test_boards.py b/tests/unit_tests/components/esp8266/test_boards.py new file mode 100644 index 0000000000..df0e536d42 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_boards.py @@ -0,0 +1,33 @@ +"""Tests for the per-board linker-script rule.""" + +import pytest + +from esphome.components.esp8266 import _choose_ld_script +from esphome.components.esp8266.boards import BOARDS, board_ld_script +import esphome.config_validation as cv +from esphome.core import EsphomeError + + +def test_d1_wroom_02_keeps_its_shipped_layout() -> None: + """The override must survive a BOARDS regeneration or key typo: the + 2m.ld default moves _FS_end and the preferences sector on deployed + devices.""" + assert board_ld_script(BOARDS["d1_wroom_02"]) == "eagle.flash.2m64.ld" + + +def test_default_boards_use_the_flash_size_layout() -> None: + assert board_ld_script(BOARDS["d1_mini"]) == "eagle.flash.4m.ld" + assert board_ld_script(BOARDS["esp01_1m"]) == "eagle.flash.1m.ld" + + +def test_choose_ld_script_paths() -> None: + """Old cores get the size default, overriding boards hard-error there + (a substituted layout would wipe flash-backed state), modern cores + honor the override.""" + assert _choose_ld_script("nodemcuv2", cv.Version(2, 3, 0)) is None + assert _choose_ld_script("nodemcuv2", cv.Version(2, 4, 2)) == "eagle.flash.4m.ld" + assert _choose_ld_script("d1_wroom_02", cv.Version(2, 7, 4)) == ( + "eagle.flash.2m64.ld" + ) + with pytest.raises(EsphomeError, match="cannot honor"): + _choose_ld_script("d1_wroom_02", cv.Version(2, 4, 2)) diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py new file mode 100644 index 0000000000..411a35eb96 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -0,0 +1,145 @@ +"""Tests for the linker-script surgery shared with the native toolchain.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + +import pytest + +from esphome.components.esp8266 import build_surgery +from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD +from esphome.components.esp8266.build_surgery import ( + RATETABLE_RULE, + apply_testing_memory_patches, + relocate_ratetable, + segment_length, +) + +_COMMON_LD_SNIPPET = """\ + .dport0.data : ALIGN(4) + { + _dport0_data_start = ABSOLUTE(.); + } >dport0_0_seg :dport0_0_phdr + .data : ALIGN(4) + { + _data_start = ABSOLUTE(.); + *(.data) + } >dram0_0_seg :dram0_0_phdr +""" + +# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in +# the generated common ld only) +_FLASH_LD_SNIPPET = """\ +MEMORY +{ + dport0_0_seg : org = 0x3FF00000, len = 0x10 + dram0_0_seg : org = 0x3FFE8000, len = 0x14000 + irom0_0_seg : org = 0x40201010, len = 0xfeff0 +} +""" + +# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul +# suffix the patcher must leave in place +_COMMON_LD_MEMORY_SNIPPET = """\ +MEMORY +{ + iram1_0_seg : org = 0x40100000, len = 0x8000ul +} +""" + + +def test_relocate_ratetable_inserts_after_data_start() -> None: + patched = relocate_ratetable(_COMMON_LD_SNIPPET) + assert RATETABLE_RULE in patched + # Inserted after the .data section's anchor, not the .dport0.data one + # (whose closing brace bounds the decoy block) + assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")] + assert patched.index(RATETABLE_RULE) < patched.index("*(.data)") + # Idempotent on an already-patched script + assert relocate_ratetable(patched) == patched + + +def test_relocate_ratetable_requires_anchor() -> None: + with pytest.raises(RuntimeError, match="_data_start"): + relocate_ratetable("SECTIONS { }") + + +def test_testing_memory_patches_enlarge_segments() -> None: + patched = apply_testing_memory_patches( + _FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg") + ) + assert segment_length(patched, "dram0_0_seg") == 0x200000 + assert segment_length(patched, "irom0_0_seg") == 0x2000000 + # Untouched segments keep their sizes + assert segment_length(patched, "dport0_0_seg") == 0x10 + + +def test_testing_memory_patches_keep_ul_suffix() -> None: + """The common ld's preprocessed sizes carry a ul suffix; the patch must + replace only the hex digits, as testing_mode.py.script does.""" + patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",)) + assert "len = 0x200000ul" in patched + assert segment_length(patched, "iram1_0_seg") == 0x200000 + + +def test_segment_length_requires_whole_name() -> None: + """A name must match its own line, never inside a longer segment name.""" + assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None + + +def test_testing_memory_patches_unknown_segment_raises() -> None: + with pytest.raises(RuntimeError, match="Unknown testing-mode segment"): + apply_testing_memory_patches("MEMORY { }", ("bogus_seg",)) + + +def test_segment_length() -> None: + assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0 + assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None + + +def test_testing_memory_patches_missing_segment_raises() -> None: + """A named segment the patch could not find raises instead of silently + keeping the real memory limits.""" + with pytest.raises(RuntimeError, match="dram0_0_seg"): + apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",)) + + +def test_board_build_covers_every_board() -> None: + """Every supported board has native build metadata (the table may carry + extras that BOARDS does not expose).""" + assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) + + +def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None: + """The properties the linker-script cache depends on: the fingerprint is + stable across calls and changes when the module's source changes.""" + + first = build_surgery.surgery_fingerprint() + assert first == build_surgery.surgery_fingerprint() + assert len(first) == 64 + int(first, 16) # sha256 hex digest + + # A modified copy of the module must fingerprint differently + copy = tmp_path / "build_surgery_variant.py" + copy.write_text( + Path(build_surgery.__file__).read_text(encoding="utf-8") + + "\nEXTRA_BEHAVIORAL_INPUT = 1\n", + encoding="utf-8", + ) + spec = importlib.util.spec_from_file_location("build_surgery_variant", copy) + variant = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = variant + try: + spec.loader.exec_module(variant) + assert variant.surgery_fingerprint() != first + finally: + del sys.modules[spec.name] + + +def test_testing_memory_patches_present_but_unselected_raises() -> None: + """A known segment left off the caller's list must fail, not silently + keep its real memory limit.""" + with pytest.raises(RuntimeError, match="not selected"): + apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",)) From c272c4c1a64d08547e510c3e9e6b90ccaadfda0d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:44:42 +1000 Subject: [PATCH 3/3] [lvgl] Add table widget (#18422) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/lvgl/lvgl_esphome.cpp | 46 +++ esphome/components/lvgl/lvgl_esphome.h | 25 ++ esphome/components/lvgl/widgets/table.py | 280 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 32 ++ tests/unit_tests/components/lvgl/__init__.py | 0 .../components/lvgl/test_table_codegen.py | 206 +++++++++++++ .../components/lvgl/test_table_config.py | 142 +++++++++ 7 files changed, 731 insertions(+) create mode 100644 esphome/components/lvgl/widgets/table.py create mode 100644 tests/unit_tests/components/lvgl/__init__.py create mode 100644 tests/unit_tests/components/lvgl/test_table_codegen.py create mode 100644 tests/unit_tests/components/lvgl/test_table_config.py diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 22fccdd92a..684f472ebd 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -525,6 +525,52 @@ void IndicatorLine::update_length_() { } #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return row; +} + +uint32_t lv_table_get_selected_column(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return column; +} + +void LvTableType::set_obj(lv_obj_t *lv_obj) { + LvCompound::set_obj(lv_obj); + lv_obj_add_event_cb( + lv_obj, + [](lv_event_t *e) { + auto *table = static_cast(lv_event_get_user_data(e)); + table->update_column_widths_(); + }, + LV_EVENT_SIZE_CHANGED, this); +} + +void LvTableType::add_column_width_pct(uint32_t col, uint8_t pct) { + for (auto &i : this->column_pct_) { + if (i.col == col) { + i.pct = pct; + this->update_column_widths_(); + return; + } + } + this->column_pct_.push_back({col, pct}); + this->update_column_widths_(); +} + +void LvTableType::update_column_widths_() { + auto content_width = lv_obj_get_content_width(this->obj); + for (const auto &col : this->column_pct_) { + lv_table_set_column_width(this->obj, col.col, content_width * col.pct / 100); + } +} +#endif // USE_LVGL_TABLE + #ifdef USE_LVGL_KEY_LISTENER LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) { this->drv_ = lv_indev_create(); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 98b97e26d7..ceba786e43 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -58,6 +58,10 @@ lv_obj_t *lv_container_create(lv_obj_t *parent); void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_end, lv_color_t color_start, lv_color_t color_end, int width, bool local); #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj); +uint32_t lv_table_get_selected_column(lv_obj_t *obj); +#endif #if LV_COLOR_DEPTH == 16 static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565; #elif LV_COLOR_DEPTH == 32 @@ -511,6 +515,27 @@ class LvLineType : public LvCompound { FixedVector points_{}; }; #endif +#ifdef USE_LVGL_TABLE +// Unlike most size properties, lv_table_set_column_width() only accepts a literal pixel +// count, so percentage column widths must be recomputed by hand whenever the table's own +// content width changes. +class LvTableType : public LvCompound { + public: + void set_obj(lv_obj_t *lv_obj) override; + // count is the number of percentage-width columns, known at code-generation time. + void init_column_pct(size_t count) { this->column_pct_.init(count); } + void add_column_width_pct(uint32_t col, uint8_t pct); + + protected: + void update_column_widths_(); + + struct ColumnPct { + uint32_t col; + uint8_t pct; + }; + FixedVector column_pct_{}; +}; +#endif // USE_LVGL_TABLE #if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER) class LvSelectable : public LvCompound { public: diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py new file mode 100644 index 0000000000..efae2be2be --- /dev/null +++ b/esphome/components/lvgl/widgets/table.py @@ -0,0 +1,280 @@ +from contextlib import ExitStack + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigFragmentType, ConfigType, SafeExpType + +from ..automation import action_to_code +from ..defines import CONF_COLUMN, CONF_MAIN, LValidator, literal +from ..lv_validation import lv_int, lv_text, pixels_or_percent, pixels_validator +from ..lvcode import LocalVariable, lv, lv_add, lv_expr +from ..types import LvCompound, LvType, ObjUpdateAction, lv_coord_t +from . import Widget, WidgetType, get_widgets +from .label import CONF_LABEL + +CONF_TABLE = "table" +CONF_CELLS = "cells" +CONF_COLUMNS = "columns" +CONF_ROW_COUNT = "row_count" +CONF_COLUMN_COUNT = "column_count" +CONF_MERGE_RIGHT = "merge_right" +CONF_TEXT_CROP = "text_crop" +CONF_SELECTED_ROW = "selected_row" +CONF_SELECTED_COLUMN = "selected_column" + +CELL_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TEXT, default=""): lv_text, + # Not templatable: the value selects between two different LVGL calls + # (set/clear cell ctrl), so a runtime lambda can't be mapped to a single call. + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } +) + +# A cell can be given as a bare piece of text, or a dict for more control +TABLE_CELL_SCHEMA = cv.maybe_simple_value(CELL_SCHEMA, key=CONF_TEXT) + +# A row can be given as a bare list of cells, or a dict for future extension +ROW_SCHEMA = cv.maybe_simple_value( + cv.Schema({cv.Required(CONF_CELLS): cv.ensure_list(TABLE_CELL_SCHEMA)}), + key=CONF_CELLS, +) + + +def _column_width_validator(value: ConfigFragmentType) -> int | float | list[str]: + """Like pixels_or_percent, but rejects negative widths, which would + defeat the 100%-total check and wrap around in the generated uint8_t pct.""" + if value == SCHEMA_EXTRACT: + return ["pixels", "..%"] + return cv.Any(pixels_validator, cv.percentage)(value) + + +column_width = LValidator( + _column_width_validator, + lv_coord_t, + retmapper=pixels_or_percent.retmapper, + animatable=True, +) + +COLUMN_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WIDTH): column_width, + } +) + + +def _validate_table(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS) + min_row_count = len(rows) if rows else 0 + min_column_count = max(len(row[CONF_CELLS]) for row in rows) if rows else 0 + row_count = config.get(CONF_ROW_COUNT) + if row_count is not None and row_count < min_row_count: + raise cv.Invalid( + f"{CONF_ROW_COUNT} must be at least {min_row_count} to hold all the given rows", + path=[CONF_ROW_COUNT], + ) + column_count = config.get(CONF_COLUMN_COUNT) + if column_count is not None and column_count < min_column_count: + raise cv.Invalid( + f"{CONF_COLUMN_COUNT} must be at least {min_column_count} to hold all the cells in a row", + path=[CONF_COLUMN_COUNT], + ) + column_count = column_count if column_count is not None else min_column_count + columns = config.get(CONF_COLUMNS) + if columns and column_count and len(columns) > column_count: + raise cv.Invalid( + f"{CONF_COLUMNS} defines {len(columns)} columns, but the table has only {column_count}", + path=[CONF_COLUMNS], + ) + total_pct = sum( + width + for column in columns or () + if isinstance((width := column.get(CONF_WIDTH)), float) + ) + if total_pct > 1.0: + raise cv.Invalid( + f"{CONF_COLUMNS} percentage widths add up to {total_pct * 100:.0f}%, which exceeds 100%", + path=[CONF_COLUMNS], + ) + return config + + +TABLE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ROWS): cv.ensure_list(ROW_SCHEMA), + cv.Optional(CONF_ROW_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMN_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMNS): cv.ensure_list(COLUMN_SCHEMA), + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +).add_extra(_validate_table) + +lv_table_t = LvType( + "LvTableType", + parents=(LvCompound,), + largs=[(cg.uint32, "row"), (cg.uint32, "column")], + lvalue=lambda w: [ + lv_expr.table_get_selected_row(w.obj), + lv_expr.table_get_selected_column(w.obj), + ], + has_on_value=True, +) + + +async def set_cell_ctrl( + w: Widget, row: SafeExpType, column: SafeExpType, cell: ConfigType +) -> None: + for key, ctrl in ( + (CONF_MERGE_RIGHT, "LV_TABLE_CELL_CTRL_MERGE_RIGHT"), + (CONF_TEXT_CROP, "LV_TABLE_CELL_CTRL_TEXT_CROP"), + ): + if key not in cell: + continue + if cell[key]: + lv.table_set_cell_ctrl(w.obj, row, column, literal(ctrl)) + else: + lv.table_clear_cell_ctrl(w.obj, row, column, literal(ctrl)) + + +async def set_selected_cell(w: Widget, config: ConfigType) -> None: + selected_row = config.get(CONF_SELECTED_ROW) + selected_column = config.get(CONF_SELECTED_COLUMN) + if selected_row is None and selected_column is None: + return + # LV_TABLE_CELL_NONE selects the whole column/row when only one index is given + row_value = ( + await lv_int.process(selected_row) + if selected_row is not None + else literal("LV_TABLE_CELL_NONE") + ) + column_value = ( + await lv_int.process(selected_column) + if selected_column is not None + else literal("LV_TABLE_CELL_NONE") + ) + lv.table_set_selected_cell(w.obj, row_value, column_value) + + +TABLE_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +) + + +class TableType(WidgetType): + def __init__(self): + super().__init__( + CONF_TABLE, + lv_table_t, + (CONF_MAIN, CONF_ITEMS), + TABLE_SCHEMA, + modify_schema=TABLE_MODIFY_SCHEMA, + ) + + def get_uses(self) -> tuple[str]: + return (CONF_LABEL,) + + async def to_code(self, w: Widget, config: dict) -> None: + rows = config.get(CONF_ROWS) + row_count = config.get(CONF_ROW_COUNT) + column_count = config.get(CONF_COLUMN_COUNT) + if rows is not None: + if row_count is None: + row_count = len(rows) + if column_count is None: + column_count = max((len(row[CONF_CELLS]) for row in rows), default=0) + if row_count is not None: + lv.table_set_row_count(w.obj, row_count) + if column_count is not None: + lv.table_set_column_count(w.obj, column_count) + columns = config.get(CONF_COLUMNS, ()) + pct_column_count = sum( + 1 for column in columns if isinstance(column.get(CONF_WIDTH), float) + ) + if pct_column_count: + lv_add(w.var.init_column_pct(pct_column_count)) + for index, column in enumerate(columns): + if (width := column.get(CONF_WIDTH)) is None: + continue + if isinstance(width, float): + # A percentage: column_width validation leaves it as a 0.0-1.0 + # fraction. LVGL's table widget only accepts a literal pixel width, so + # the actual width is recomputed at runtime from the table's own size. + lv_add(w.var.add_column_width_pct(index, round(width * 100))) + else: + lv.table_set_column_width( + w.obj, index, await column_width.process(width) + ) + for row_index, row in enumerate(rows or ()): + for column_index, cell in enumerate(row[CONF_CELLS]): + lv.table_set_cell_value( + w.obj, + row_index, + column_index, + await lv_text.process(cell[CONF_TEXT]), + ) + await set_cell_ctrl(w, row_index, column_index, cell) + await set_selected_cell(w, config) + + +table_spec = TableType() + + +@automation.register_action( + "lvgl.table.cell.update", + ObjUpdateAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_table_t), + cv.Required(CONF_ROW): lv_int, + cv.Required(CONF_COLUMN): lv_int, + cv.Optional(CONF_TEXT): lv_text, + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } + ).add_extra(cv.has_at_least_one_key(CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)), + synchronous=True, +) +async def table_cell_update_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + widgets = await get_widgets(config) + + async def do_update(w: Widget): + row = await lv_int.process(config[CONF_ROW]) + column = await lv_int.process(config[CONF_COLUMN]) + fields_set = sum( + key in config for key in (CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP) + ) + with ExitStack() as stack: + if fields_set > 1: + # row/column feed more than one generated call below: cache them in + # local variables so a !lambda value is only evaluated once. + row = stack.enter_context( + LocalVariable("row", cg.int_, row, modifier="") + ) + column = stack.enter_context( + LocalVariable("column", cg.int_, column, modifier="") + ) + if CONF_TEXT in config: + lv.table_set_cell_value( + w.obj, row, column, await lv_text.process(config[CONF_TEXT]) + ) + await set_cell_ctrl(w, row, column, config) + + return await action_to_code( + widgets, do_update, action_id, template_arg, args, config + ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index c78e910bc8..57be4e9043 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1181,6 +1181,38 @@ lvgl: - logger.log: format: "bar value %f" args: [x] + - table: + id: table_id + align: top_mid + y: 60 + columns: + - width: 40% + - width: 80 + rows: + - ["Name", "Value"] + - cells: + - text: "Temp" + merge_right: true + - text: "22.5" + text_crop: true + selected_row: 0 + on_value: + then: + - logger.log: + format: "table selected row %u col %u" + args: [row, column] + on_click: + then: + - lvgl.table.cell.update: + id: table_id + row: 1 + column: 1 + text: !lambda return str_sprintf("%.1f", (float) rand() / RAND_MAX * 100); + merge_right: false + - lvgl.table.update: + id: table_id + selected_row: !lambda return (int) ((float) rand() / RAND_MAX * 2); + selected_column: 0 - line: id: lv_line_id align: center diff --git a/tests/unit_tests/components/lvgl/__init__.py b/tests/unit_tests/components/lvgl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/lvgl/test_table_codegen.py b/tests/unit_tests/components/lvgl/test_table_codegen.py new file mode 100644 index 0000000000..390f67dffc --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_table_codegen.py @@ -0,0 +1,206 @@ +"""Tests for the LVGL table widget's C++ code generation.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.defines import set_widgets_completed +from esphome.components.lvgl.lvcode import LvContext +from esphome.components.lvgl.schemas import container_schema +from esphome.components.lvgl.trigger import generate_triggers +from esphome.components.lvgl.widgets import Widget, widget_to_code +from esphome.components.lvgl.widgets.table import table_spec +from esphome.const import ( + CONF_AUTOMATION_ID, + CONF_ON_VALUE, + CONF_THEN, + CONF_TRIGGER_ID, + CONF_TYPE_ID, +) +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArguments +from esphome.yaml_util import make_data_base + + +async def _create_table(raw_config: dict) -> Widget: + """Validate `raw_config` as a table widget and generate its creation code.""" + config = container_schema(table_spec)(raw_config) + parent = MockObj("parent_obj") + async with LvContext(): + return await widget_to_code(config, table_spec, parent) + + +def _statements() -> list[str]: + return [str(s) for s in CORE.main_statements] + + +@pytest.mark.asyncio +async def test_create_table_sets_row_and_column_count(setup_core) -> None: + await _create_table( + {"id": "table_counts", "rows": [["Name", "Value"], ["Temp", "22.5"]]} + ) + statements = _statements() + assert any("lv_table_set_row_count(table_counts->obj, 2)" in s for s in statements) + assert any( + "lv_table_set_column_count(table_counts->obj, 2)" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_create_table_writes_cell_values(setup_core) -> None: + await _create_table({"id": "table_cells", "rows": [["Name", "Value"]]}) + statements = _statements() + assert any( + 'lv_table_set_cell_value(table_cells->obj, 0, 0, "Name")' in s + for s in statements + ) + assert any( + 'lv_table_set_cell_value(table_cells->obj, 0, 1, "Value")' in s + for s in statements + ) + + +@pytest.mark.asyncio +async def test_create_table_sets_cell_control_flags(setup_core) -> None: + await _create_table( + { + "id": "table_ctrl", + "rows": [ + { + "cells": [ + {"text": "wide", "merge_right": True}, + {"text": "cropped", "text_crop": True}, + ] + } + ], + } + ) + statements = _statements() + assert any( + "lv_table_set_cell_ctrl(table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_MERGE_RIGHT)" + in s + for s in statements + ) + assert any( + "lv_table_set_cell_ctrl(table_ctrl->obj, 0, 1, LV_TABLE_CELL_CTRL_TEXT_CROP)" + in s + for s in statements + ) + # text_crop omitted for cell 0: no clear_cell_ctrl() should be emitted. + assert not any( + "table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_TEXT_CROP" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_pixel_column_width_calls_lvgl_directly(setup_core) -> None: + await _create_table({"id": "table_px", "columns": [{"width": 96}]}) + statements = _statements() + assert any( + "lv_table_set_column_width(table_px->obj, 0, 96)" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_percent_column_width_uses_the_dynamic_helper(setup_core) -> None: + """Regression test: lv_table_set_column_width() only accepts a literal + pixel count, so a percentage width must not be passed to it directly - + it has to go through the LvTableType helper that recomputes it at + runtime from the table's actual content width. + """ + await _create_table({"id": "table_pct", "columns": [{"width": "40%"}]}) + statements = _statements() + assert any("table_pct->init_column_pct(1)" in s for s in statements) + assert any("table_pct->add_column_width_pct(0, 40)" in s for s in statements) + assert not any( + "lv_table_set_column_width(table_pct->obj, 0" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_selected_cell_with_both_indices(setup_core) -> None: + await _create_table( + {"id": "table_sel_both", "selected_row": 1, "selected_column": 2} + ) + statements = _statements() + assert any( + "lv_table_set_selected_cell(table_sel_both->obj, 1, 2)" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_selected_cell_with_only_row_selects_whole_row(setup_core) -> None: + await _create_table({"id": "table_sel_row", "selected_row": 1}) + statements = _statements() + assert any( + "lv_table_set_selected_cell(table_sel_row->obj, 1, LV_TABLE_CELL_NONE)" in s + for s in statements + ) + + +@pytest.mark.asyncio +async def test_selected_cell_omitted_entirely_when_not_configured( + setup_core, +) -> None: + await _create_table({"id": "table_no_selection", "rows": [["a"]]}) + statements = _statements() + assert not any("lv_table_set_selected_cell" in s for s in statements) + + +@pytest.mark.asyncio +async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None: + await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]}) + set_widgets_completed(True) + # Only inspect statements emitted by the action below, not by creation. + before = len(_statements()) + + entry = ACTION_REGISTRY["lvgl.table.cell.update"] + config = entry.schema( + {"id": "table_update", "row": 1, "column": 1, "text": "new value"} + ) + action_id = ID("test_cell_update_action", is_declaration=True, type=entry.type_id) + await entry.coroutine_fun(config, action_id, TemplateArguments(), []) + + statements = _statements()[before:] + assert any( + 'lv_table_set_cell_value(table_update->obj, 1, 1, "new value")' in s + for s in statements + ) + # Neither control flag was specified, so neither call should be emitted. + assert not any("LV_TABLE_CELL_CTRL" in s for s in statements) + + +@pytest.mark.asyncio +async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> None: + config = container_schema(table_spec)( + { + "id": "table_on_value", + "rows": [["a"]], + "on_value": [ + {"lambda": make_data_base("id(table_on_value).get_selected_row();")} + ], + } + ) + # Auto-generated IDs (trigger/automation/action) are normally resolved to + # unique names by esphome's full config pass before code generation; do + # that by hand here since this test only exercises the widget/trigger + # codegen slice in isolation. + automation_conf = config[CONF_ON_VALUE][0] + automation_conf[CONF_TRIGGER_ID].resolve([]) + automation_conf[CONF_AUTOMATION_ID].resolve([]) + automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([]) + + parent = MockObj("parent_obj") + async with LvContext(): + await widget_to_code(config, table_spec, parent) + set_widgets_completed(True) + await generate_triggers() + + statements = _statements() + assert any( + "table_on_value->obj" in s + and "add_event_cb" in s + and "LV_EVENT_VALUE_CHANGED" in s + for s in statements + ) diff --git a/tests/unit_tests/components/lvgl/test_table_config.py b/tests/unit_tests/components/lvgl/test_table_config.py new file mode 100644 index 0000000000..047d1781ae --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_table_config.py @@ -0,0 +1,142 @@ +"""Tests for the LVGL table widget's configuration validation.""" + +from __future__ import annotations + +import pytest + +from esphome import config_validation as cv +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.widgets.table import ( + CONF_MERGE_RIGHT, + CONF_TEXT_CROP, + TABLE_SCHEMA, +) + + +def test_minimal_config_is_valid() -> None: + assert TABLE_SCHEMA({}) == {} + + +def test_row_shorthand_expands_to_plain_cells() -> None: + config = TABLE_SCHEMA({"rows": [["Name", "Value"]]}) + [row] = config["rows"] + assert row["cells"] == [{"text": "Name"}, {"text": "Value"}] + + +def test_row_dict_form_with_cell_overrides() -> None: + config = TABLE_SCHEMA( + { + "rows": [ + { + "cells": [ + "Temp", + {"text": "22.5", "text_crop": True, "merge_right": True}, + ] + } + ] + } + ) + [row] = config["rows"] + assert row["cells"][0] == {"text": "Temp"} + assert row["cells"][1] == { + "text": "22.5", + "merge_right": True, + "text_crop": True, + } + + +def test_row_count_defaults_are_not_injected_by_the_schema() -> None: + # Inference of row/column counts from `rows` happens at code generation + # time, not during validation - the schema should leave them unset. + config = TABLE_SCHEMA({"rows": [["a", "b"], ["c"]]}) + assert "row_count" not in config + assert "column_count" not in config + + +def test_explicit_row_and_column_count_are_kept() -> None: + config = TABLE_SCHEMA({"row_count": 5, "column_count": 3}) + assert config["row_count"] == 5 + assert config["column_count"] == 3 + + +def test_row_count_too_small_for_given_rows_raises() -> None: + with pytest.raises(cv.Invalid, match="row_count"): + TABLE_SCHEMA({"rows": [["a"], ["b"], ["c"]], "row_count": 2}) + + +def test_column_count_too_small_for_given_cells_raises() -> None: + with pytest.raises(cv.Invalid, match="column_count"): + TABLE_SCHEMA({"rows": [["a", "b", "c"]], "column_count": 2}) + + +def test_columns_list_longer_than_column_count_raises() -> None: + with pytest.raises(cv.Invalid, match="columns"): + TABLE_SCHEMA( + { + "column_count": 1, + "columns": [{"width": 10}, {"width": 20}], + } + ) + + +def test_columns_list_matching_inferred_column_count_is_valid() -> None: + config = TABLE_SCHEMA( + { + "rows": [["a", "b"]], + "columns": [{"width": 10}, {"width": 20}], + } + ) + assert [c["width"] for c in config["columns"]] == [10, 20] + + +@pytest.mark.parametrize( + ("width", "expected"), + [ + (100, 100), + ("50%", 0.5), + ("32px", 32), + ], +) +def test_column_width_accepts_pixels_and_percent(width, expected) -> None: + config = TABLE_SCHEMA({"columns": [{"width": width}]}) + assert config["columns"][0]["width"] == expected + + +def test_columns_percent_widths_summing_over_100_percent_raises() -> None: + with pytest.raises(cv.Invalid, match="columns"): + TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "50%"}]}) + + +def test_columns_percent_widths_summing_to_100_percent_is_valid() -> None: + config = TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "40%"}]}) + assert [c["width"] for c in config["columns"]] == [0.6, 0.4] + + +def test_columns_mixed_pixel_and_percent_widths_ignore_pixels_in_the_total() -> None: + # Pixel widths aren't part of the percentage budget, so they shouldn't + # count towards the 100% limit. + config = TABLE_SCHEMA( + {"columns": [{"width": 200}, {"width": "80%"}, {"width": "20%"}]} + ) + assert [c["width"] for c in config["columns"]] == [200, 0.8, 0.2] + + +def test_selected_row_and_selected_column_are_independently_optional() -> None: + config = TABLE_SCHEMA({"selected_row": 1}) + assert config["selected_row"] == 1 + assert "selected_column" not in config + + +def test_cell_update_action_requires_at_least_one_field() -> None: + entry = ACTION_REGISTRY["lvgl.table.cell.update"] + with pytest.raises(cv.Invalid): + entry.schema({"id": "some_table", "row": 0, "column": 0}) + + +def test_cell_update_action_accepts_a_single_field() -> None: + entry = ACTION_REGISTRY["lvgl.table.cell.update"] + config = entry.schema( + {"id": "some_table", "row": 0, "column": 0, "merge_right": True} + ) + assert config[CONF_MERGE_RIGHT] is True + assert CONF_TEXT_CROP not in config