diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75f40a732d3..cc18088b896 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,9 +193,10 @@ jobs: needs: - common # PR-branch cache saves are invisible to other PRs, so dev pushes seed - # the shared entry test-esp8266-native restores. Only dev: the composite - # action saves nowhere else, so a beta/release push would download the - # toolchain and discard it. + # the shared entry the component matrix, the memory impact jobs and + # test-esp8266-native restore. Only dev: the composite action saves + # nowhere else, so a beta/release push would download the toolchain and + # discard it. if: github.event_name == 'push' && github.ref == 'refs/heads/dev' timeout-minutes: 15 steps: @@ -399,14 +400,9 @@ jobs: matrix: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} env: - # What the cache steps persist; libdeps is excluded (keyed per xdist - # worker and env, it never crosses runs). - INTEGRATION_PIO_CACHE_PATH: | - ~/.esphome-integration-tests/platformio/platforms - ~/.esphome-integration-tests/platformio/packages - ~/.esphome-integration-tests/platformio/appstate.json - ~/.esphome-integration-tests/platformio/.cache - ~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json + # Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds + # download, shared per xdist worker by tests/integration/conftest.py + INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -426,14 +422,13 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - - name: Restore integration PlatformIO cache - # Native platform + toolchain installed by shared_platformio_cache in - # tests/integration/conftest.py; a miss self-heals, so no restore-keys. - id: pio-cache + - name: Restore integration library cache + # A miss or a changed pin self-heals with a download, so no restore-keys + id: library-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -501,16 +496,16 @@ jobs: # A full cron period of margin for the weekly refresh retention-days: 14 - name: Print ccache statistics - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). - run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save integration PlatformIO cache + # esphome stores the host build's ccache under the machine-global + # cache dir (see get_build_env() in esphome/host/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s + - name: Save integration library cache # Bucket 0 only; the others would race the same immutable key. - if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true' + if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: ${{ steps.pio-cache.outputs.cache-primary-key }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: ${{ steps.library-cache.outputs.cache-primary-key }} import-time: name: Check import esphome.__main__ time @@ -1127,6 +1122,11 @@ jobs: uses: ./.github/actions/cache-sdk-nrf with: restore-only: true + - name: Cache the native ESP8266 toolchain + # Only batches whose test platforms include esp8266; never saves + # here, it reuses the install the dev seed job cached. + if: matrix.batch.needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -1481,12 +1481,9 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' @@ -1571,11 +1568,9 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory id: extract run: | diff --git a/AGENTS.md b/AGENTS.md index 30ce6dfe9a8..6a60fc47b17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,6 +322,25 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or + `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, + `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, + then make one call per key, even when there is only one. A call creates the entity only when its key + is configured, passes it to the setter and returns it (or `None`); extra arguments such as + `min_value` or `options` go on the call. Always name the setter explicitly on the object that owns + it, never with `getattr` and an f-string, and keep that variable short (`var` for the component + itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose + setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. + ```python + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + buttons = button.sub_buttons(config, parent=var) + await buttons(CONF_RESTART, var.set_restart_button) + ``` + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). diff --git a/CODEOWNERS b/CODEOWNERS index eaf8c795767..ff2b6eb3275 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -125,6 +125,7 @@ esphome/components/combination/* @Cat-Ion @kahrendt esphome/components/const/* @esphome/core esphome/components/coolix/* @glmnet esphome/components/copy/* @OttoWinter +esphome/components/counter/* @clydebarrow esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger diff --git a/docker/Dockerfile b/docker/Dockerfile index c68cd0f4ba4..408e9e21559 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.16.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.17.0 RUN \ platformio settings set enable_telemetry No \ diff --git a/esphome/__main__.py b/esphome/__main__.py index 707ad036096..8ed97ccae88 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Protocol # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const, platform_hooks -from esphome.build_helpers.native import native_backend +from esphome.build_helpers.native import analysis_backend, native_backend from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -1714,26 +1714,12 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: if exit_code != 0: return exit_code if CORE.is_host: - _LOGGER.info( - "Successfully compiled program to path '%s'", _host_program_path(config) - ) + _LOGGER.info("Successfully compiled program to path '%s'", CORE.firmware_bin) else: _LOGGER.info("Successfully compiled program.") return 0 -def _host_program_path(config: ConfigType) -> str: - """Return the compiled host ELF path.""" - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain - - return str(toolchain.get_elf_path()) - from esphome.platformio.toolchain import get_idedata - - # Memoized by compile_program's own call; this is a dict lookup - return str(get_idedata(config).firmware_elf_path) - - def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: # Get devices, resolving special identifiers like OTA devices = choose_upload_log_host( @@ -1778,7 +1764,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: return exit_code _LOGGER.info("Successfully compiled program.") if CORE.is_host: - program_path = _host_program_path(config) + program_path = str(CORE.firmware_bin) _LOGGER.info("Running program from path '%s'", program_path) return run_external_process(program_path) @@ -2015,8 +2001,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Refuse an unsupported toolchain before paying for a full compile - native_toolchain = native_backend() - if native_toolchain is None and not CORE.using_toolchain_platformio: + analysis_toolchain = analysis_backend() + if analysis_toolchain is None and not CORE.using_toolchain_platformio: _LOGGER.error( "analyze-memory is not supported with the '%s' toolchain on %s; " "re-run with --toolchain platformio", @@ -2024,6 +2010,11 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: CORE.target_platform, ) return 1 + if ( + check_supported := getattr(analysis_toolchain, "check_analysis_supported", None) + ) is not None: + # Raises with the reason; before the compile, not after it + check_supported() # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) @@ -2036,9 +2027,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: # Get idedata for analysis idedata = None - if native_toolchain is not None: - objdump = native_toolchain.get_objdump_path() - readelf = native_toolchain.get_readelf_path() + if analysis_toolchain is not None: + objdump = analysis_toolchain.get_objdump_path() + readelf = analysis_toolchain.get_readelf_path() for tool in (objdump, readelf): if not tool.is_file(): # The analyzer would silently fall back to host @@ -2052,7 +2043,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: objdump_path = str(objdump) readelf_path = str(readelf) - firmware_elf = native_toolchain.get_elf_path() + firmware_elf = analysis_toolchain.get_elf_path() if not firmware_elf.is_file(): # The analyzer swallows tool failures, so a missing ELF would # produce an exit-0 zeroed report diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index e224e625897..143b6d23a07 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -2,7 +2,9 @@ Bundled names build straight from the framework tree; everything else goes through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each -library builds its own archive; all include dirs join one global path. +library builds its own archive; all include dirs join one global path. The +host build reuses it without a framework tree: nothing is bundled there and +every name resolves from the registry. Deviations from PlatformIO: flat-layout libraries get the recursive default source filter; ``dot_a_linkage`` is honored; bundled libraries never run a @@ -342,13 +344,24 @@ def _check_unfulfilled_provides( def resolve_libraries( - framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str + framework_path: Path | None, + *, + pio_platform: str, + board_mcu: str, + cache_key: str, + framework: str | None = "arduino", + manifest_optional: bool = False, ) -> list[ArduinoLibrary]: """Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`. ``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the - shared converter's download cache. + shared converter's download cache. ``framework`` is the manifest + framework token the compatibility check warns about; None skips it. + A None ``framework_path`` means no core-bundled libraries exist (the + host build): every name resolves from the registry. + ``manifest_optional`` accepts libraries without a manifest, built with + PlatformIO's default layout. The returned list is not topologically sorted, so the caller must link the archives inside one ``--start-group``/``--end-group`` pair (the @@ -359,18 +372,22 @@ def resolve_libraries( # PlatformIO's lib_ignore covers framework-bundled libraries too; the # shared converter only filters the registry/git ones. lib_ignore = lib_ignore_set() - # Exact directory names keep membership case-sensitive everywhere - # (an is_dir() probe would match "wire" on macOS/Windows and build - # the bundled Wire twice) - libraries_dir = framework_path / "libraries" - if not libraries_dir.is_dir(): - # A registry fallback would fail later with a misleading - # package-not-found error per bundled name - raise EsphomeError( - f"{libraries_dir} is missing; the framework install may be " - "incomplete (run 'esphome clean-all')" + bundled_dir_names: frozenset[str] = frozenset() + if framework_path is not None: + # Exact directory names keep membership case-sensitive everywhere + # (an is_dir() probe would match "wire" on macOS/Windows and build + # the bundled Wire twice) + libraries_dir = framework_path / "libraries" + if not libraries_dir.is_dir(): + # A registry fallback would fail later with a misleading + # package-not-found error per bundled name + raise EsphomeError( + f"{libraries_dir} is missing; the framework install may be " + "incomplete (run 'esphome clean-all')" + ) + bundled_dir_names = frozenset( + p.name for p in libraries_dir.iterdir() if p.is_dir() ) - bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir()) def _provided(name: object) -> bool: return _is_safe_library_name(name) and name in bundled_dir_names @@ -497,12 +514,13 @@ def resolve_libraries( backend = LibraryBackend( platform=pio_platform, - framework="arduino", + framework=framework, emit=_emit, cache_key=cache_key, # The walk must not resolve bundled names from the registry; # _add_bundled_dependencies adds them after emit provides=_provided, + manifest_optional=manifest_optional, ) if external: convert_libraries(external, backend) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index cd624dcb1f8..d32d61375bb 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -17,9 +17,8 @@ import os from pathlib import Path from typing import NamedTuple -from esphome.build_helpers.ccache import ccache_defaults_env +from esphome.build_helpers.ccache import ccache_env from esphome.build_helpers.ninja import find_ninja -from esphome.build_helpers.pch import ccache_pch_env from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path from esphome.core import EsphomeError, Version from esphome.framework_helpers import str_to_lst_of_str @@ -144,19 +143,5 @@ def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]: *filter(None, env.get("PATH", "").split(os.pathsep)), ] env["PATH"] = os.pathsep.join(parts) - env.update(ccache_env(ccache)) - return env - - -def ccache_env(ccache: str | None) -> dict[str, str]: - """Return ccache settings for the build subprocess (not os.environ). - - ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None - when disabled. Values the user already set in the environment are - respected. - """ - if ccache is None: - return {} - env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache") - env.update(ccache_pch_env()) + env.update(ccache_env(ccache, ARDUINO8266_TOOLS_CACHE)) return env diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index 339fb44337f..5bc1889a21b 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -2,21 +2,21 @@ from __future__ import annotations -import json import logging from pathlib import Path import subprocess from typing import TYPE_CHECKING -from esphome.build_helpers.ccache import resolve_ccache_path +from esphome.build_helpers.ccache import resolve_absolute_ccache_path +from esphome.build_helpers.native import warn_ignored_platformio_options +from esphome.build_helpers.ninja import refresh_compile_commands from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE, KEY_FRAMEWORK_VERSION, ) -from esphome.core import CORE, EsphomeError -from esphome.helpers import write_file +from esphome.core import CORE from esphome.types import ConfigType if TYPE_CHECKING: @@ -28,20 +28,6 @@ _LOGGER = logging.getLogger(__name__) _MAX_RAM_SIZE = 81920 -def _warn_ignored_platformio_options() -> None: - """Warn for component-added platformio options the native build drops.""" - from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS - - consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS - for key in sorted(CORE.platformio_options or {}): - if key not in consumed: - _LOGGER.warning( - "platformio_options->%s is ignored when building with the " - "native 'arduino' toolchain", - key, - ) - - _RAM_SECTIONS = (".data", ".rodata", ".bss") _FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata") @@ -84,21 +70,17 @@ def get_readelf_path() -> Path: def run_compile(config: ConfigType, verbose: bool) -> int: from esphome.arduino8266 import framework from esphome.build_gen import arduino8266 as build_gen + from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS - _warn_ignored_platformio_options() + warn_ignored_platformio_options(NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS) paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) # Resolved once: the probe is not free and three consumers need it - ccache = resolve_ccache_path() - build_gen.write_project(paths, ccache) + ccache = resolve_absolute_ccache_path() + ninja_changed = build_gen.write_project(paths, ccache) build_dir = get_build_dir() env = framework.get_build_env(paths.toolchain, ccache) - - # The DB is a pure function of build.ninja; regenerate when it is older - compdb = build_dir / "compile_commands.json" - ninja_file = build_dir / "build.ninja" - if not compdb.is_file() or compdb.stat().st_mtime < ninja_file.stat().st_mtime: - _write_compile_commands(paths.ninja, build_dir, env) + refresh_compile_commands(paths.ninja, build_dir, env, ninja_changed) cmd = [str(paths.ninja)] if verbose: @@ -140,42 +122,6 @@ def run_compile(config: ConfigType, verbose: bool) -> int: return 0 -def _write_compile_commands( - ninja_path: Path, build_dir: Path, env: dict[str, str] -) -> None: - compdb = build_dir / "compile_commands.json" - result = subprocess.run( - [str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"], - env=env, - capture_output=True, - text=True, - check=False, - close_fds=False, - ) - if result.returncode != 0: - # Drop any stale database so consumers (IDE integration, clang-tidy, - # the memory analyzer) can't silently read outdated data. - compdb.unlink(missing_ok=True) - raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}") - try: - entries = json.loads(result.stdout) - except ValueError as err: - compdb.unlink(missing_ok=True) - raise EsphomeError( - f"ninja produced an unparsable compile database: {err} " - f"(output starts {result.stdout[:120]!r})" - ) from err - if not entries: - # compdb exits 0 with [] for unknown rule names; a renamed compile - # rule must fail the build, not silently strand every consumer - compdb.unlink(missing_ok=True) - raise EsphomeError( - "ninja produced an empty compile database; the generator's rule " - "names no longer match" - ) - write_file(compdb, result.stdout) - - def _parse_app_size(build_dir: Path, paths: InstalledPaths) -> int | None: """Read the app flash budget (irom0_0_seg length) from the linker script.""" from esphome.build_gen.arduino8266 import get_flash_ld_path @@ -253,7 +199,7 @@ def get_idedata(ccache: str | None = None) -> dict | None: # A disabled ccache resolves to None without spawning anything, so # re-resolving here costs nothing when the caller has no answer - launcher = ccache or resolve_ccache_path() + launcher = ccache or resolve_absolute_ccache_path() return load_or_build_idedata( get_build_dir() / "compile_commands.json", get_elf_path(), diff --git a/esphome/automation.py b/esphome/automation.py index 2f970b915be..c8d0e4c5743 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -345,14 +345,28 @@ async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: - # Must match ApplyAction::ApplyFn and ApplyCondition::CheckFn exactly for the function - # pointer conversion. + # The generated function's parameters; a std::string arg is never copied. return [ (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg) for t, arg in args ] +def _apply_function( + id_: ID, + return_type: SafeExpType, + template_arg: cg.TemplateArguments, + lambda_args: TemplateArgsType, + statements: list[str], +) -> MockObj: + """Emit the generated function and declare ``id_`` as the ``ApplyAction`` or + ``ApplyCondition`` templated on it, so ``play()`` calls it directly.""" + fn = cg.static_function( + f"esphome__{id_.id}__fn", return_type, lambda_args, statements + ) + return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) + + async def _render_values( name: str, target: str, @@ -403,8 +417,8 @@ def register_apply_action( ) -> None: """Register an action that only forwards config values to its parent, with no C++ class. - Generates one stateless function for ``ApplyAction``: the parent (read from - ``id_key``) and constants are baked in, lambdas are called inline with the trigger args. + Generates one static function with the parent (read from ``id_key``) and constants baked + in, lambdas called inline with the trigger args, and an ``ApplyAction`` templated on it. A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. @@ -449,10 +463,9 @@ def register_apply_action( *statements, "apply_call.perform();", ] - apply_lambda = LambdaExpression( - ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + return _apply_function( + action_id, cg.void, template_arg, lambda_args, statements ) - return cg.new_Pvariable(action_id, template_arg, apply_lambda) register_action(name, ApplyAction, schema, synchronous=True)(builder) @@ -466,7 +479,7 @@ def register_apply_condition( ``ApplyCall`` such as ``ApplyCall("state == {}", ((CONF_STATE, cg.bool_),))`` compares against config values, all of which must be present. Write ``== false`` to negate. String constants are plain literals, so compare a ``std::string`` or ``StringRef`` member. - Generates one stateless function for ``ApplyCondition``. + Generates one static predicate and an ``ApplyCondition`` templated on it. """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) members = call.members @@ -492,13 +505,13 @@ def register_apply_condition( lambda_args, compare=True, ) - check_lambda = LambdaExpression( - [f"return {parent}->{call.target.format(*exprs)};"], + return _apply_function( + condition_id, + cg.bool_, + template_arg, lambda_args, - capture="", - return_type=cg.bool_, + [f"return {parent}->{call.target.format(*exprs)};"], ) - return cg.new_Pvariable(condition_id, template_arg, check_lambda) register_condition(name, ApplyCondition, schema)(builder) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index d1ca98e1a0e..69b76e02314 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -14,7 +14,6 @@ from the build flags with the same precedence as the PlatformIO builder. from __future__ import annotations -from collections.abc import Collection from dataclasses import dataclass import hashlib import logging @@ -23,7 +22,6 @@ from pathlib import Path import re import shlex import subprocess -import sys from typing import TYPE_CHECKING, NamedTuple from esphome.arduino8266.framework import toolchain_tool @@ -34,6 +32,15 @@ from esphome.build_helpers.ninja import ( quote_path as _q, shell_token as _shell_token, ) +from esphome.build_helpers.ninja_gen import ( + ar_rule_lines, + collect_sources, + compile_edges, + compile_rule_lines, + library_edges, + pch_rule_lines, + tool_lines, +) from esphome.build_helpers.pch import ( PCH_CORE_HEADER, PCH_HEADER_NAME, @@ -61,12 +68,9 @@ from esphome.components.esp8266.const import ( KEY_SCANF_FLOAT, ) from esphome.core import CORE, EsphomeError -from esphome.framework_helpers import ( - get_project_cxx_compile_flags, - strip_win_long_path_prefix, -) +from esphome.framework_helpers import get_project_cxx_compile_flags from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX, lex_build_flags +from esphome.platformio.library import lex_build_flags if TYPE_CHECKING: from esphome.arduino8266.framework import InstalledPaths @@ -658,14 +662,6 @@ _PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") _PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") -def _collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: - return sorted( - p - for p in root.rglob("*") - if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude - ) - - def _stat_sig(path: Path) -> str: """Size and mtime cache-stamp signature for one input file. @@ -883,41 +879,6 @@ def _generate_testing_flash_ld( ) -def _ninja_compile_edges( - lines: list[str], - sources: list[Path], - root: Path, - group: str, - flags: str = "", - cxx_override: tuple[str, str] | None = None, -) -> list[str]: - """Emit compile edges for ``sources``; return the object paths. - - ``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges - only, replacing ``flags`` (used for the precompiled header). - """ - objects = [] - for src in sources: - rel = src.relative_to(root).as_posix() - obj = f"obj/{group}/{rel}.o" - escaped_obj = _e(obj) - kind = SOURCE_KIND_FOR_SUFFIX[src.suffix] - override = cxx_override if kind == "cxx" and cxx_override else None - implicit = f" | {override[1]}" if override else "" - lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}") - edge_flags = override[0] if override else flags - if edge_flags: - lines.append(f" flags = {edge_flags}") - # Escaped once here: the returned paths only ever appear in build - # statements (archive/link inputs), which use ninja escaping. - objects.append(escaped_obj) - return objects - - -def _common_parent(paths: list[Path]) -> Path: - return Path(os.path.commonpath([str(p.parent) for p in paths])) - - class _FlagSets(NamedTuple): cflags: list[str] cxxflags: list[str] @@ -1104,55 +1065,15 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] ) - build_tool = Path(__file__).parent / "build_tool.py" - - # $in/$out stay unquoted: ninja escapes its built-in path variables - # itself; only literal paths need _q(). lines = [ - "# Auto-generated by ESPHome", - "ninja_required_version = 1.5", - f"cc = {_q(toolchain_tool(paths.toolchain, 'gcc'))}", - f"cxx = {_q(toolchain_tool(paths.toolchain, 'g++'))}", - # The NSIS launcher starts Python with a \\?\ extended-length path - # that cmd.exe cannot spawn; same strip every other emitted binary - # path gets - f"python = {_q(strip_win_long_path_prefix(sys.executable))}", - f"buildtool = {_q(build_tool)}", - f"ccache = {_q(ccache) if ccache else ''}", - "", - # Rule names match SOURCE_KIND_FOR_SUFFIX values (c, cxx, asm, aspp) - "rule c", - " command = $ccache $cc -MMD -MF $out.d $cflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = CC $out", - "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $cxxflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = CXX $out", - "rule aspp", - " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $asflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = AS $out", - # No $ccache: the .gch is compiled once per build dir and ccache - # cannot cache it usefully (its bytes embed build-dir paths) - "rule pch", - " command = $cxx -MMD -MF $out.d -x c++-header $cxxflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = PCH $out", - # Plain assembler, as SCons's ASCOM: no preprocessor, so no - # depfile and no $flags (defines/includes) either - "rule asm", - " command = $ccache $cc -x assembler $asflags -c $in -o $out", - " description = AS $out", - "rule ar", - f" command = $python $buildtool ar {_q(toolchain_tool(paths.toolchain, 'ar'))} $out $out.rsp", - " rspfile = $out.rsp", - " rspfile_content = $in_newline", - " description = AR $out", + *tool_lines( + (toolchain_tool(paths.toolchain, "gcc"),), + (toolchain_tool(paths.toolchain, "g++"),), + ccache, + ), + *compile_rule_lines(), + *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), + *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", " rspfile = $out.rsp", @@ -1182,16 +1103,15 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: core_exclude |= _CORE_EXCLUDE_WAVEFORM archives = [] - direct_objs: list[str] = [] # variant_dir existence was already enforced with the include dirs - variant_sources = _collect_sources(variant_dir) + variant_sources = collect_sources(variant_dir) if variant_sources: - objs = _ninja_compile_edges(lines, variant_sources, variant_dir, "variant") + objs = compile_edges(lines, variant_sources, variant_dir, "variant") lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") archives.append("libFrameworkArduinoVariant.a") - core_objs = _ninja_compile_edges( - lines, _collect_sources(core_dir, core_exclude), core_dir, "core" + core_objs = compile_edges( + lines, collect_sources(core_dir, core_exclude), core_dir, "core" ) if not core_objs: # An empty archive would link into a wall of undefined references @@ -1202,32 +1122,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") archives.append("libFrameworkArduino.a") - for lib in libraries: - if not lib.sources: - # Header-only libraries are legitimate; the log makes an empty - # srcFilter or broken tree traceable before link errors do. - _LOGGER.debug( - "Library %s has no source files; contributing includes only", - lib.name, - ) - continue - lib_root = _common_parent(lib.sources) - objs = _ninja_compile_edges( - lines, - lib.sources, - lib_root, - f"lib/{lib.name}", - flags=" ".join(_shell_token(f) for f in lib.flags), - ) - if not lib.lib_archive: - # libArchive: false / dot_a_linkage=false: hand the objects to - # the linker directly so unreferenced-but-required symbols - # (exception handlers, weak overrides) survive - direct_objs.extend(objs) - continue - archive = f"lib{lib.name}.a" - lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") - archives.append(archive) + lib_archives, direct_objs = library_edges(lines, libraries) + archives += lib_archives # One source of truth with the PlatformIO path: esp8266/__init__ pins # build_src_flags (the throw_stubs force-include); -include paths @@ -1348,9 +1244,9 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: mark_pch_emitted() else: pch_disabled_degraded() - src_objs = _ninja_compile_edges( + src_objs = compile_edges( lines, - _collect_sources(src_dir), + collect_sources(src_dir), src_dir, "src", flags="$srcflags", diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py new file mode 100644 index 00000000000..c883d912eb9 --- /dev/null +++ b/esphome/build_gen/host.py @@ -0,0 +1,272 @@ +"""Native ninja build generator for the host platform. + +Emits ``build.ninja`` under ``.pioenvs//``: every source in the +generated ``src/`` tree plus the resolved registry libraries compiles with +the machine's compiler and links into ``program``, the name PlatformIO's +native platform produced. Build flags route the way SCons's ``ParseFlags`` +did under PlatformIO: ``-D``/``-I``/``-std=``/``-W`` shapes reach the +compile lines only, ``-l``/``-L``/``-Wl,`` the link line only, everything +else both. +""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +import os +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from esphome.build_helpers.ninja import escape as _e, quote_path as _q, shell_token +from esphome.build_helpers.ninja_gen import ( + PATH_ARG_FLAGS, + Flag, + anchor_path_flag, + ar_rule_lines, + collect_sources, + compile_edges, + compile_rule_lines, + library_edges, + tool_lines, +) +from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import get_project_cxx_compile_flags +from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.host.toolchain import PROGRAM_NAME, HostCompilers, find_tool, get_build_dir +from esphome.platformio.library import lex_build_flags + +if TYPE_CHECKING: + from esphome.arduino.library import ArduinoLibrary + +_LOGGER = logging.getLogger(__name__) + +# The PlatformIO platform the host built under; registry manifests declare +# compatibility against it, as lib_compat_mode=strict checked before +PIO_PLATFORM = "native" +# Namespaces the shared library download cache (pio_components/host/) +LIBRARY_CACHE_KEY = "host" + +# Flag shapes that only the compiler understands; dropped from the link line +_COMPILE_ONLY_PREFIXES = ("-D", "-U", "-I", "-std=", "-W", *PATH_ARG_FLAGS) +# Flag shapes that only the linker consumes; inert on a -c compile line +_LINK_ONLY_PREFIXES = ("-l", "-L", "-Wl,") +# Link-only flags whose argument is the next token +_LINK_ONLY_ARG_FLAGS = ("-framework", "-Xlinker", "-z") + + +def parse_flags(entries: Iterable[str], owner: str) -> list[Flag]: + """Lex build flag entries into flags, each with its argument. + + Entries are a set, so their order is not the user's: a flag and the + argument it takes as the next token must share one entry. + """ + flags: list[Flag] = [] + for entry in entries: + it = iter(lex_build_flags(entry, owner)) + for tok in it: + if tok not in PATH_ARG_FLAGS and tok not in _LINK_ONLY_ARG_FLAGS: + flags.append((tok,)) + continue + arg = next(it, None) + # A path never starts with "-"; that is the next flag + if arg is None or (tok in PATH_ARG_FLAGS and arg.startswith("-")): + raise EsphomeError( + f"{owner} build flags have '{tok}' with no argument; write " + f"the flag and its argument as one entry" + ) + flags.append((tok, arg)) + return flags + + +def split_flags(flags: list[Flag]) -> tuple[list[Flag], list[Flag]]: + """Route build flags to the compile and link lines.""" + compile_flags: list[Flag] = [] + link_flags: list[Flag] = [] + for flag in flags: + name = flag[0] + if len(flag) > 1: + (compile_flags if name in PATH_ARG_FLAGS else link_flags).append(flag) + elif name.startswith(_LINK_ONLY_PREFIXES): + # Checked before the compile prefixes: -Wl, would match -W + link_flags.append(flag) + elif name.startswith(_COMPILE_ONLY_PREFIXES): + compile_flags.append(flag) + else: + # -g, -O, -f*, -m*, -pthread, --coverage: both lines, as SCons + compile_flags.append(flag) + link_flags.append(flag) + return compile_flags, link_flags + + +def _is_std(flag: Flag) -> bool: + return flag[0].startswith("-std=") + + +def _is_cxx_std(flag: Flag) -> bool: + return _is_std(flag) and "++" in flag[0] + + +def _anchored_flags(entries: Iterable[str], owner: str) -> list[Flag]: + build_path = Path(CORE.build_path) + return [ + anchor_path_flag(flag, build_path) + for flag in parse_flags(sorted(entries), owner) + ] + + +def _flag_lists() -> tuple[list[str], list[str], list[str]]: + """The C, C++, and link flag lists (raw tokens), build_unflags applied. + + ``cg.set_cpp_standard`` wins over any ``-std=`` in the build flags for + C++ compiles, as PlatformIO's unflag of every other standard did; C + compiles never see a C++ standard. + """ + # The funnel warns and drops empty glued arguments (-D "") itself + compile_flags, link_flags = split_flags( + _anchored_flags(CORE.build_flags, "esphome") + ) + cflags = [f for f in compile_flags if not _is_cxx_std(f)] + cxx_std = CORE.cpp_standard + cxxflags = [f for f in compile_flags if not (cxx_std and _is_std(f))] + if cxx_std: + cxxflags.insert(0, (f"-std={cxx_std}",)) + cxxflags += [(tok,) for tok in get_project_cxx_compile_flags()] + + # A flag is removed whole, with its argument, as PlatformIO did + unflags = set(_anchored_flags(CORE.build_unflags, "esphome build_unflags")) + # An unflag that hits nothing (a typo, or -DUSE_FOO against + # -DUSE_FOO=1) must be visible, since the user believes the flag is + # gone while it still drives the build + if unmatched := sorted(unflags - set(cflags) - set(cxxflags) - set(link_flags)): + _LOGGER.warning( + "build_unflags entries matched no build flag: %s", + ", ".join(" ".join(flag) for flag in unmatched), + ) + + def keep(flags: list[Flag]) -> list[str]: + return [tok for flag in flags if flag not in unflags for tok in flag] + + return keep(cflags), keep(cxxflags), keep(link_flags) + + +def _resolve_host_libraries() -> list[ArduinoLibrary]: + """Every ``cg.add_library()`` entry, fetched from the registry. + + The host has no framework, so nothing is bundled and no framework + compatibility check applies; the platform check keeps the strict + manifest gate PlatformIO's native platform enforced. Manifest-less + libraries (a bare git checkout) build with PlatformIO's default + layout, as they did under its native platform. + """ + if not CORE.platformio_libraries: + return [] + from esphome.arduino.library import resolve_libraries + + return resolve_libraries( + None, + pio_platform=PIO_PLATFORM, + board_mcu="host", + cache_key=LIBRARY_CACHE_KEY, + framework=None, + manifest_optional=True, + ) + + +def _file_macro_maps(build_dir: Path) -> list[str]: + """Flags that keep ``__FILE__`` relative to the build path. + + PlatformIO compiled ``src/x.cpp`` from the build path, and tools name + things after that spelling (CodSpeed's benchmark ids). Here a source + reaches the compiler by its absolute path, or relative to the build + directory when ccache rewrites it. + """ + build_path = Path(CORE.build_path) + prefixes = (build_path, Path(os.path.relpath(build_path, build_dir))) + return [ + shell_token(f"-fmacro-prefix-map={prefix}{os.sep}=", force=True) + for prefix in prefixes + ] + + +def write_project(compilers: HostCompilers, ccache: str | None) -> bool: + """Write the ninja build for the current configuration. + + ``ccache`` is the caller's already-resolved binary (None when disabled). + Returns True when ``build.ninja`` changed, so the caller can skip work + derived purely from it (the compile database) on unchanged builds. + """ + build_dir = get_build_dir() + mkdir_p(build_dir) + src_dir = CORE.relative_src_path() + if not src_dir.is_dir(): + # Generated project state, not install state: clean-all would not help + raise EsphomeError(f"Generated source directory {src_dir} is missing") + + cflags, cxxflags, link_flags = _flag_lists() + libraries = _resolve_host_libraries() + + include_dirs = [src_dir] + for lib in libraries: + include_dirs += lib.include_dirs + includes = [f"-I{_q(d)}" for d in include_dirs] + includes += _file_macro_maps(build_dir) + + # SCons's link line: $LINKFLAGS $SOURCES $_LIBDIRFLAGS $_LIBFLAGS, so + # -L and -l trail the objects while every other link token leads + lib_dirs = [Path(t[2:]) for t in link_flags if t.startswith("-L")] + libs = [t for t in link_flags if t.startswith("-l")] + linkflags = [shell_token(t) for t in link_flags if not t.startswith(("-L", "-l"))] + for lib in libraries: + lib_dirs += lib.link_dirs + libs += [f"-l{name}" for name in lib.link_libs] + linkflags += [shell_token(f) for f in lib.link_flags] + + # PlatformIO's ASPPCOM passes only -D/-I user flags to assembly + asflags = [t for t in cflags if t.startswith(("-D", "-I"))] + + lines = [ + *tool_lines(compilers.cc, compilers.cxx, ccache), + *compile_rule_lines(), + "rule link", + " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = LINK $out", + ] + if any(lib.sources and lib.lib_archive for lib in libraries): + # Resolved only when an archive is built, so a system without + # binutils still links a library-free configuration + lines += ar_rule_lines(find_tool("AR", ("ar",))) + lines += [ + "", + f"cflags = {' '.join([*map(shell_token, cflags), *includes])}", + f"cxxflags = {' '.join([*map(shell_token, cxxflags), *includes])}", + f"asflags = {' '.join([*map(shell_token, asflags), *includes])}", + f"linkflags = {' '.join(linkflags)}", + f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}", + f"libflags = {' '.join(shell_token(lib) for lib in libs)}", + "", + ] + + archives, direct_objs = library_edges(lines, libraries) + + src_objs = compile_edges(lines, collect_sources(src_dir), src_dir, "src") + if not src_objs: + raise EsphomeError(f"No source files found under {src_dir}") + + # Archives are not topologically sorted; GNU ld needs the group to + # resolve references between them. ld64 loads archives iteratively and + # rejects the option, so macOS lists them bare. + archive_tokens = [shell_token(a) for a in archives] + if archive_tokens and sys.platform != "darwin": + archive_tokens = ["-Wl,--start-group", *archive_tokens, "-Wl,--end-group"] + lines.append( + f"build {PROGRAM_NAME}: link {' '.join(src_objs + direct_objs)} | " + f"{' '.join(_e(a) for a in archives)}" + ) + lines.append(f" archives = {' '.join(archive_tokens)}") + lines.append(f"default {PROGRAM_NAME}") + lines.append("") + + return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines)) diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py index 32724b4a8e3..0689259253e 100644 --- a/esphome/build_helpers/ccache.py +++ b/esphome/build_helpers/ccache.py @@ -73,6 +73,15 @@ def resolve_ccache_path() -> str | None: return ccache +def resolve_absolute_ccache_path() -> str | None: + """``resolve_ccache_path`` for the ninja backends, which run their + commands from the build directory, where a relative path is lost.""" + from esphome.build_helpers.ninja import absolute_tool + + ccache = resolve_ccache_path() + return absolute_tool(ccache) if ccache else None + + def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: """Default ``CCACHE_*`` values for a build subprocess (not os.environ). @@ -89,11 +98,11 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: "CORE.build_path must be set before constructing the build environment" ) defaults = { - "CCACHE_DIR": str(cache_dir), + # ccache expands $VAR in its settings; $$ is a literal $ + "CCACHE_DIR": str(cache_dir).replace("$", "$$"), "CCACHE_NOHASHDIR": "true", "CCACHE_DEPEND": "1", - # A user value wins via the filter below - "CCACHE_BASEDIR": effective_ccache_basedir(), + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()).replace("$", "$$"), } return {k: v for k, v in defaults.items() if k not in os.environ} @@ -108,3 +117,20 @@ def effective_ccache_basedir() -> str: return raw # Unset or degenerate ("", "/", relative): fall back to the build path return str(Path(CORE.build_path).resolve()) + + +def ccache_env(ccache: str | None, tools_cache: tuple[str, str]) -> dict[str, str]: + """The ccache settings for a build subprocess (not os.environ). + + ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None when + disabled; ``tools_cache`` is the backend's tools cache spec, which holds + its ccache dir. + """ + if ccache is None: + return {} + from esphome.build_helpers.pch import ccache_pch_env + from esphome.build_helpers.tools_cache import tools_cache_path + + env = ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache") + env.update(ccache_pch_env()) + return env diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py index cde526b8033..1d2dc1b3cae 100644 --- a/esphome/build_helpers/native.py +++ b/esphome/build_helpers/native.py @@ -7,16 +7,21 @@ platform component package or the backend itself. from __future__ import annotations +from collections.abc import Collection import importlib +import logging from types import ModuleType from esphome.const import Toolchain from esphome.core import CORE, EsphomeError +_LOGGER = logging.getLogger(__name__) + # Native build backend per (target platform, toolchain) NATIVE_TOOLCHAIN_MODULES = { ("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain", ("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain", + ("host", Toolchain.HOST): "esphome.host.toolchain", } @@ -32,3 +37,38 @@ def native_backend() -> ModuleType | None: f"module for platform {CORE.target_platform}" ) return importlib.import_module(module_path) + + +# Binutils and the linked image for memory analysis, for toolchains that build +# without PlatformIO but have no native build backend (which supplies them) +ANALYSIS_TOOLCHAIN_MODULES = { + ("nrf52", Toolchain.SDK_NRF): "esphome.components.nrf52.toolchain", +} + + +def analysis_backend() -> ModuleType | None: + """The module giving objdump, readelf and the ELF of a non-PlatformIO build. + + None means PlatformIO's idedata supplies them (or nothing can). + """ + if (native := native_backend()) is not None: + return native + module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain)) + return importlib.import_module(module_path) if module_path else None + + +def warn_ignored_platformio_options(consumed: Collection[str]) -> None: + """Warn for component-added platformio options a native build drops. + + User-supplied keys were already routed or warned about by + ``core/config.py``; what survives into ``CORE.platformio_options`` came + from ``cg.add_platformio_option`` calls in components. + """ + for key in sorted(CORE.platformio_options or {}): + if key not in consumed: + _LOGGER.warning( + "platformio_options->%s is ignored when building with the " + "native '%s' toolchain", + key, + CORE.toolchain.value, + ) diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py index 8c25bc95130..7634b0f2f34 100644 --- a/esphome/build_helpers/ninja.py +++ b/esphome/build_helpers/ninja.py @@ -2,14 +2,17 @@ from __future__ import annotations +import json import logging import os from pathlib import Path import re import shutil +import subprocess from esphome.core import EsphomeError from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs +from esphome.helpers import write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -23,11 +26,26 @@ def _ninja_runs(binary: str) -> bool: ) +# Compile rule names the generators emit; ninja's compdb tool is asked for +# exactly these, so a renamed rule fails the build instead of stranding idedata +COMPILE_RULES = ("c", "cxx", "aspp", "asm") + + +def absolute_tool(tool: str | Path) -> str: + """A tool path that still resolves from the build directory. + + ``shutil.which`` returns a relative path for a relative PATH entry, and + ninja runs the commands from ``.pioenvs/``. Symlinks are kept: + ccache's compiler links depend on the name they are called by. + """ + return strip_win_long_path_prefix(str(Path(tool).absolute())) + + def find_ninja() -> Path: """Locate the ninja binary: a runnable PATH hit first, else the ninja PyPI wheel.""" if binary := shutil.which("ninja"): - binary = strip_win_long_path_prefix(binary) + binary = absolute_tool(binary) if _ninja_runs(binary): return Path(binary) import_error: ImportError | None = None @@ -49,8 +67,18 @@ def find_ninja() -> Path: def escape(value: Path | str) -> str: - """Escape a path or token for a ninja file.""" - return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + """Escape a path or token for a ninja file. + + ninja has no escape for ``|`` or a line break in a path, so those fail + here by name instead of producing a build file ninja misreads. + """ + text = str(value) + if bad := next((c for c in "|\r\n" if c in text), None): + raise EsphomeError( + f"Path {text!r} contains {bad!r}, which a ninja build file cannot " + "express; rename the file or directory" + ) + return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ") def quote_arg(tok: str) -> str: @@ -90,3 +118,65 @@ def shell_token(tok: str, force: bool = False) -> str: def quote_path(value: Path | str) -> str: """Force-quote a path for the ninja command line (shell/CreateProcess).""" return shell_token(str(value), force=True) + + +def refresh_compile_commands( + ninja_path: Path, build_dir: Path, env: dict[str, str], ninja_changed: bool +) -> None: + """Regenerate the compile DB (a pure function of build.ninja) when stale. + + Freshness rides a stamp: the DB itself is written through + write_file_if_changed (its mtime feeds the idedata cache), so a + regeneration with identical content would stay "stale" forever. An + interrupted previous run may have rewritten the manifest without + regenerating the DB, hence the mtime comparison. + """ + compdb = build_dir / "compile_commands.json" + compdb_stamp = build_dir / ".compile_commands.stamp" + ninja_file = build_dir / "build.ninja" + if ( + ninja_changed + or not compdb.is_file() + or not compdb_stamp.is_file() + or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime + ): + write_compile_commands(ninja_path, build_dir, env) + compdb_stamp.touch() + + +def write_compile_commands( + ninja_path: Path, build_dir: Path, env: dict[str, str] +) -> None: + compdb = build_dir / "compile_commands.json" + result = subprocess.run( + [str(ninja_path), "-C", str(build_dir), "-t", "compdb", *COMPILE_RULES], + env=env, + capture_output=True, + text=True, + check=False, + close_fds=False, + ) + if result.returncode != 0: + # Drop any stale database so consumers (IDE integration, clang-tidy, + # the memory analyzer) can't silently read outdated data + compdb.unlink(missing_ok=True) + raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}") + try: + entries = json.loads(result.stdout) + except ValueError as err: + compdb.unlink(missing_ok=True) + raise EsphomeError( + f"ninja produced an unparsable compile database: {err} " + f"(output starts {result.stdout[:120]!r})" + ) from err + if not entries: + # compdb exits 0 with [] for unknown rule names; a renamed compile + # rule must fail the build, not silently strand every consumer + compdb.unlink(missing_ok=True) + raise EsphomeError( + "ninja produced an empty compile database; the generator's rule " + "names no longer match" + ) + # write_file_if_changed keeps the mtime stable on no-op builds so the + # idedata cache stays valid + write_file_if_changed(compdb, result.stdout) diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py new file mode 100644 index 00000000000..56f054f23ff --- /dev/null +++ b/esphome/build_helpers/ninja_gen.py @@ -0,0 +1,223 @@ +"""The parts of a ``build.ninja`` every native backend emits the same way. + +Rule names match ``SOURCE_KIND_FOR_SUFFIX`` values (c, cxx, asm, aspp) and +``ninja.COMPILE_RULES``, which the compile database is asked for. +""" + +from __future__ import annotations + +from collections.abc import Collection, Sequence +import logging +import os +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from esphome.build_helpers.ninja import ( + escape as _e, + quote_path as _q, + shell_token as _shell_token, +) +from esphome.framework_helpers import strip_win_long_path_prefix +from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX + +if TYPE_CHECKING: + from esphome.arduino.library import ArduinoLibrary + +_LOGGER = logging.getLogger(__name__) + +_BUILD_TOOL = Path(__file__).parent.parent / "build_gen" / "build_tool.py" + + +def collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]: + return sorted( + p + for p in root.rglob("*") + if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude + ) + + +def common_parent(paths: list[Path]) -> Path: + return Path(os.path.commonpath([str(p.parent) for p in paths])) + + +def _command(words: Sequence[Path | str]) -> str: + program, *args = words + return " ".join([_q(program), *(_shell_token(str(a)) for a in args)]) + + +def tool_lines( + cc: Sequence[Path | str], cxx: Sequence[Path | str], ccache: str | None +) -> list[str]: + """The file header: the compilers and the helper tools as variables. + + A compiler is its program followed by any arguments it always takes. + """ + return [ + "# Auto-generated by ESPHome", + "ninja_required_version = 1.5", + f"cc = {_command(cc)}", + f"cxx = {_command(cxx)}", + # The NSIS launcher starts Python with a \\?\ extended-length path + # that cmd.exe cannot spawn; same strip every other emitted binary + # path gets + f"python = {_q(strip_win_long_path_prefix(sys.executable))}", + f"buildtool = {_q(_BUILD_TOOL)}", + f"ccache = {_q(ccache) if ccache else ''}", + "", + ] + + +def compile_rule_lines() -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" + return [ + "rule c", + " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CC $out", + "rule cxx", + " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CXX $out", + "rule aspp", + " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = AS $out", + # Plain assembler, as SCons's ASCOM: no preprocessor, so no + # depfile and no $flags (defines/includes) either + "rule asm", + " command = $ccache $cc -x assembler $asflags -c $in -o $out", + " description = AS $out", + ] + + +def pch_rule_lines() -> list[str]: + """The precompiled header rule, for a generator that emits one.""" + return [ + # No $ccache: the .gch is compiled once per build dir and ccache + # cannot cache it usefully (its bytes embed build-dir paths) + "rule pch", + " command = $cxx -MMD -MF $out.d -x c++-header $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = PCH $out", + ] + + +def ar_rule_lines(ar: Path | str) -> list[str]: + return [ + "rule ar", + f" command = $python $buildtool ar {_q(ar)} $out $out.rsp", + " rspfile = $out.rsp", + " rspfile_content = $in_newline", + " description = AR $out", + ] + + +def compile_edges( + lines: list[str], + sources: list[Path], + root: Path, + group: str, + flags: str = "", + own_includes: str = "", + cxx_override: tuple[str, str] | None = None, +) -> list[str]: + """Emit compile edges for ``sources``; return the object paths. + + ``cxx_override`` is a (flags, implicit-dep) pair applied to C++ edges + only, replacing ``flags`` (used for the precompiled header). + """ + objects = [] + for src in sources: + rel = src.relative_to(root).as_posix() + obj = f"obj/{group}/{rel}.o" + escaped_obj = _e(obj) + kind = SOURCE_KIND_FOR_SUFFIX[src.suffix] + override = cxx_override if kind == "cxx" else None + implicit = f" | {override[1]}" if override else "" + lines.append(f"build {escaped_obj}: {kind} {_e(src)}{implicit}") + if own_includes: + lines.append(f" own_includes = {own_includes}") + if edge_flags := override[0] if override else flags: + lines.append(f" flags = {edge_flags}") + # Escaped once here: the returned paths only ever appear in build + # statements (archive/link inputs), which use ninja escaping + objects.append(escaped_obj) + return objects + + +def library_edges( + lines: list[str], libraries: list[ArduinoLibrary] +) -> tuple[list[str], list[str]]: + """Emit every library's compile and archive edges. + + Returns the archive names and the objects that link directly. A + library's own include dirs lead its compile lines, as PlatformIO searched + them first: the include path is one global list, so another library's + header of the same name would shadow them. + """ + archives: list[str] = [] + direct_objs: list[str] = [] + for lib in libraries: + if not lib.sources: + # Header-only libraries are legitimate; the log makes an empty + # srcFilter or broken tree traceable before link errors do + _LOGGER.debug( + "Library %s has no source files; contributing includes only", + lib.name, + ) + continue + objs = compile_edges( + lines, + lib.sources, + common_parent(lib.sources), + f"lib/{lib.name}", + flags=" ".join(_shell_token(f) for f in lib.flags), + own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs), + ) + if not lib.lib_archive: + # libArchive: false / dot_a_linkage=false: hand the objects to + # the linker directly so unreferenced-but-required symbols + # (exception handlers, weak overrides) survive + direct_objs.extend(objs) + continue + archive = f"lib{lib.name}.a" + lines.append(f"build {_e(archive)}: ar {' '.join(objs)}") + archives.append(archive) + return archives, direct_objs + + +# One build flag: the flag, plus its argument when that is a separate token +Flag = tuple[str, ...] + +# Flags whose path operand is the next token; gcc also takes it glued on +PATH_ARG_FLAGS = ("-include", "-imacros", "-isystem", "-iquote", "-idirafter") +# Flags whose path operand is glued on +PATH_PREFIXES = ("-I", "-L", *PATH_ARG_FLAGS) + + +def _anchor(path: str, base: Path) -> str: + if not path or Path(path).is_absolute(): + return path + return str(base / path) + + +def anchor_path_flag(flag: Flag, base: Path) -> Flag: + """Anchor a flag's relative path operand at ``base``. + + PlatformIO ran the compiler from the build path; ninja runs it from + ``.pioenvs/``, where a relative operand would point elsewhere. + """ + name, *args = flag + if args: + if name in PATH_ARG_FLAGS: + return (name, _anchor(args[0], base)) + return flag + for prefix in PATH_PREFIXES: + if name.startswith(prefix): + return (prefix + _anchor(name[len(prefix) :], base),) + return flag diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py index e7193a8e2af..2dbbb2ffc22 100644 --- a/esphome/build_helpers/tools_cache.py +++ b/esphome/build_helpers/tools_cache.py @@ -33,4 +33,11 @@ def tools_cache_path(env_var: str, subdir: str) -> Path: IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf") SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf") ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266") -TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE) +# The host backend installs nothing; the entry only holds its ccache dir +HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host") +TOOLS_CACHE_SPECS = ( + IDF_TOOLS_CACHE, + SDK_NRF_TOOLS_CACHE, + ARDUINO8266_TOOLS_CACHE, + HOST_TOOLS_CACHE, +) diff --git a/esphome/codegen.py b/esphome/codegen.py index 3dfec65f233..4de1d8d5c19 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -42,6 +42,7 @@ from esphome.cpp_generator import ( # noqa: F401 set_cpp_standard, statement, static_const_array, + static_function, templatable, variable, with_local_variable, diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index 58fde11a3d2..2cec58934d6 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -85,20 +85,11 @@ async def wave_base_to_code(var: MockObj, config: ConfigType) -> None: await ble_client.register_ble_node(var, config) - if config_humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(config_humidity) - cg.add(var.set_humidity(sens)) - if config_temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(config_temperature) - cg.add(var.set_temperature(sens)) - if config_pressure := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(config_pressure) - cg.add(var.set_pressure(sens)) - if config_tvoc := config.get(CONF_TVOC): - sens = await sensor.new_sensor(config_tvoc) - cg.add(var.set_tvoc(sens)) - if config_battery_voltage := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(config_battery_voltage) - cg.add(var.set_battery_voltage(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_TVOC, var.set_tvoc) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) if config_battery_update_interval := config.get(CONF_BATTERY_UPDATE_INTERVAL): cg.add(var.set_battery_update_interval(config_battery_update_interval)) diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index 8ea79e644fc..75c1eca594f 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -87,16 +87,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await airthings_wave_base.wave_base_to_code(var, config) - if config_radon := config.get(CONF_RADON): - sens = await sensor.new_sensor(config_radon) - cg.add(var.set_radon(sens)) - if config_radon_long_term := config.get(CONF_RADON_LONG_TERM): - sens = await sensor.new_sensor(config_radon_long_term) - cg.add(var.set_radon_long_term(sens)) - if config_co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(config_co2) - cg.add(var.set_co2(sens)) - if config_illuminance := config.get(CONF_ILLUMINANCE): - sens = await sensor.new_sensor(config_illuminance) - cg.add(var.set_illuminance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RADON, var.set_radon) + await sensors(CONF_RADON_LONG_TERM, var.set_radon_long_term) + await sensors(CONF_CO2, var.set_co2) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) cg.add(var.set_device_type(config[CONF_DEVICE_TYPE])) diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index 184b2e87339..cde1a41f657 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -85,18 +85,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) - if battery_voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(battery_voltage_config) - cg.add(var.set_battery_voltage(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 87db214233a..5a6b667535f 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -129,30 +129,15 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await spi.register_spi_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if reactive_power_config := config.get(CONF_REACTIVE_POWER): - sens = await sensor.new_sensor(reactive_power_config) - cg.add(var.set_reactive_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) - if forward_active_energy_config := config.get(CONF_FORWARD_ACTIVE_ENERGY): - sens = await sensor.new_sensor(forward_active_energy_config) - cg.add(var.set_forward_active_energy_sensor(sens)) - if reverse_active_energy_config := config.get(CONF_REVERSE_ACTIVE_ENERGY): - sens = await sensor.new_sensor(reverse_active_energy_config) - cg.add(var.set_reverse_active_energy_sensor(sens)) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_freq_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FORWARD_ACTIVE_ENERGY, var.set_forward_active_energy_sensor) + await sensors(CONF_REVERSE_ACTIVE_ENERGY, var.set_reverse_active_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_freq_sensor) cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY])) cg.add(var.set_meter_constant(config[CONF_METER_CONSTANT])) cg.add(var.set_pl_const(config[CONF_PL_CONST])) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 6b7c3dcb163..f5cde2c8e6f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -61,14 +61,16 @@ from esphome.const import ( DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -632,6 +634,13 @@ async def new_binary_sensor(config, *args): return var +def sub_binary_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_binary_sensor.""" + return SubEntities(new_binary_sensor, config, parent) + + BINARY_SENSOR_CONDITION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(BinarySensor), diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 67ff903fbf1..25c50555dbc 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -150,15 +150,10 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_frequency_sensor(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) for i in range(6): if channel_config := config.get(f"{CONF_CHANNEL}_{i + 1}"): @@ -172,10 +167,5 @@ async def to_code(config: ConfigType) -> None: sens = await sensor.new_sensor(energy_config) cg.add(getattr(var, f"set_energy_{i + 1}_sensor")(sens)) - if total_power_config := config.get(CONF_TOTAL_POWER): - sens = await sensor.new_sensor(total_power_config) - cg.add(var.set_total_power_sensor(sens)) - - if total_energy_config := config.get(CONF_TOTAL_ENERGY): - sens = await sensor.new_sensor(total_energy_config) - cg.add(var.set_total_energy_sensor(sens)) + await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) + await sensors(CONF_TOTAL_ENERGY, var.set_total_energy_sensor) diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index ec17ef2c7ec..9f6bd7814e4 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -94,27 +94,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_1_config := config.get(CONF_CURRENT_1): - sens = await sensor.new_sensor(current_1_config) - cg.add(var.set_current_sensor_1(sens)) - if current_2_config := config.get(CONF_CURRENT_2): - sens = await sensor.new_sensor(current_2_config) - cg.add(var.set_current_sensor_2(sens)) - if active_power_1_config := config.get(CONF_ACTIVE_POWER_1): - sens = await sensor.new_sensor(active_power_1_config) - cg.add(var.set_power_sensor_1(sens)) - if active_power_2_config := config.get(CONF_ACTIVE_POWER_2): - sens = await sensor.new_sensor(active_power_2_config) - cg.add(var.set_power_sensor_2(sens)) - if energy_1_config := config.get(CONF_ENERGY_1): - sens = await sensor.new_sensor(energy_1_config) - cg.add(var.set_energy_sensor_1(sens)) - if energy_2_config := config.get(CONF_ENERGY_2): - sens = await sensor.new_sensor(energy_2_config) - cg.add(var.set_energy_sensor_2(sens)) - if energy_total_config := config.get(CONF_ENERGY_TOTAL): - sens = await sensor.new_sensor(energy_total_config) - cg.add(var.set_energy_sensor_sum(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT_1, var.set_current_sensor_1) + await sensors(CONF_CURRENT_2, var.set_current_sensor_2) + await sensors(CONF_ACTIVE_POWER_1, var.set_power_sensor_1) + await sensors(CONF_ACTIVE_POWER_2, var.set_power_sensor_2) + await sensors(CONF_ENERGY_1, var.set_energy_sensor_1) + await sensors(CONF_ENERGY_2, var.set_energy_sensor_2) + await sensors(CONF_ENERGY_TOTAL, var.set_energy_sensor_sum) diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 7e6403c3bc9..1cf7e8e6e38 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -231,24 +231,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if internal_temperature_config := config.get(CONF_INTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(internal_temperature_config) - cg.add(var.set_internal_temperature_sensor(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) # enable legacy mode cg.add(var.set_legacy_mode(config.get(CONF_LEGACY_MODE))) diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 5531fe411ba..397471cf7a9 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -101,21 +101,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_frequency_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY])) cg.add(var.set_address(config[CONF_ADDRESS])) cg.add(var.set_reset(config[CONF_RESET])) diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index c0837da4025..55a2f9bd188 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -7,6 +7,7 @@ from esphome.components.uart import ( uart_ns, ) from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_DEBUG, @@ -61,6 +62,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT_NUS", True) + request_ble_l2cap_mtu(247) # DLE sweet spot: one NUS frame per LL PDU expose_log = config[CONF_TYPE] == CONF_LOGS cg.add(var.set_expose_log(expose_log)) if expose_log: diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index 287946801eb..4b079def64d 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -90,20 +90,14 @@ async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING])) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING])) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING] + cg.add(var.set_temperature_oversampling(oversampling)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING])) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING])) cg.add(var.set_iir_filter(config[CONF_IIR_FILTER])) diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index f559d0aa9b1..86ff34a3492 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -72,18 +72,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await setup_bthome_mithermometer(var, config) - if temp_sens := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temp_sens) - cg.add(var.set_temperature(sens)) - if humi_sens := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humi_sens) - cg.add(var.set_humidity(sens)) - if batl_sens := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(batl_sens) - cg.add(var.set_battery_level(sens)) - if batv_sens := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(batv_sens) - cg.add(var.set_battery_voltage(sens)) - if sgnl_sens := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(sgnl_sens) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index bdb438a99de..342f4515093 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,8 +16,9 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, @@ -114,6 +115,13 @@ async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: return var +def sub_buttons( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_button.""" + return SubEntities(new_button, config, parent) + + BUTTON_PRESS_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Button), diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index faa2a376709..97d757c9d46 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -15,6 +15,7 @@ CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_COLUMNS = "columns" +CONF_CONVERSION_RATE = "conversion_rate" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" diff --git a/esphome/components/counter/__init__.py b/esphome/components/counter/__init__.py new file mode 100644 index 00000000000..c58ce8a01e8 --- /dev/null +++ b/esphome/components/counter/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/counter/counter_sensor.cpp b/esphome/components/counter/counter_sensor.cpp new file mode 100644 index 00000000000..22adb49cca7 --- /dev/null +++ b/esphome/components/counter/counter_sensor.cpp @@ -0,0 +1,28 @@ +#include "counter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::counter { + +static const char *const TAG = "counter"; + +void CounterSensor::setup() { + if (this->restore_) { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&this->value_); + } + this->publish_state(static_cast(this->value_)); +} + +void CounterSensor::set_value(int64_t value) { + this->value_ = value; + this->publish_state(static_cast(value)); + if (this->restore_) + this->pref_.save(&this->value_); +} + +void CounterSensor::dump_config() { + LOG_SENSOR("", "Counter", this); + ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_)); +} + +} // namespace esphome::counter diff --git a/esphome/components/counter/counter_sensor.h b/esphome/components/counter/counter_sensor.h new file mode 100644 index 00000000000..3468c4d5a04 --- /dev/null +++ b/esphome/components/counter/counter_sensor.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::counter { + +/// A counter held as a 64-bit integer. The published sensor state is a float, so it is exact only up to 2^24. +class CounterSensor final : public sensor::Sensor, public Component { + public: + /// The counter starts at initial_value unless a stored value is restored. + CounterSensor(bool restore, int64_t initial_value) : value_(initial_value), restore_(restore) {} + + void setup() override; + void dump_config() override; + // restore value before `on_boot` automations run + float get_setup_priority() const override { return setup_priority::DATA + 50.0f; } + + /// Increment by one each time the given sensor publishes a state. + void count_updates_from(sensor::Sensor *source) { + source->add_on_state_callback([this](float) { this->increment(); }); + } + + /// Increment by one each time the given binary sensor changes to true. + template void count_true_from(T *source) { + source->add_on_state_callback([this](bool state) { + if (state) + this->increment(); + }); + } + + void set_value(int64_t value); + /// overflow on addition of signed numbers is undefined - use the well defined unsigned version + void increment(int64_t amount = 1) { + this->set_value(static_cast(static_cast(this->value_) + static_cast(amount))); + } + + protected: + ESPPreferenceObject pref_; + int64_t value_; + bool restore_; +}; + +} // namespace esphome::counter diff --git a/esphome/components/counter/sensor.py b/esphome/components/counter/sensor.py new file mode 100644 index 00000000000..0f36b1721bf --- /dev/null +++ b/esphome/components/counter/sensor.py @@ -0,0 +1,85 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BINARY_SENSOR, + CONF_ID, + CONF_INITIAL_VALUE, + CONF_RESTORE, + CONF_SENSOR, + CONF_VALUE, + ICON_COUNTER, +) +from esphome.types import ConfigType + +counter_ns = cg.esphome_ns.namespace("counter") +CounterSensor = counter_ns.class_("CounterSensor", sensor.Sensor, cg.Component) + +# The lowest value is left out because its C++ literal cannot be written portably. +INT64_MAX = 2**63 - 1 +COUNTER_RANGE = cv.int_range(min=-INT64_MAX, max=INT64_MAX) +COUNTER_VALUE = cv.templatable(COUNTER_RANGE) + + +def _not_own_source(config: ConfigType) -> ConfigType: + if (source := config.get(CONF_SENSOR)) is not None and source.id == config[ + CONF_ID + ].id: + raise cv.Invalid("A counter cannot count its own updates", [CONF_SENSOR]) + return config + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + CounterSensor, + icon=ICON_COUNTER, + accuracy_decimals=0, + ) + .extend( + { + cv.Optional(CONF_RESTORE, default=True): cv.boolean, + cv.Optional(CONF_INITIAL_VALUE, default=0): COUNTER_RANGE, + cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_BINARY_SENSOR): cv.use_id(binary_sensor.BinarySensor), + } + ) + .extend(cv.COMPONENT_SCHEMA), + _not_own_source, +) + + +async def to_code(config): + var = cg.new_Pvariable( + config[CONF_ID], config[CONF_RESTORE], config[CONF_INITIAL_VALUE] + ) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + if (source := config.get(CONF_SENSOR)) is not None: + cg.add(var.count_updates_from(await cg.get_variable(source))) + if (source := config.get(CONF_BINARY_SENSOR)) is not None: + cg.add(var.count_true_from(await cg.get_variable(source))) + + +automation.register_apply_action( + "counter.set_value", + cv.maybe_simple_value( + { + cv.GenerateID(CONF_ID): cv.use_id(CounterSensor), + cv.Required(CONF_VALUE): COUNTER_VALUE, + }, + key=CONF_VALUE, + ), + automation.ApplyField(CONF_VALUE, "set_value", cg.int64), +) + +automation.register_apply_action( + "counter.increment", + automation.maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(CounterSensor), + cv.Optional(CONF_VALUE, default=1): COUNTER_VALUE, + } + ), + automation.ApplyField(CONF_VALUE, "increment", cg.int64), +) diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 9bed0f3f59e..86b6965e3a2 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -98,24 +98,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if voltage_config := config.get(CONF_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - if current_config := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(current_config) - cg.add(var.set_current_sensor(sens)) - if power_config := config.get(CONF_POWER): - sens = await sensor.new_sensor(power_config) - cg.add(var.set_power_sensor(sens)) - if energy_config := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(energy_config) - cg.add(var.set_energy_sensor(sens)) - if apparent_power_config := config.get(CONF_APPARENT_POWER): - sens = await sensor.new_sensor(apparent_power_config) - cg.add(var.set_apparent_power_sensor(sens)) - if reactive_power_config := config.get(CONF_REACTIVE_POWER): - sens = await sensor.new_sensor(reactive_power_config) - cg.add(var.set_reactive_power_sensor(sens)) - if power_factor_config := config.get(CONF_POWER_FACTOR): - sens = await sensor.new_sensor(power_factor_config) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index a889d13329b..e4692dfa9aa 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -51,7 +52,8 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("HWINFO", True) # gdb thread support zephyr_add_prj_conf("DEBUG_THREAD_INFO", True) - # RTT + # RTT sources live in the segger project + include_west_project("segger") zephyr_add_prj_conf("USE_SEGGER_RTT", True) zephyr_add_prj_conf("RTT_CONSOLE", True) zephyr_add_prj_conf("LOG", True) diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 80d1daa81f1..07c67a2b937 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -106,32 +106,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if free_conf := config.get(CONF_FREE): - sens = await sensor.new_sensor(free_conf) - cg.add(debug_component.set_free_sensor(sens)) - - if block_conf := config.get(CONF_BLOCK): - sens = await sensor.new_sensor(block_conf) - cg.add(debug_component.set_block_sensor(sens)) - - if fragmentation_conf := config.get(CONF_FRAGMENTATION): - sens = await sensor.new_sensor(fragmentation_conf) - cg.add(debug_component.set_fragmentation_sensor(sens)) - - if min_free_conf := config.get(CONF_MIN_FREE): - sens = await sensor.new_sensor(min_free_conf) - cg.add(debug_component.set_min_free_sensor(sens)) - - if loop_time_conf := config.get(CONF_LOOP_TIME): - sens = await sensor.new_sensor(loop_time_conf) - cg.add(debug_component.set_loop_time_sensor(sens)) - - if psram_conf := config.get(CONF_PSRAM): - sens = await sensor.new_sensor(psram_conf) - cg.add(debug_component.set_psram_sensor(sens)) - - if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): - sens = await sensor.new_sensor(cpu_freq_conf) - cg.add(debug_component.set_cpu_frequency_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREE, hub.set_free_sensor) + await sensors(CONF_BLOCK, hub.set_block_sensor) + await sensors(CONF_FRAGMENTATION, hub.set_fragmentation_sensor) + await sensors(CONF_MIN_FREE, hub.set_min_free_sensor) + await sensors(CONF_LOOP_TIME, hub.set_loop_time_sensor) + await sensors(CONF_PSRAM, hub.set_psram_sensor) + await sensors(CONF_CPU_FREQUENCY, hub.set_cpu_frequency_sensor) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index 2e02af67cbe..43ed0d15c62 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -31,11 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if CONF_DEVICE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DEVICE]) - cg.add(debug_component.set_device_info_sensor(sens)) - if CONF_RESET_REASON in config: - sens = await text_sensor.new_text_sensor(config[CONF_RESET_REASON]) - cg.add(debug_component.set_reset_reason_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DEVICE, hub.set_device_info_sensor) + await text_sensors(CONF_RESET_REASON, hub.set_reset_reason_sensor) diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 639847345f2..6cd122fb96e 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION from esphome.types import ConfigType @@ -13,7 +14,6 @@ MULTI_CONF = True CONF_PWM = "pwm" CONF_DIVIDER = "divider" CONF_DAC = "dac" -CONF_CONVERSION_RATE = "conversion_rate" CONF_EMC2101_ID = "emc2101_id" diff --git a/esphome/components/ezo_pmp/binary_sensor.py b/esphome/components/ezo_pmp/binary_sensor.py index a81b6c09f05..fcdb94b62b2 100644 --- a/esphome/components/ezo_pmp/binary_sensor.py +++ b/esphome/components/ezo_pmp/binary_sensor.py @@ -31,12 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_PUMP_STATE in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_PUMP_STATE]) - cg.add(parent.set_is_dosing(sens)) - - if CONF_IS_PAUSED in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_IS_PAUSED]) - cg.add(parent.set_is_paused(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PUMP_STATE, hub.set_is_dosing) + await binary_sensors(CONF_IS_PAUSED, hub.set_is_paused) diff --git a/esphome/components/ezo_pmp/sensor.py b/esphome/components/ezo_pmp/sensor.py index ed4efeeabcb..1ff214de78e 100644 --- a/esphome/components/ezo_pmp/sensor.py +++ b/esphome/components/ezo_pmp/sensor.py @@ -76,28 +76,12 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_CURRENT_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_CURRENT_VOLUME_DOSED]) - cg.add(parent.set_current_volume_dosed(sens)) - - if CONF_LAST_VOLUME_REQUESTED in config: - sens = await sensor.new_sensor(config[CONF_LAST_VOLUME_REQUESTED]) - cg.add(parent.set_last_volume_requested(sens)) - - if CONF_TOTAL_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_VOLUME_DOSED]) - cg.add(parent.set_total_volume_dosed(sens)) - - if CONF_ABSOLUTE_TOTAL_VOLUME_DOSED in config: - sens = await sensor.new_sensor(config[CONF_ABSOLUTE_TOTAL_VOLUME_DOSED]) - cg.add(parent.set_absolute_total_volume_dosed(sens)) - - if CONF_PUMP_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_PUMP_VOLTAGE]) - cg.add(parent.set_pump_voltage(sens)) - - if CONF_MAX_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_MAX_FLOW_RATE]) - cg.add(parent.set_max_flow_rate(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CURRENT_VOLUME_DOSED, hub.set_current_volume_dosed) + await sensors(CONF_LAST_VOLUME_REQUESTED, hub.set_last_volume_requested) + await sensors(CONF_TOTAL_VOLUME_DOSED, hub.set_total_volume_dosed) + await sensors(CONF_ABSOLUTE_TOTAL_VOLUME_DOSED, hub.set_absolute_total_volume_dosed) + await sensors(CONF_PUMP_VOLTAGE, hub.set_pump_voltage) + await sensors(CONF_MAX_FLOW_RATE, hub.set_max_flow_rate) diff --git a/esphome/components/ezo_pmp/text_sensor.py b/esphome/components/ezo_pmp/text_sensor.py index afae1e5188f..47651d0d1da 100644 --- a/esphome/components/ezo_pmp/text_sensor.py +++ b/esphome/components/ezo_pmp/text_sensor.py @@ -24,12 +24,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_DOSING_MODE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DOSING_MODE]) - cg.add(parent.set_dosing_mode(sens)) - - if CONF_CALIBRATION_STATUS in config: - sens = await text_sensor.new_text_sensor(config[CONF_CALIBRATION_STATUS]) - cg.add(parent.set_calibration_status(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DOSING_MODE, hub.set_dosing_mode) + await text_sensors(CONF_CALIBRATION_STATUS, hub.set_calibration_status) diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 3728710b1f9..af323e6b07d 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -177,33 +177,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) - if CONF_INVERTER_STATUS in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_STATUS]) - cg.add(var.set_inverter_status_sensor(sens)) - - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_grid_frequency_sensor(sens)) - - if CONF_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_ACTIVE_POWER]) - cg.add(var.set_grid_active_power_sensor(sens)) - - if CONF_PV_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_PV_ACTIVE_POWER]) - cg.add(var.set_pv_active_power_sensor(sens)) - - if CONF_ENERGY_PRODUCTION_DAY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY_PRODUCTION_DAY]) - cg.add(var.set_today_production_sensor(sens)) - - if CONF_TOTAL_ENERGY_PRODUCTION in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_ENERGY_PRODUCTION]) - cg.add(var.set_total_energy_production_sensor(sens)) - - if CONF_INVERTER_MODULE_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_MODULE_TEMP]) - cg.add(var.set_inverter_module_temp_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_INVERTER_STATUS, var.set_inverter_status_sensor) + await sensors(CONF_FREQUENCY, var.set_grid_frequency_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_grid_active_power_sensor) + await sensors(CONF_PV_ACTIVE_POWER, var.set_pv_active_power_sensor) + await sensors(CONF_ENERGY_PRODUCTION_DAY, var.set_today_production_sensor) + await sensors(CONF_TOTAL_ENERGY_PRODUCTION, var.set_total_energy_production_sensor) + await sensors(CONF_INVERTER_MODULE_TEMP, var.set_inverter_module_temp_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index efb7af2c3cc..d2db05381d8 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -229,65 +229,24 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_frequency_sensor(sens)) - - if CONF_ACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_ACTIVE_POWER]) - cg.add(var.set_active_power_sensor(sens)) - - if CONF_REACTIVE_POWER in config: - sens = await sensor.new_sensor(config[CONF_REACTIVE_POWER]) - cg.add(var.set_reactive_power_sensor(sens)) - - if CONF_ENERGY_PRODUCTION_DAY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY_PRODUCTION_DAY]) - cg.add(var.set_today_production_sensor(sens)) - - if CONF_TOTAL_ENERGY_PRODUCTION in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_ENERGY_PRODUCTION]) - cg.add(var.set_total_energy_production_sensor(sens)) - - if CONF_TOTAL_GENERATION_TIME in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_GENERATION_TIME]) - cg.add(var.set_total_generation_time_sensor(sens)) - - if CONF_TODAY_GENERATION_TIME in config: - sens = await sensor.new_sensor(config[CONF_TODAY_GENERATION_TIME]) - cg.add(var.set_today_generation_time_sensor(sens)) - - if CONF_INVERTER_MODULE_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_MODULE_TEMP]) - cg.add(var.set_inverter_module_temp_sensor(sens)) - - if CONF_INVERTER_INNER_TEMP in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_INNER_TEMP]) - cg.add(var.set_inverter_inner_temp_sensor(sens)) - - if CONF_INVERTER_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_INVERTER_BUS_VOLTAGE]) - cg.add(var.set_inverter_bus_voltage_sensor(sens)) - - if CONF_INSULATION_OF_PV_N_TO_GROUND in config: - sens = await sensor.new_sensor(config[CONF_INSULATION_OF_PV_N_TO_GROUND]) - cg.add(var.set_insulation_pv_n_to_ground_sensor(sens)) - - if CONF_GFCI_VALUE in config: - sens = await sensor.new_sensor(config[CONF_GFCI_VALUE]) - cg.add(var.set_gfci_value_sensor(sens)) - - if CONF_DCI_OF_R in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_R]) - cg.add(var.set_dci_of_r_sensor(sens)) - - if CONF_DCI_OF_S in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_S]) - cg.add(var.set_dci_of_s_sensor(sens)) - - if CONF_DCI_OF_T in config: - sens = await sensor.new_sensor(config[CONF_DCI_OF_T]) - cg.add(var.set_dci_of_t_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_ENERGY_PRODUCTION_DAY, var.set_today_production_sensor) + await sensors(CONF_TOTAL_ENERGY_PRODUCTION, var.set_total_energy_production_sensor) + await sensors(CONF_TOTAL_GENERATION_TIME, var.set_total_generation_time_sensor) + await sensors(CONF_TODAY_GENERATION_TIME, var.set_today_generation_time_sensor) + await sensors(CONF_INVERTER_MODULE_TEMP, var.set_inverter_module_temp_sensor) + await sensors(CONF_INVERTER_INNER_TEMP, var.set_inverter_inner_temp_sensor) + await sensors(CONF_INVERTER_BUS_VOLTAGE, var.set_inverter_bus_voltage_sensor) + await sensors( + CONF_INSULATION_OF_PV_N_TO_GROUND, var.set_insulation_pv_n_to_ground_sensor + ) + await sensors(CONF_GFCI_VALUE, var.set_gfci_value_sensor) + await sensors(CONF_DCI_OF_R, var.set_dci_of_r_sensor) + await sensors(CONF_DCI_OF_S, var.set_dci_of_s_sensor) + await sensors(CONF_DCI_OF_T, var.set_dci_of_t_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 384477be3df..5099dcc94ca 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -107,18 +107,11 @@ async def to_code(config: ConfigType) -> None: cf1 = await cg.gpio_pin_expression(config[CONF_CF1_PIN]) cg.add(var.set_cf1_pin(cf1)) - if CONF_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_VOLTAGE]) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_ENERGY]) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) cg.add(var.set_initial_mode(INITIAL_MODES[config[CONF_INITIAL_MODE]])) diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 2fa82b2710a..79fb81ea5f5 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -92,17 +92,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) # Remove before 2026.12.0 if CONF_AQI in config: diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 4aa2c79bb15..9ab7014e0e4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -1,6 +1,9 @@ #include "hoermann_hcp.h" +#include + #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hoermann_hcp { @@ -60,6 +63,61 @@ static bool is_moving(DoorState state) { } } +#ifdef USE_HOERMANN_HCP_IDENTITY +// The command byte of a status poll. Only its answer can carry a request. +static constexpr uint8_t STATUS_COMMAND = 0x03; +// A status answer with this code in the low byte of its second register asks the bus controller for a value, +// named in the high byte of the third. +static constexpr uint8_t ANSWER_REQUEST = 0x22; +static constexpr uint8_t REQUEST_SERIAL = 0x05; +static constexpr uint8_t REQUEST_FIRMWARE = 0x06; +// Each request goes out in one answer, up to this many times, this far apart. +static constexpr uint8_t IDENTITY_MAX_ATTEMPTS = 3; +static constexpr uint32_t IDENTITY_RETRY_MS = 30000; +// The value comes back as a payload transfer: this command in the low byte of the first command register, a sub +// code in the high byte of the second, the payload from the third on. +static constexpr uint8_t TRANSFER_COMMAND = 0x04; +static constexpr uint8_t TRANSFER_SUB_SERIAL = 0x0C; +static constexpr uint8_t TRANSFER_SUB_FIRMWARE = 0x0D; +static constexpr uint8_t TRANSFER_ACK = 0xFD; +static constexpr size_t TRANSFER_PAYLOAD_REG = 2; +// Marks the first half of the serial number in the counter byte, and is not part of the count. +static constexpr uint8_t COUNTER_FIRST_HALF = 0x80; +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. It is as long +// as a second half, so one copy path serves both. +static constexpr size_t SERIAL_SINGLE_FRAME_REGS = 6; +static_assert(SERIAL_SINGLE_FRAME_REGS == SERIAL_SECOND_HALF_REGS, "one copy path serves both serial frames"); + +// Registers hold two payload bytes each, high byte first. +static void copy_payload(const modbus::RegisterValues ®isters, size_t count, char *out) { + for (size_t i = 0; i < count; i++) { + const uint16_t value = registers[TRANSFER_PAYLOAD_REG + i]; + out[2 * i] = static_cast(value >> 8); + out[2 * i + 1] = static_cast(value); + } +} + +// Length of the printable text at the start, without trailing spaces. The padding after it varies. +static size_t text_length(const char *text, size_t len) { + size_t at = 0; + while (at < len && text[at] >= 0x20 && text[at] <= 0x7E) + at++; + while (at > 0 && text[at - 1] == ' ') + at--; + return at; +} + +static void terminate_text(char *text, size_t len) { text[text_length(text, len)] = '\0'; } + +// dump_config() is replayed to remote log clients, so they see the outcome of the exchange at boot. +static void log_identity_value(text_sensor::TextSensor *sensor) { + if (sensor != nullptr) { + ESP_LOGCONFIG(TAG, " Value: %s", + sensor->has_state() ? sensor->get_state().c_str() : LOG_STR_LITERAL("not received")); + } +} +#endif + void HoermannHcp::update() { const uint32_t now = millis(); // Time out the connection flag if the bus controller stopped polling. @@ -91,6 +149,9 @@ void HoermannHcp::update() { ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); this->forget_light_toggles_(); } +#ifdef USE_HOERMANN_HCP_IDENTITY + this->publish_identity_(); +#endif if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -102,6 +163,12 @@ void HoermannHcp::dump_config() { "Hoermann HCP bridge:\n" " Modbus server address: 0x%02X", this->get_address()); +#ifdef USE_HOERMANN_HCP_IDENTITY + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_text_sensor_); + log_identity_value(this->serial_number_text_sensor_); + LOG_TEXT_SENSOR(" ", "Firmware Version", this->version_text_sensor_); + log_identity_value(this->version_text_sensor_); +#endif } modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, @@ -113,6 +180,14 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add this->record_response_(); +#ifdef USE_HOERMANN_HCP_IDENTITY + // Acknowledge the transfer taken by the write half of this frame. + if (this->transfer_answer_counter_ != NO_TRANSFER_ANSWER) { + this->push_transfer_answer_(registers, number_of_registers); + return {}; + } +#endif + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message // counter (high byte) and command (low byte). The read length identifies which internal block is requested. const uint16_t counter = this->command_reg_value_ & 0xFF00; @@ -125,6 +200,9 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add registers.push_back(static_cast(0x0001 | command)); this->push_command_registers_(registers); push_zeros(registers, 4); +#ifdef USE_HOERMANN_HCP_IDENTITY + this->add_identity_request_(registers, command); +#endif break; case 2: // Empty command request. @@ -156,6 +234,9 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, // command byte back from STATE_REG. The hub always runs the write before the read within one request. this->record_response_(); this->command_reg_value_ = registers[0]; +#ifdef USE_HOERMANN_HCP_IDENTITY + this->transfer_answer_counter_ = this->take_identity_transfer_(registers); +#endif return {}; } @@ -217,6 +298,155 @@ void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { registers.push_back(command->released_value_2); } +#ifdef USE_HOERMANN_HCP_IDENTITY +void HoermannHcp::add_identity_request_(modbus::RegisterValues ®isters, uint16_t command) { + if (static_cast(this->command_reg_value_) != STATUS_COMMAND) + return; + // Like Hoermann's own bus accessory, only after one ordinary answer. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE) { + if (this->serial_number_text_sensor_ != nullptr || this->version_text_sensor_ != nullptr) + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_SERIAL); + return; + } + // Uses the registers of a key press, so it waits while one is pending. + if (this->next_command_ != nullptr || registers[2] != 0 || registers[3] != 0 || !this->take_identity_request_()) + return; + registers[1] = static_cast(ANSWER_REQUEST | command); + registers[2] = encode_uint16(this->identity_request_(), 0); +} + +void HoermannHcp::arm_identity_request_(IdentityPhase phase) { + this->identity_phase_ = phase; + this->identity_attempts_ = 0; +} + +bool HoermannHcp::take_identity_request_() { + const uint8_t request = this->identity_request_(); + if (request == 0) + return false; + const uint32_t now = millis(); + if (this->identity_attempts_ != 0 && now - this->identity_asked_at_ <= IDENTITY_RETRY_MS) + return false; + if (this->identity_attempts_ >= IDENTITY_MAX_ATTEMPTS) { + this->identity_unanswered_ = request; + if (request == REQUEST_SERIAL) { + // Still ask for the firmware version, and drop a leftover first half. + this->serial_number_[0] = '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + } else { + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + } + return false; + } + this->identity_attempts_++; + this->identity_asked_at_ = now; + return true; +} + +uint8_t HoermannHcp::take_identity_transfer_(const modbus::RegisterValues ®isters) { + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_IDLE || registers.size() < TRANSFER_PAYLOAD_REG || + static_cast(registers[0]) != TRANSFER_COMMAND) + return NO_TRANSFER_ANSWER; + const uint8_t counter = static_cast(registers[0] >> 8); + const uint8_t sub_code = static_cast(registers[1] >> 8); + if (sub_code != TRANSFER_SUB_SERIAL && sub_code != TRANSFER_SUB_FIRMWARE) + return NO_TRANSFER_ANSWER; + // Acknowledged whether kept or not, as Hoermann's own bus accessory does. What was not kept is asked for again. + const uint8_t answer = counter & ~COUNTER_FIRST_HALF; + // Only an answer to the request that went out is kept. + const uint8_t request = sub_code == TRANSFER_SUB_SERIAL ? REQUEST_SERIAL : REQUEST_FIRMWARE; + if (this->identity_attempts_ == 0 || this->identity_request_() != request) + return answer; + + const size_t payload_regs = registers.size() - TRANSFER_PAYLOAD_REG; + if (request == REQUEST_FIRMWARE) { + const size_t regs = std::min(payload_regs, FIRMWARE_REGS); + copy_payload(registers, regs, this->firmware_version_); + if (regs == FIRMWARE_REGS && text_length(this->firmware_version_, 2 * regs) != 0) { + terminate_text(this->firmware_version_, 2 * regs); + // Clear an earlier unreadable answer, so this one is shown. + this->firmware_unreadable_ = false; + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + // Left as received for update() to log. + this->firmware_unreadable_ = true; + this->firmware_unreadable_len_ = 2 * regs; + // A short answer is asked for again; unreadable text would come back the same. + if (regs == FIRMWARE_REGS) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_DONE; + return answer; + } + if ((counter & COUNTER_FIRST_HALF) != 0) { + // Even a half too short to keep says the number comes in two halves; a first half already kept stays kept. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL) + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT; + if (payload_regs >= SERIAL_FIRST_HALF_REGS) { + copy_payload(registers, SERIAL_FIRST_HALF_REGS, this->serial_number_); + this->identity_phase_ = IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF; + } + return answer; + } + // Without a half marker seen, the frame is the whole number; after one, it can only be the second half. + if (this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SPLIT || payload_regs < SERIAL_SINGLE_FRAME_REGS) + return answer; + const size_t at = + this->identity_phase_ == IdentityPhase::IDENTITY_PHASE_SERIAL_SECOND_HALF ? 2 * SERIAL_FIRST_HALF_REGS : 0; + copy_payload(registers, SERIAL_SINGLE_FRAME_REGS, this->serial_number_ + at); + terminate_text(this->serial_number_, at + 2 * SERIAL_SINGLE_FRAME_REGS); + this->serial_unreadable_ = this->serial_number_[0] == '\0'; + this->arm_identity_request_(IdentityPhase::IDENTITY_PHASE_FIRMWARE); + return answer; +} + +void HoermannHcp::push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers) { + const uint16_t answer[] = {encode_uint16(this->transfer_answer_counter_, 0), + encode_uint16(TRANSFER_COMMAND, TRANSFER_ACK)}; + this->transfer_answer_counter_ = NO_TRANSFER_ANSWER; + for (uint16_t i = 0; i < number_of_registers; i++) + registers.push_back(i < 2 ? answer[i] : 0x0000); +} + +void HoermannHcp::publish_identity_() { + // Buffers are cleared once published, so each value goes out once. The serial number is whole once the firmware + // version is requested. + if (this->serial_number_text_sensor_ != nullptr && this->identity_request_() != REQUEST_SERIAL && + this->serial_number_[0] != '\0') { + this->serial_number_text_sensor_->publish_state(this->serial_number_); + this->serial_number_[0] = '\0'; + } + if (this->serial_unreadable_) { + this->serial_unreadable_ = false; + ESP_LOGW(TAG, "Unreadable serial number"); + } + // Checked first: the buffer then holds raw bytes, not text. + if (this->firmware_unreadable_) { + this->firmware_unreadable_ = false; + const uint8_t len = this->firmware_unreadable_len_; + // All zeros: the motor does not report a version (index B1 seen). + if (len == 2 * FIRMWARE_REGS && + std::all_of(this->firmware_version_, this->firmware_version_ + len, [](char c) { return c == '\0'; })) { + ESP_LOGD(TAG, "Motor does not report its firmware version"); + } else { + char hex[format_hex_size(2 * FIRMWARE_REGS)]; + ESP_LOGW(TAG, "Unreadable firmware version (%u bytes): %s", len, + format_hex_to(hex, reinterpret_cast(this->firmware_version_), len)); + } + this->firmware_version_[0] = '\0'; + } + if (this->version_text_sensor_ != nullptr && this->firmware_version_[0] != '\0') { + this->version_text_sensor_->publish_state(this->firmware_version_); + this->firmware_version_[0] = '\0'; + } + if (this->identity_unanswered_ != 0) { + ESP_LOGW(TAG, "No usable %s received", + this->identity_unanswered_ == REQUEST_SERIAL ? LOG_STR_LITERAL("serial number") + : LOG_STR_LITERAL("firmware version")); + this->identity_unanswered_ = 0; + } +} +#endif + void HoermannHcp::on_position_reg_(uint16_t value) { // Low byte: current position. const uint8_t position = static_cast(value); @@ -371,6 +601,9 @@ void HoermannHcp::set_valid_(bool valid) { this->forget_light_toggles_(); // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. this->set_light_seen_(false); + // The same holds for the door. The next broadcast is decoded even if it repeats the last one. + this->door_state_seen_ = false; + this->prev_state_reg_ = 0xFFFF; this->short_broadcast_logged_ = false; } @@ -410,6 +643,11 @@ void HoermannHcp::forget_light_toggles_() { } void HoermannHcp::set_door_state_(DoorState state) { + // The first state may equal the default, so being seen is a change of its own. + if (!this->door_state_seen_) { + this->door_state_seen_ = true; + this->changed_ = true; + } if (this->door_state_ == state) return; this->door_state_ = state; diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 83be385c7b1..9ebb717d0c4 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -4,7 +4,11 @@ #include "esphome/components/modbus/modbus.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#ifdef USE_HOERMANN_HCP_IDENTITY +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::hoermann_hcp { @@ -21,6 +25,28 @@ enum class DoorState : uint8_t { STOPPED, }; +#ifdef USE_HOERMANN_HCP_IDENTITY +// Payload registers of each value, two bytes each. +static constexpr size_t SERIAL_FIRST_HALF_REGS = 7; +static constexpr size_t SERIAL_SECOND_HALF_REGS = 6; +static constexpr size_t FIRMWARE_REGS = 6; +// Counter of the transfer the read half of the frame acknowledges. Real counters have the 0x80 half marker +// stripped, so this value never occurs. +static constexpr uint8_t NO_TRANSFER_ANSWER = 0xFF; + +// Where the identity exchange stands. The low nibble is the code the motor is asked with, 0 while nothing is +// outstanding. Like Hoermann's own bus accessory, the first status answer stays ordinary. Older motors (index B1 +// seen) send the whole serial number in one frame, without the half marker. +enum class IdentityPhase : uint8_t { + IDENTITY_PHASE_IDLE = 0x00, + IDENTITY_PHASE_DONE = 0x10, + IDENTITY_PHASE_SERIAL = 0x05, // A plain frame is the whole serial number. + IDENTITY_PHASE_SERIAL_SPLIT = 0x15, // A half marker was seen, so a plain frame can only be the second half. + IDENTITY_PHASE_SERIAL_SECOND_HALF = 0x25, // The first half is in. + IDENTITY_PHASE_FIRMWARE = 0x06, +}; +#endif + // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a // short delay the released value. Each half also carries a second register, which names the buttons that do // not fit into the first. @@ -35,6 +61,12 @@ struct HoermannHcpCommand { }; class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { +#ifdef USE_HOERMANN_HCP_IDENTITY + // The motor is asked for these only when one of them is configured. + SUB_TEXT_SENSOR(serial_number) + SUB_TEXT_SENSOR(version) +#endif + public: void update() override; void dump_config() override; @@ -63,6 +95,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool toggle_light(); DoorState get_door_state() const { return this->door_state_; } + // False until a broadcast has carried a state the door is known to report. Bus traffic alone makes the + // connection valid, so get_door_state() would still be its default. + bool is_door_state_known() const { return this->door_state_seen_; } float get_current_position() const { return this->current_position_; } bool is_valid() const { return this->valid_; } bool is_light_on() const { return this->light_on_; } @@ -95,6 +130,21 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { void on_position_reg_(uint16_t value); void on_state_reg_(uint16_t value); void on_light_reg_(uint16_t value); +#ifdef USE_HOERMANN_HCP_IDENTITY + // Puts a due request into a status answer. + void add_identity_request_(modbus::RegisterValues ®isters, uint16_t command); + void arm_identity_request_(IdentityPhase phase); + uint8_t identity_request_() const { return static_cast(this->identity_phase_) & 0x0F; } + // True when the request is due, counting the attempt. Gives up after the last one. + bool take_identity_request_(); + // Takes a value the motor hands over as a payload transfer. Returns the counter to acknowledge it with, kept or + // not, or NO_TRANSFER_ANSWER when the frame was something else. + uint8_t take_identity_transfer_(const modbus::RegisterValues ®isters); + // Acknowledges the transfer taken by the write half of the same frame. + void push_transfer_answer_(modbus::RegisterValues ®isters, uint16_t number_of_registers); + // Runs from update(), outside the bus callbacks. + void publish_identity_(); +#endif void set_valid_(bool valid); void set_door_state_(DoorState state); @@ -145,7 +195,24 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool changed_{false}; bool light_on_{false}; bool light_seen_{false}; + bool door_state_seen_{false}; bool short_broadcast_logged_{false}; + +#ifdef USE_HOERMANN_HCP_IDENTITY + uint32_t identity_asked_at_{0}; + IdentityPhase identity_phase_{IdentityPhase::IDENTITY_PHASE_IDLE}; + uint8_t identity_attempts_{0}; + // The request given up on, for update() to report. + uint8_t identity_unanswered_{0}; + uint8_t transfer_answer_counter_{NO_TRANSFER_ANSWER}; + // Length of an unreadable firmware version left in firmware_version_. + uint8_t firmware_unreadable_len_{0}; + // A serial number arrived without text, for update() to log. + bool serial_unreadable_{false}; + bool firmware_unreadable_{false}; + char serial_number_[2 * (SERIAL_FIRST_HALF_REGS + SERIAL_SECOND_HALF_REGS) + 1]{}; + char firmware_version_[2 * FIRMWARE_REGS + 1]{}; +#endif }; } // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/__init__.py b/esphome/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 00000000000..2f038025b9a --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,50 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_DOOR_STATE = "door_state" +CONF_SERIAL_NUMBER = "serial_number" + +HoermannHcpDoorStateTextSensor = hoermann_hcp_ns.class_( + "HoermannHcpDoorStateTextSensor", text_sensor.TextSensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_DOOR_STATE): text_sensor.text_sensor_schema( + HoermannHcpDoorStateTextSensor, icon="mdi:garage" + ).extend(cv.COMPONENT_SCHEMA), + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema( + icon="mdi:data-matrix", entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + icon=ICON_CHIP, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ), + cv.has_at_least_one_key(CONF_DOOR_STATE, CONF_SERIAL_NUMBER, CONF_VERSION), +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + if (conf := config.get(CONF_DOOR_STATE)) is not None: + var = await text_sensor.new_text_sensor(conf, parent) + await cg.register_component(var, conf) + # Only the identity sensors need the exchange with the motor compiled in. + if CONF_SERIAL_NUMBER in config or CONF_VERSION in config: + cg.add_define("USE_HOERMANN_HCP_IDENTITY") + if (conf := config.get(CONF_SERIAL_NUMBER)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_serial_number_text_sensor(sens)) + if (conf := config.get(CONF_VERSION)) is not None: + sens = await text_sensor.new_text_sensor(conf) + cg.add(parent.set_version_text_sensor(sens)) diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp new file mode 100644 index 00000000000..765a80ae6f3 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.cpp @@ -0,0 +1,49 @@ +#include "hoermann_hcp_text_sensor.h" + +#include + +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.text_sensor"; + +// Indexed by DoorState. Each fits the 15 characters std::string keeps inline, so publishing never allocates. +PROGMEM_STRING_TABLE(DoorStateStrings, "Open", "Opening", "Closed", "Closing", "Half open", "Moving to vent", + "Vent position", "Moving to half", "Stopped"); +static_assert(DoorStateStrings::COUNT == static_cast(DoorState::STOPPED) + 1, + "DoorStateStrings must list every DoorState in order"); + +void HoermannHcpDoorStateTextSensor::setup() { + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); + // A state decoded before the callback was added would otherwise wait for the next change. + this->update_from_state_(); +} + +void HoermannHcpDoorStateTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Hoermann HCP Door State", this); } + +void HoermannHcpDoorStateTextSensor::update_from_state_() { + // The last state stays while the bus controller is gone, and is published again once it is back. + if (!this->parent_->is_valid()) { + this->published_ = false; + return; + } + if (!this->parent_->is_door_state_known()) + return; + // Any hub change runs this, so only a new door state is published. + const DoorState state = this->parent_->get_door_state(); + if (this->published_ && state == this->published_state_) + return; + this->published_ = true; + this->published_state_ = state; + char text[16]; + ESPHOME_strncpy_P(text, + reinterpret_cast( + DoorStateStrings::get_progmem_str(static_cast(state), DoorStateStrings::LAST_INDEX)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->publish_state(text); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h new file mode 100644 index 00000000000..a3fb91cd9a8 --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpDoorStateTextSensor : public text_sensor::TextSensor, public Component { + public: + explicit HoermannHcpDoorStateTextSensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + void update_from_state_(); + + HoermannHcp *const parent_; + DoorState published_state_{DoorState::CLOSED}; + bool published_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index bd074ab6b58..16e4c81d344 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -1,3 +1,5 @@ +import sys + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -8,9 +10,9 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_HOST, ThreadModel, + Toolchain, ) -from esphome.core import CORE -from esphome.platformio.toolchain import copy_ccache_script +from esphome.core import CORE, EsphomeError from esphome.types import ConfigType from .const import KEY_HOST @@ -37,7 +39,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address, } ), - cv.require_platformio_toolchain("host"), + # The host builds with the machine's own compiler through ninja; there + # is no PlatformIO backend, so a CLI --toolchain must name this one + cv.resolve_toolchain("host", (Toolchain.HOST,), Toolchain.HOST), set_core_data, ) @@ -48,16 +52,23 @@ async def to_code(config: ConfigType) -> None: # The prefs file finds stored preferences by key, so key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts) - cg.add_build_flag("-std=gnu++20") + cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", "host") cg.add_define("ESPHOME_VARIANT", "HOST") cg.add_define(ThreadModel.MULTI_ATOMICS) - cg.add_platformio_option("platform", "platformio/native") - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) -# Called by writer.py -def copy_files() -> None: - copy_ccache_script() +# Called by __main__.compile_program; True means this platform built the +# program itself instead of falling through to the PlatformIO toolchain. +def run_compile(args: object, config: ConfigType) -> bool: + from esphome.host import toolchain + + if sys.platform == "win32": + # Here, not in validation: `esphome config` must keep working + raise EsphomeError( + "The host platform uses POSIX interfaces and does not build on " + "Windows; use WSL or a Linux or macOS machine" + ) + if toolchain.run_compile(config, CORE.verbose) != 0: + raise EsphomeError("Host build failed") + return True diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 97482f81a01..0a81adc785e 100644 --- a/esphome/components/ina219/sensor.py +++ b/esphome/components/ina219/sensor.py @@ -80,18 +80,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_max_current_a(config[CONF_MAX_CURRENT])) cg.add(var.set_max_voltage_v(config[CONF_MAX_VOLTAGE])) - if CONF_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BUS_VOLTAGE]) - cg.add(var.set_bus_voltage_sensor(sens)) - - if CONF_SHUNT_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_SHUNT_VOLTAGE]) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index 4fd98fbcd40..af1793450c9 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -133,18 +133,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_adc_avg_samples(config[CONF_ADC_AVERAGING])) - if CONF_BUS_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BUS_VOLTAGE]) - cg.add(var.set_bus_voltage_sensor(sens)) - - if CONF_SHUNT_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_SHUNT_VOLTAGE]) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if CONF_CURRENT in config: - sens = await sensor.new_sensor(config[CONF_CURRENT]) - cg.add(var.set_current_sensor(sens)) - - if CONF_POWER in config: - sens = await sensor.new_sensor(config[CONF_POWER]) - cg.add(var.set_power_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 474283ccce1..b6cf0e38f30 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -225,38 +225,13 @@ async def setup_ina2xx(var: MockObj, config: ConfigType) -> None: cg.add(var.set_adc_time_shunt_voltage(adc_time_config)) cg.add(var.set_adc_time_die_temperature(adc_time_config)) - if conf := config.get(CONF_SHUNT_VOLTAGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_shunt_voltage_sensor(sens)) - - if conf := config.get(CONF_BUS_VOLTAGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_bus_voltage_sensor(sens)) - - if conf := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_die_temperature_sensor(sens)) - - if conf := config.get(CONF_CURRENT): - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - - if conf := config.get(CONF_POWER): - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - - if conf := config.get(CONF_ENERGY): - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor_wh(sens)) - - if conf := config.get(CONF_ENERGY_JOULES): - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor_j(sens)) - - if conf := config.get(CONF_CHARGE): - sens = await sensor.new_sensor(conf) - cg.add(var.set_charge_sensor_ah(sens)) - - if conf := config.get(CONF_CHARGE_COULOMBS): - sens = await sensor.new_sensor(conf) - cg.add(var.set_charge_sensor_c(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_SHUNT_VOLTAGE, var.set_shunt_voltage_sensor) + await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor) + await sensors(CONF_TEMPERATURE, var.set_die_temperature_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor_wh) + await sensors(CONF_ENERGY_JOULES, var.set_energy_sensor_j) + await sensors(CONF_CHARGE, var.set_charge_sensor_ah) + await sensors(CONF_CHARGE_COULOMBS, var.set_charge_sensor_c) diff --git a/esphome/components/ld2410/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index 2b687335320..f764e89f9b6 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -48,16 +48,11 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(ld2410_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(ld2410_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(ld2410_component.set_still_target_binary_sensor(sens)) - if out_pin_presence_status_config := config.get(CONF_OUT_PIN_PRESENCE_STATUS): - sens = await binary_sensor.new_binary_sensor(out_pin_presence_status_config) - cg.add(ld2410_component.set_out_pin_presence_status_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) + await binary_sensors( + CONF_OUT_PIN_PRESENCE_STATUS, hub.set_out_pin_presence_status_binary_sensor + ) diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index 59a95583318..3826455986d 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -46,16 +46,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_factory_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_restart_button(b)) - if query_params_config := config.get(CONF_QUERY_PARAMS): - b = await button.new_button(query_params_config) - await cg.register_parented(b, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_query_button(b)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons(CONF_QUERY_PARAMS, hub.set_query_button) diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number/__init__.py index 3500d704a1e..b1891a5935a 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number/__init__.py @@ -87,31 +87,32 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number( - timeout_config, min_value=0, max_value=65535, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_timeout_number(n)) - if max_move_distance_gate_config := config.get(CONF_MAX_MOVE_DISTANCE_GATE): - n = await number.new_number( - max_move_distance_gate_config, min_value=2, max_value=8, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_max_move_distance_gate_number(n)) - if max_still_distance_gate_config := config.get(CONF_MAX_STILL_DISTANCE_GATE): - n = await number.new_number( - max_still_distance_gate_config, min_value=2, max_value=8, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_max_still_distance_gate_number(n)) - if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): - n = await number.new_number( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_light_threshold_number(n)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_TIMEOUT, hub.set_timeout_number, min_value=0, max_value=65535, step=1 + ) + await numbers( + CONF_MAX_MOVE_DISTANCE_GATE, + hub.set_max_move_distance_gate_number, + min_value=2, + max_value=8, + step=1, + ) + await numbers( + CONF_MAX_STILL_DISTANCE_GATE, + hub.set_max_still_distance_gate_number, + min_value=2, + max_value=8, + step=1, + ) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) for x in range(9): if gate_conf := config.get(f"g{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] @@ -119,13 +120,13 @@ async def to_code(config: ConfigType) -> None: await number.register_number( n, move_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_gate_move_threshold_number(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] n = cg.new_Pvariable(still_config[CONF_ID], x) await number.register_number( n, still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_gate_still_threshold_number(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select/__init__.py index e89e3d59976..936ad83d39d 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -50,36 +50,32 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): - s = await select.new_select( - distance_resolution_config, options=["0.2m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_distance_resolution_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_out_pin_level_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_light_function_select(s)) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_baud_rate_select(s)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.75m"], + ) + await selects( + CONF_OUT_PIN_LEVEL, hub.set_out_pin_level_select, options=["low", "high"] + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) diff --git a/esphome/components/ld2410/sensor.py b/esphome/components/ld2410/sensor.py index ca42b3a1d30..5b523d09442 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -157,30 +157,19 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if moving_distance_config := config.get(CONF_MOVING_DISTANCE): - sens = await sensor.new_sensor(moving_distance_config) - cg.add(ld2410_component.set_moving_target_distance_sensor(sens)) - if still_distance_config := config.get(CONF_STILL_DISTANCE): - sens = await sensor.new_sensor(still_distance_config) - cg.add(ld2410_component.set_still_target_distance_sensor(sens)) - if moving_energy_config := config.get(CONF_MOVING_ENERGY): - sens = await sensor.new_sensor(moving_energy_config) - cg.add(ld2410_component.set_moving_target_energy_sensor(sens)) - if still_energy_config := config.get(CONF_STILL_ENERGY): - sens = await sensor.new_sensor(still_energy_config) - cg.add(ld2410_component.set_still_target_energy_sensor(sens)) - if light_config := config.get(CONF_LIGHT): - sens = await sensor.new_sensor(light_config) - cg.add(ld2410_component.set_light_sensor(sens)) - if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): - sens = await sensor.new_sensor(detection_distance_config) - cg.add(ld2410_component.set_detection_distance_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOVING_DISTANCE, hub.set_moving_target_distance_sensor) + await sensors(CONF_STILL_DISTANCE, hub.set_still_target_distance_sensor) + await sensors(CONF_MOVING_ENERGY, hub.set_moving_target_energy_sensor) + await sensors(CONF_STILL_ENERGY, hub.set_still_target_energy_sensor) + await sensors(CONF_LIGHT, hub.set_light_sensor) + await sensors(CONF_DETECTION_DISTANCE, hub.set_detection_distance_sensor) for x in range(9): if gate_conf := config.get(f"g{x}"): if move_config := gate_conf.get(CONF_MOVE_ENERGY): sens = await sensor.new_sensor(move_config) - cg.add(ld2410_component.set_gate_move_sensor(x, sens)) + cg.add(hub.set_gate_move_sensor(x, sens)) if still_config := gate_conf.get(CONF_STILL_ENERGY): sens = await sensor.new_sensor(still_config) - cg.add(ld2410_component.set_gate_still_sensor(x, sens)) + cg.add(hub.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch/__init__.py index 6d8053ddd60..646890d43f5 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): - s = await switch.new_switch(engineering_mode_config) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_engineering_mode_switch(s)) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2410_ID]) - cg.add(ld2410_component.set_bluetooth_switch(s)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) diff --git a/esphome/components/ld2410/text_sensor.py b/esphome/components/ld2410/text_sensor.py index 25c61a4825f..8a4e5186ef4 100644 --- a/esphome/components/ld2410/text_sensor.py +++ b/esphome/components/ld2410/text_sensor.py @@ -28,10 +28,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(ld2410_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(ld2410_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2410_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) diff --git a/esphome/components/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 80cff014c0d..9e937275499 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -50,24 +50,12 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if dynamic_background_correction_status_config := config.get( - CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS - ): - sens = await binary_sensor.new_binary_sensor( - dynamic_background_correction_status_config - ) - cg.add( - LD2412_component.set_dynamic_background_correction_status_binary_sensor( - sens - ) - ) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(LD2412_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(LD2412_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(LD2412_component.set_still_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors( + CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS, + hub.set_dynamic_background_correction_status_binary_sensor, + ) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index 5a1ea2e6a5d..9f5c868310b 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -56,22 +56,12 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_factory_reset_button(b)) - if query_params_config := config.get(CONF_QUERY_PARAMS): - b = await button.new_button(query_params_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_query_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_restart_button(b)) - if start_dynamic_background_correction_config := config.get( - CONF_START_DYNAMIC_BACKGROUND_CORRECTION - ): - b = await button.new_button(start_dynamic_background_correction_config) - await cg.register_parented(b, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_start_dynamic_background_correction_button(b)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_QUERY_PARAMS, hub.set_query_button) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons( + CONF_START_DYNAMIC_BACKGROUND_CORRECTION, + hub.set_start_dynamic_background_correction_button, + ) diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index f27e241491d..0c0c01c69cc 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -87,42 +87,45 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): - n = await number.new_number( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_threshold_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_DISTANCE_GATE): - n = await number.new_number( - max_distance_gate_config, min_value=2, max_value=13, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_max_distance_gate_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_DISTANCE_GATE): - n = await number.new_number( - min_distance_gate_config, min_value=1, max_value=12, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_min_distance_gate_number(n)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) + await numbers( + CONF_MAX_DISTANCE_GATE, + hub.set_max_distance_gate_number, + min_value=2, + max_value=13, + step=1, + ) + await numbers( + CONF_MIN_DISTANCE_GATE, + hub.set_min_distance_gate_number, + min_value=1, + max_value=12, + step=1, + ) for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID]) - await number.register_number( - n, move_config, min_value=0, max_value=100, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) + n = await number.new_number(move_config, min_value=0, max_value=100, step=1) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID]) - await number.register_number( - n, still_config, min_value=0, max_value=100, step=1 + n = await number.new_number( + still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_still_threshold_number(x, n)) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number(timeout_config, min_value=0, max_value=900, step=1) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_timeout_number(n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) + await numbers( + CONF_TIMEOUT, + hub.set_timeout_number, + min_value=0, + max_value=900, + step=1, + ) diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 02ecf2c30f5..40a075e5d54 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -50,36 +50,34 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_baud_rate_select(s)) - if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): - s = await select.new_select( - distance_resolution_config, options=["0.2m", "0.5m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_distance_resolution_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_function_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_out_pin_level_select(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.5m", "0.75m"], + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_OUT_PIN_LEVEL, + hub.set_out_pin_level_select, + options=["low", "high"], + ) diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index 0b6e676931d..e236a7d5f38 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -158,30 +158,19 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): - sens = await sensor.new_sensor(detection_distance_config) - cg.add(LD2412_component.set_detection_distance_sensor(sens)) - if light_config := config.get(CONF_LIGHT): - sens = await sensor.new_sensor(light_config) - cg.add(LD2412_component.set_light_sensor(sens)) - if moving_distance_config := config.get(CONF_MOVING_DISTANCE): - sens = await sensor.new_sensor(moving_distance_config) - cg.add(LD2412_component.set_moving_target_distance_sensor(sens)) - if moving_energy_config := config.get(CONF_MOVING_ENERGY): - sens = await sensor.new_sensor(moving_energy_config) - cg.add(LD2412_component.set_moving_target_energy_sensor(sens)) - if still_distance_config := config.get(CONF_STILL_DISTANCE): - sens = await sensor.new_sensor(still_distance_config) - cg.add(LD2412_component.set_still_target_distance_sensor(sens)) - if still_energy_config := config.get(CONF_STILL_ENERGY): - sens = await sensor.new_sensor(still_energy_config) - cg.add(LD2412_component.set_still_target_energy_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DETECTION_DISTANCE, hub.set_detection_distance_sensor) + await sensors(CONF_LIGHT, hub.set_light_sensor) + await sensors(CONF_MOVING_DISTANCE, hub.set_moving_target_distance_sensor) + await sensors(CONF_MOVING_ENERGY, hub.set_moving_target_energy_sensor) + await sensors(CONF_STILL_DISTANCE, hub.set_still_target_distance_sensor) + await sensors(CONF_STILL_ENERGY, hub.set_still_target_energy_sensor) for x in range(14): if gate_conf := config.get(f"gate_{x}"): if move_config := gate_conf.get(CONF_MOVE_ENERGY): sens = await sensor.new_sensor(move_config) - cg.add(LD2412_component.set_gate_move_sensor(x, sens)) + cg.add(hub.set_gate_move_sensor(x, sens)) if still_config := gate_conf.get(CONF_STILL_ENERGY): sens = await sensor.new_sensor(still_config) - cg.add(LD2412_component.set_gate_still_sensor(x, sens)) + cg.add(hub.set_gate_still_sensor(x, sens)) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index e7f71222fdb..d0643055972 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_bluetooth_switch(s)) - if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): - s = await switch.new_switch(engineering_mode_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_engineering_mode_switch(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index c8e9f42ef39..63856d9f002 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -28,10 +28,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(LD2412_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(LD2412_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) diff --git a/esphome/components/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py index 76b42c03628..ca3891a97c0 100644 --- a/esphome/components/ld2420/binary_sensor/__init__.py +++ b/esphome/components/ld2420/binary_sensor/__init__.py @@ -27,8 +27,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_HAS_TARGET in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_HAS_TARGET]) - cg.add(var.set_presence_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, var.set_presence_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button/__init__.py index cfcffd0922d..533f3b91aa3 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button/__init__.py @@ -52,20 +52,9 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2420_component = await cg.get_variable(config[CONF_LD2420_ID]) - if apply_config := config.get(CONF_APPLY_CONFIG): - b = await button.new_button(apply_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_apply_config_button(b)) - if revert_config := config.get(CONF_REVERT_CONFIG): - b = await button.new_button(revert_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_revert_config_button(b)) - if restart_config := config.get(CONF_RESTART_MODULE): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_restart_module_button(b)) - if factory_reset := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset) - await cg.register_parented(b, config[CONF_LD2420_ID]) - cg.add(ld2420_component.set_factory_reset_button(b)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_APPLY_CONFIG, hub.set_apply_config_button) + await buttons(CONF_REVERT_CONFIG, hub.set_revert_config_button) + await buttons(CONF_RESTART_MODULE, hub.set_restart_module_button) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index 448639c9112..200f4e202fb 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -115,56 +115,65 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) - if gate_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): - n = await number.new_number( - gate_timeout_config, min_value=0, max_value=255, step=5 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_timeout_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_GATE_DISTANCE): - n = await number.new_number( - min_distance_gate_config, min_value=0, max_value=15, step=1 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_min_gate_distance_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_GATE_DISTANCE): - n = await number.new_number( - max_distance_gate_config, min_value=1, max_value=15, step=1 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_max_gate_distance_number(n)) - if gate_move_sensitivity_config := config.get(CONF_GATE_MOVE_SENSITIVITY): - n = await number.new_number( - gate_move_sensitivity_config, min_value=0.05, max_value=1, step=0.025 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_sensitivity_factor_number(n)) - if gate_still_sensitivity_config := config.get(CONF_GATE_STILL_SENSITIVITY): - n = await number.new_number( - gate_still_sensitivity_config, min_value=0.05, max_value=1, step=0.025 - ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_sensitivity_factor_number(n)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PRESENCE_TIMEOUT, + hub.set_gate_timeout_number, + min_value=0, + max_value=255, + step=5, + ) + await numbers( + CONF_MIN_GATE_DISTANCE, + hub.set_min_gate_distance_number, + min_value=0, + max_value=15, + step=1, + ) + await numbers( + CONF_MAX_GATE_DISTANCE, + hub.set_max_gate_distance_number, + min_value=1, + max_value=15, + step=1, + ) + await numbers( + CONF_GATE_MOVE_SENSITIVITY, + hub.set_gate_move_sensitivity_factor_number, + min_value=0.05, + max_value=1, + step=0.025, + ) + await numbers( + CONF_GATE_STILL_SENSITIVITY, + hub.set_gate_still_sensitivity_factor_number, + min_value=0.05, + max_value=1, + step=0.025, + ) if config.get(CONF_GATE_SELECT): - if gate_number := config.get(CONF_GATE_SELECT): - n = await number.new_number(gate_number, min_value=0, max_value=15, step=1) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_select_number(n)) + await numbers( + CONF_GATE_SELECT, + hub.set_gate_select_number, + min_value=0, + max_value=15, + step=1, + ) if gate_still_threshold := config.get(CONF_STILL_THRESHOLD): n = cg.new_Pvariable(gate_still_threshold[CONF_ID]) await number.register_number( n, gate_still_threshold, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_threshold_numbers(0, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_numbers(0, n)) if gate_move_threshold := config.get(CONF_MOVE_THRESHOLD): n = cg.new_Pvariable(gate_move_threshold[CONF_ID]) await number.register_number( n, gate_move_threshold, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_threshold_numbers(0, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_numbers(0, n)) else: for x in range(16): if gate_conf := config.get(f"gate_{x}"): @@ -173,13 +182,13 @@ async def to_code(config: ConfigType) -> None: await number.register_number( n, move_config, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_move_threshold_numbers(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_numbers(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] n = cg.new_Pvariable(still_config[CONF_ID], x) await number.register_number( n, still_config, min_value=0, max_value=65535, step=25 ) - await cg.register_parented(n, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_gate_still_threshold_numbers(x, n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_numbers(x, n)) diff --git a/esphome/components/ld2420/select/__init__.py b/esphome/components/ld2420/select/__init__.py index cd66064e475..6f3c73b9d12 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -25,11 +25,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) - if operating_mode_config := config.get(CONF_OPERATING_MODE): - sel = await select.new_select( - operating_mode_config, - options=CONF_SELECTS, - ) - await cg.register_parented(sel, config[CONF_LD2420_ID]) - cg.add(LD2420_component.set_operating_mode_select(sel)) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_OPERATING_MODE, hub.set_operating_mode_select, options=CONF_SELECTS + ) diff --git a/esphome/components/ld2420/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index f98d63585b6..89cfd3a5153 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -34,11 +34,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_MOVING_DISTANCE in config: - sens = await sensor.new_sensor(config[CONF_MOVING_DISTANCE]) - cg.add(var.set_distance_sensor(sens)) - if CONF_GATE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_GATE_ENERGY]) - cg.add(var.set_energy_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOVING_DISTANCE, var.set_distance_sensor) + await sensors(CONF_GATE_ENERGY, var.set_energy_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2420/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index cee8f25c1fe..58ebc9cf5b1 100644 --- a/esphome/components/ld2420/text_sensor/__init__.py +++ b/esphome/components/ld2420/text_sensor/__init__.py @@ -28,8 +28,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_FW_VERSION in config: - sens = await text_sensor.new_text_sensor(config[CONF_FW_VERSION]) - cg.add(var.set_fw_version_text_sensor(sens)) - ld2420 = await cg.get_variable(config[CONF_LD2420_ID]) - cg.add(ld2420.register_listener(var)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_FW_VERSION, var.set_fw_version_text_sensor) + hub = await cg.get_variable(config[CONF_LD2420_ID]) + cg.add(hub.register_listener(var)) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 779d151fd91..c7c1b6b46af 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -41,13 +41,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(ld2450_component.set_target_binary_sensor(sens)) - if has_moving_target_config := config.get(CONF_HAS_MOVING_TARGET): - sens = await binary_sensor.new_binary_sensor(has_moving_target_config) - cg.add(ld2450_component.set_moving_target_binary_sensor(sens)) - if has_still_target_config := config.get(CONF_HAS_STILL_TARGET): - sens = await binary_sensor.new_binary_sensor(has_still_target_config) - cg.add(ld2450_component.set_still_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) + await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) + await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button/__init__.py index 42cadd2052d..d23f1bebf8f 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_factory_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_restart_button(b)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 4f242076d66..237c7c4c231 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -80,16 +80,15 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if presence_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): - n = await number.new_number( - presence_timeout_config, - min_value=0, - max_value=3600, - step=1, - ) - await cg.register_parented(n, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_presence_timeout_number(n)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PRESENCE_TIMEOUT, + hub.set_presence_timeout_number, + min_value=0, + max_value=3600, + step=1, + ) for zone_num in range(MAX_ZONES): if zone_conf := config.get(f"zone_{zone_num + 1}"): zone_x1_config = zone_conf.get(CONF_X1) @@ -97,27 +96,27 @@ async def to_code(config: ConfigType) -> None: await number.register_number( x1, zone_x1_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x1, config[CONF_LD2450_ID]) + await cg.register_parented(x1, hub) zone_y1_config = zone_conf.get(CONF_Y1) y1 = cg.new_Pvariable(zone_y1_config[CONF_ID], zone_num) await number.register_number( y1, zone_y1_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y1, config[CONF_LD2450_ID]) + await cg.register_parented(y1, hub) zone_x2_config = zone_conf.get(CONF_X2) x2 = cg.new_Pvariable(zone_x2_config[CONF_ID], zone_num) await number.register_number( x2, zone_x2_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x2, config[CONF_LD2450_ID]) + await cg.register_parented(x2, hub) zone_y2_config = zone_conf.get(CONF_Y2) y2 = cg.new_Pvariable(zone_y2_config[CONF_ID], zone_num) await number.register_number( y2, zone_y2_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y2, config[CONF_LD2450_ID]) + await cg.register_parented(y2, hub) - cg.add(ld2450_component.set_zone_numbers(zone_num, x1, y1, x2, y2)) + cg.add(hub.set_zone_numbers(zone_num, x1, y1, x2, y2)) diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index d91b42426ad..779dfc2a35c 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -33,31 +33,28 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_baud_rate_select(s)) - if zone_type_config := config.get(CONF_ZONE_TYPE): - s = await select.new_select( - zone_type_config, - options=[ - "Disabled", - "Detection", - "Filter", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_zone_type_select(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_ZONE_TYPE, + hub.set_zone_type_select, + options=[ + "Disabled", + "Detection", + "Filter", + ], + ) diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 40462e202d1..e714d353d1f 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -228,47 +228,40 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) + hub = await cg.get_variable(config[CONF_LD2450_ID]) - if target_count_config := config.get(CONF_TARGET_COUNT): - sens = await sensor.new_sensor(target_count_config) - cg.add(ld2450_component.set_target_count_sensor(sens)) - - if still_target_count_config := config.get(CONF_STILL_TARGET_COUNT): - sens = await sensor.new_sensor(still_target_count_config) - cg.add(ld2450_component.set_still_target_count_sensor(sens)) - - if moving_target_count_config := config.get(CONF_MOVING_TARGET_COUNT): - sens = await sensor.new_sensor(moving_target_count_config) - cg.add(ld2450_component.set_moving_target_count_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + await sensors(CONF_STILL_TARGET_COUNT, hub.set_still_target_count_sensor) + await sensors(CONF_MOVING_TARGET_COUNT, hub.set_moving_target_count_sensor) for n in range(MAX_TARGETS): if target_conf := config.get(f"target_{n + 1}"): if x_config := target_conf.get(CONF_X): sens = await sensor.new_sensor(x_config) - cg.add(ld2450_component.set_move_x_sensor(n, sens)) + cg.add(hub.set_move_x_sensor(n, sens)) if y_config := target_conf.get(CONF_Y): sens = await sensor.new_sensor(y_config) - cg.add(ld2450_component.set_move_y_sensor(n, sens)) + cg.add(hub.set_move_y_sensor(n, sens)) if speed_config := target_conf.get(CONF_SPEED): sens = await sensor.new_sensor(speed_config) - cg.add(ld2450_component.set_move_speed_sensor(n, sens)) + cg.add(hub.set_move_speed_sensor(n, sens)) if angle_config := target_conf.get(CONF_ANGLE): sens = await sensor.new_sensor(angle_config) - cg.add(ld2450_component.set_move_angle_sensor(n, sens)) + cg.add(hub.set_move_angle_sensor(n, sens)) if distance_config := target_conf.get(CONF_DISTANCE): sens = await sensor.new_sensor(distance_config) - cg.add(ld2450_component.set_move_distance_sensor(n, sens)) + cg.add(hub.set_move_distance_sensor(n, sens)) if resolution_config := target_conf.get(CONF_RESOLUTION): sens = await sensor.new_sensor(resolution_config) - cg.add(ld2450_component.set_move_resolution_sensor(n, sens)) + cg.add(hub.set_move_resolution_sensor(n, sens)) for n in range(MAX_ZONES): if zone_config := config.get(f"zone_{n + 1}"): if target_count_config := zone_config.get(CONF_TARGET_COUNT): sens = await sensor.new_sensor(target_count_config) - cg.add(ld2450_component.set_zone_target_count_sensor(n, sens)) + cg.add(hub.set_zone_target_count_sensor(n, sens)) if still_target_count_config := zone_config.get(CONF_STILL_TARGET_COUNT): sens = await sensor.new_sensor(still_target_count_config) - cg.add(ld2450_component.set_zone_still_target_count_sensor(n, sens)) + cg.add(hub.set_zone_still_target_count_sensor(n, sens)) if moving_target_count_config := zone_config.get(CONF_MOVING_TARGET_COUNT): sens = await sensor.new_sensor(moving_target_count_config) - cg.add(ld2450_component.set_zone_moving_target_count_sensor(n, sens)) + cg.add(hub.set_zone_moving_target_count_sensor(n, sens)) diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch/__init__.py index 084f79ee1bc..bb920f6c76e 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_bluetooth_switch(s)) - if multi_target_config := config.get(CONF_MULTI_TARGET): - s = await switch.new_switch(multi_target_config) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_multi_target_switch(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_MULTI_TARGET, hub.set_multi_target_switch) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index a8b978ef488..3eaf6c7fcad 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -51,16 +51,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(ld2450_component.set_version_text_sensor(sens)) - if mac_address_config := config.get(CONF_MAC_ADDRESS): - sens = await text_sensor.new_text_sensor(mac_address_config) - cg.add(ld2450_component.set_mac_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) + await text_sensors(CONF_MAC_ADDRESS, hub.set_mac_text_sensor) for n in range(MAX_TARGETS): if (direction_conf := config.get(f"target_{n + 1}")) and ( direction_config := direction_conf.get(CONF_DIRECTION) ): sens = await text_sensor.new_text_sensor(direction_config) - cg.add(ld2450_component.set_direction_text_sensor(n, sens)) + cg.add(hub.set_direction_text_sensor(n, sens)) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 328d01a167d..b175620bbf4 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -70,6 +70,7 @@ from .types import ( # noqa: F401 AddressableLightState, ChannelColors, ColorMode, + GammaTable, LightOutput, LightState, LightStateRTCState, @@ -85,6 +86,8 @@ CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True DOMAIN = "light" +CONF_GAMMA_TABLE_ID = "gamma_table_id" +CONF_RESUME_EFFECT = "resume_effect" @dataclass @@ -140,18 +143,29 @@ def generate_gamma_table(gamma_correct: float) -> list[HexInt]: return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)] -def _get_or_create_gamma_table(gamma_correct): +def gamma_table_initializer(gamma_correct: float) -> str: + """C++ initializer for a light::GammaTable: the lookup table, then gamma * 100.""" + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(gamma_correct)) + # gamma_x100 is a uint16_t; platforms that redefine gamma_correct leave it unbounded, so saturate here + return f"{{{{{lut}}}, {min(0xFFFF, round(gamma_correct * 100))}}}" + + +def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpression: data = _get_data() if gamma_correct in data.gamma_tables: return data.gamma_tables[gamma_correct] - forward = generate_gamma_table(gamma_correct) - - gamma_str = f"{gamma_correct}".replace(".", "_") - fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16) - fwd_arr = cg.progmem_array(fwd_id, forward) - data.gamma_tables[gamma_correct] = fwd_arr - return fwd_arr + # table_id is generated and resolved against every declared ID, so it can't collide with a + # YAML ID; lights sharing a gamma reuse the first light's table. + cg.add( + cg.RawStatement( + f"static constexpr light::GammaTable {table_id} PROGMEM = " + f"{gamma_table_initializer(gamma_correct)};" + ) + ) + table = cg.RawExpression(f"&{table_id}") + data.gamma_tables[gamma_correct] = table + return table def find_effect_index(effects: list, effect_name: str) -> int | None: @@ -357,6 +371,7 @@ LIGHT_SCHEMA = ( .extend( { cv.GenerateID(): cv.declare_id(LightState), + cv.GenerateID(CONF_GAMMA_TABLE_ID): cv.declare_id(GammaTable), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id( mqtt.MQTTJSONLightComponent ), @@ -380,6 +395,7 @@ LIGHT_SCHEMA = ( } ), cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA, + cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean, } ) ) @@ -510,6 +526,9 @@ async def setup_light_core_(light_var, config, output_var): # runtime actually understands. initial_state_config = config.get(CONF_INITIAL_STATE) initial_statements = await _initial_state_statements(initial_state_config) + if config[CONF_RESUME_EFFECT]: + cg.add_define("USE_LIGHT_RESUME_EFFECT") + cg.add(light_var.set_resume_effect(True)) restore_mode = config.get(CONF_RESTORE_MODE) restore_state_config = config.get(CONF_RESTORE_STATE) @@ -553,14 +572,14 @@ async def setup_light_core_(light_var, config, output_var): ) is not None and flash_transition_length != cv.time_period( DEFAULT_FLASH_TRANSITION_LENGTH ): + cg.add_define("USE_LIGHT_FLASH_TRANSITION_LENGTH") cg.add(light_var.set_flash_transition_length(flash_transition_length)) # Setting an interval opts this light in and compiles the feature in if (interval := config.get(CONF_TRANSITION_STATE_PUBLISH_INTERVAL)) is not None: cg.add(light_var.set_transition_state_publish_interval(interval)) cg.add_define("USE_LIGHT_TRANSITION_PUBLISH_INTERVAL") if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: - cg.add(light_var.set_gamma_correct(gamma_correct)) - fwd_arr = _get_or_create_gamma_table(gamma_correct) + fwd_arr = _get_or_create_gamma_table(gamma_correct, config[CONF_GAMMA_TABLE_ID]) cg.add(light_var.set_gamma_table(fwd_arr)) cg.add_define("USE_LIGHT_GAMMA_LUT") effects = await cg.build_registry_list( diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 535def0ba02..b12ec189b9e 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -545,12 +545,18 @@ async def addressable_flicker_effect_to_code(config, effect_id): return var +# LightState stores the active effect index in a uint16_t +MAX_EFFECTS = 65535 + + def validate_effects(allowed_effects): @schema_extractor("effects") def validator(value): if value == SCHEMA_EXTRACT: return (allowed_effects, EFFECTS_REGISTRY) + if isinstance(value, list) and len(value) > MAX_EFFECTS: + raise cv.Invalid(f"A light supports at most {MAX_EFFECTS} effects") value = cv.validate_registry("effect", EFFECTS_REGISTRY)(value) errors = [] names = set() diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 4eb5208c96e..0295fb36b8c 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -5,6 +5,12 @@ namespace esphome::light { +/// A gamma curve as codegen emits it into PROGMEM: the lookup table and the gamma it was built from +struct GammaTable { + uint16_t lut[256]; + uint16_t gamma_x100; +}; + /// Binary search a monotonically increasing uint16[256] PROGMEM table. /// Returns the largest index where table[index] <= target. inline uint8_t gamma_table_reverse_search(const uint16_t *table, uint16_t target) { diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 4251565e855..f540d2f31f1 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -193,6 +193,11 @@ LightColorValues LightCall::validate_() { auto *name = this->parent_->get_name().c_str(); auto traits = this->parent_->get_traits(); +#ifdef USE_LIGHT_RESUME_EFFECT + // Snapshot before the adjustments below add flags of their own + const bool plain_turn_on = this->has_state() && this->state_ && (this->flags_ & ~STATE_ONLY_FLAGS_MASK) == 0; +#endif // USE_LIGHT_RESUME_EFFECT + // Color mode check if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) { ESP_LOGW(TAG, "'%s' does not support color mode %s", name, LOG_STR_ARG(color_mode_to_human(this->color_mode_))); @@ -334,6 +339,15 @@ LightColorValues LightCall::validate_() { // validate transition length/flash length/effect not used at the same time bool supports_transition = color_mode & ColorCapability::BRIGHTNESS; +#ifdef USE_LIGHT_RESUME_EFFECT + // A plain turn-on from off brings back the effect that was running when the light was turned off + if (this->parent_->resume_effect_ && plain_turn_on && !this->parent_->remote_values.is_on() && + this->parent_->previous_effect_index_ != 0) { + this->effect_ = this->parent_->previous_effect_index_; + this->set_flag_(FLAG_HAS_EFFECT); + } +#endif // USE_LIGHT_RESUME_EFFECT + // If effect is already active, remove effect start if (this->has_effect_() && this->effect_ == this->parent_->active_effect_index_) { this->clear_flag_(FLAG_HAS_EFFECT); @@ -376,6 +390,11 @@ LightColorValues LightCall::validate_() { // Reason: When user turns off the light in frontend, the effect should also stop bool target_state = this->has_state() ? this->state_ : v.is_on(); if (!this->has_flash_() && !target_state) { +#ifdef USE_LIGHT_RESUME_EFFECT + // Remember what was running, including no effect, when a lit light is explicitly turned off + if (this->parent_->resume_effect_ && explicit_turn_off_request && this->parent_->remote_values.is_on()) + this->parent_->previous_effect_index_ = this->parent_->active_effect_index_; +#endif // USE_LIGHT_RESUME_EFFECT if (this->has_effect_()) { log_invalid_parameter(name, LOG_STR("cannot start effect when turning off")); this->clear_flag_(FLAG_HAS_EFFECT); diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index e3352de7272..c9f6af7c916 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -215,6 +215,8 @@ class LightCall { FLAG_SAVE = 1 << 15, }; static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7 + // Flags a plain turn-on may carry; any other flag means the caller asked for something specific + static constexpr uint16_t STATE_ONLY_FLAGS_MASK = FLAG_HAS_STATE | FLAG_PUBLISH | FLAG_SAVE; inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; } inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; } diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9cbe9639dbf..1b9a7f94c1b 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -9,6 +9,8 @@ #include "light_output.h" #include "transformers.h" +#include + namespace esphome::light { static const char *const TAG = "light"; @@ -94,10 +96,17 @@ void LightState::dump_config() { ESP_LOGCONFIG(TAG, "Light '%s'", this->get_name().c_str()); auto traits = this->get_traits(); if (traits.supports_color_capability(ColorCapability::BRIGHTNESS)) { +#ifdef USE_LIGHT_GAMMA_LUT + // Read the stored gamma * 100 directly so dump_config does not pull in get_gamma_correct() + const unsigned gamma_x100 = + this->gamma_table_ != nullptr ? progmem_read_uint16(&this->gamma_table_->gamma_x100) : 0; +#else + const unsigned gamma_x100 = 0; +#endif ESP_LOGCONFIG(TAG, " Default Transition Length: %.1fs\n" - " Gamma Correct: %.2f", - this->default_transition_length_ / 1e3f, this->gamma_correct_); + " Gamma Correct: %u.%02u", + this->default_transition_length_ / 1e3f, gamma_x100 / 100, gamma_x100 % 100); #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL // The define is build wide; only lights that set the option have an interval if (this->transition_state_publish_interval_ != 0) { @@ -296,6 +305,14 @@ void LightState::current_values_as_ct(float *color_temperature, float *white_bri *white_brightness = this->gamma_correct_lut(*white_brightness); } +float LightState::get_gamma_correct() const { +#ifdef USE_LIGHT_GAMMA_LUT + if (this->gamma_table_ != nullptr) + return progmem_read_uint16(&this->gamma_table_->gamma_x100) * 0.01f; +#endif // USE_LIGHT_GAMMA_LUT + return 0.0f; +} + #ifdef USE_LIGHT_GAMMA_LUT float LightState::gamma_correct_lut(float value) const { if (value <= 0.0f) @@ -307,10 +324,10 @@ float LightState::gamma_correct_lut(float value) const { float scaled = value * 255.0f; auto idx = static_cast(scaled); if (idx >= 255) - return progmem_read_uint16(&this->gamma_table_[255]) / 65535.0f; + return progmem_read_uint16(&this->gamma_table_->lut[255]) / 65535.0f; float frac = scaled - idx; - float a = progmem_read_uint16(&this->gamma_table_[idx]); - float b = progmem_read_uint16(&this->gamma_table_[idx + 1]); + float a = progmem_read_uint16(&this->gamma_table_->lut[idx]); + float b = progmem_read_uint16(&this->gamma_table_->lut[idx + 1]); return (a + frac * (b - a)) / 65535.0f; } float LightState::gamma_uncorrect_lut(float value) const { @@ -321,12 +338,12 @@ float LightState::gamma_uncorrect_lut(float value) const { if (this->gamma_table_ == nullptr) return value; uint16_t target = static_cast(value * 65535.0f); - uint8_t lo = gamma_table_reverse_search(this->gamma_table_, target); + uint8_t lo = gamma_table_reverse_search(this->gamma_table_->lut, target); if (lo >= 255) return 1.0f; // Interpolate between lo and lo+1 - uint16_t a = progmem_read_uint16(&this->gamma_table_[lo]); - uint16_t b = progmem_read_uint16(&this->gamma_table_[lo + 1]); + uint16_t a = progmem_read_uint16(&this->gamma_table_->lut[lo]); + uint16_t b = progmem_read_uint16(&this->gamma_table_->lut[lo + 1]); if (b == a) return lo / 255.0f; float frac = static_cast(target - a) / static_cast(b - a); @@ -335,11 +352,14 @@ float LightState::gamma_uncorrect_lut(float value) const { #endif // USE_LIGHT_GAMMA_LUT void LightState::start_effect_(uint32_t effect_index) { + // An external add_effects() can exceed the codegen cap; ignore an index the uint16_t can't hold + if (effect_index > std::numeric_limits::max()) + return; this->stop_effect_(); if (effect_index == 0) return; - this->active_effect_index_ = effect_index; + this->active_effect_index_ = static_cast(effect_index); auto *effect = this->get_active_effect_(); effect->start_internal(); // Enable loop while effect is active @@ -435,7 +455,7 @@ void LightState::save_remote_values_() { saved.color_temp = this->remote_values.get_color_temperature(); saved.cold_white = this->remote_values.get_cold_white(); saved.warm_white = this->remote_values.get_warm_white(); - saved.effect = this->active_effect_index_; + saved.effect = static_cast(this->active_effect_index_); // the saved layout stays uint32_t this->rtc_.save(&saved); } diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 4f7d3c93302..fcb2ca40c90 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -6,6 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/string_ref.h" #include "light_call.h" +#include "esp_color_correction.h" #include "light_color_values.h" #include "light_effect.h" #include "light_traits.h" @@ -151,15 +152,28 @@ class LightState : public EntityBase, public Component { } uint32_t get_default_transition_length() const { return this->default_transition_length_; } - /// Set the flash transition length +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH + /// Set the flash transition length; only compiled in when a light configures one void set_flash_transition_length(uint32_t flash_transition_length) { this->flash_transition_length_ = flash_transition_length; } uint32_t get_flash_transition_length() const { return this->flash_transition_length_; } +#else + // Remove before 2027.4.0 + ESPDEPRECATED("set_flash_transition_length() does nothing unless flash_transition_length is set in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_flash_transition_length(uint32_t flash_transition_length) {} + uint32_t get_flash_transition_length() const { return 0; } +#endif - /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } - float get_gamma_correct() const { return this->gamma_correct_; } + // Remove before 2027.4.0 + ESPDEPRECATED("set_gamma_correct() does nothing; gamma is fixed at build time by gamma_correct in YAML. Removed in " + "2027.4.0", + "2026.10.0") + void set_gamma_correct(float gamma_correct) {} + /// The gamma correction factor, read from the entry after the gamma lookup table; 0 without one + float get_gamma_correct() const; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL void set_transition_state_publish_interval(uint32_t transition_state_publish_interval) { @@ -169,11 +183,11 @@ class LightState : public EntityBase, public Component { #endif #ifdef USE_LIGHT_GAMMA_LUT - /// Set pre-computed gamma forward lookup table (256-entry uint16 PROGMEM array) - void set_gamma_table(const uint16_t *forward) { this->gamma_table_ = forward; } + /// Set the pre-computed PROGMEM gamma curve + void set_gamma_table(const GammaTable *table) { this->gamma_table_ = table; } - /// Get the forward gamma lookup table - const uint16_t *get_gamma_table() const { return this->gamma_table_; } + /// Get the forward gamma lookup table, 256 PROGMEM entries + const uint16_t *get_gamma_table() const { return this->gamma_table_ != nullptr ? this->gamma_table_->lut : nullptr; } /// Apply gamma correction using the pre-computed forward LUT float gamma_correct_lut(float value) const; @@ -194,6 +208,11 @@ class LightState : public EntityBase, public Component { /// Set whether this light persists its state to preferences at all. void set_save_enabled(bool save_enabled) { this->save_enabled_ = save_enabled; } +#ifdef USE_LIGHT_RESUME_EFFECT + /// Set whether a plain turn-on restores the effect that was active when the light was turned off. + void set_resume_effect(bool resume_effect) { this->resume_effect_ = resume_effect; } +#endif // USE_LIGHT_RESUME_EFFECT + /// Return whether the light has any effects that meet the trait requirements. bool supports_effects() const { return !this->effects_.empty(); } @@ -362,32 +381,40 @@ class LightState : public EntityBase, public Component { /// Values live in flash as function body; no per-instance data storage beyond this pointer. void (*state_callback_)(LightStateRTCState &, bool restored){nullptr}; - /// Value for storing the index of the currently active effect. 0 if no effect is active - uint32_t active_effect_index_{}; /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; +#ifdef USE_LIGHT_FLASH_TRANSITION_LENGTH /// Transition length to use for flash transitions. uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py +#endif #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL uint32_t transition_state_publish_interval_{0}; uint32_t last_transition_state_publish_{0}; #endif - /// Gamma correction factor for the light. - float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT - const uint16_t *gamma_table_{nullptr}; + const GammaTable *gamma_table_{nullptr}; #endif // USE_LIGHT_GAMMA_LUT + /// 1-based index of the active effect, 0 if none; codegen caps effects at MAX_EFFECTS in effects.py + uint16_t active_effect_index_{}; +#ifdef USE_LIGHT_RESUME_EFFECT + /// The effect index that was active when the light was last turned off; shares the active index's word + uint16_t previous_effect_index_{0}; +#endif // USE_LIGHT_RESUME_EFFECT /// Whether the light value should be written in the next cycle. - bool next_write_{true}; + bool next_write_{true}; // a plain bool: it is the most written flag, and still shares the index's word // for effects, true if a transformer (transition) is active. - bool is_transformer_active_{false}; + bool is_transformer_active_ : 1 {false}; /// Whether this light persists its state to preferences at all. - bool save_enabled_{false}; + bool save_enabled_ : 1 {false}; #ifdef USE_LIGHT_TRANSITION_PUBLISH_INTERVAL /// True while the active transformer publishes current_values on an interval from loop(). - bool transition_publish_enabled_{false}; + bool transition_publish_enabled_ : 1 {false}; #endif +#ifdef USE_LIGHT_RESUME_EFFECT + /// Whether a plain turn-on restores the effect that was active when the light was turned off. + bool resume_effect_ : 1 {false}; +#endif // USE_LIGHT_RESUME_EFFECT }; } // namespace esphome::light diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index ceb974f26d7..7a95920a740 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +GammaTable = light_ns.struct("GammaTable") LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 7bd476156ba..223528581e9 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -219,29 +219,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if infrared_cnt_config := config.get(CONF_INFRARED_COUNTS): - sens = await sensor.new_sensor(infrared_cnt_config) - cg.add(var.set_infrared_counts_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_full_spectrum_counts_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) - - if prox_cnt_config := config.get(CONF_PS_COUNTS): - sens = await sensor.new_sensor(prox_cnt_config) - cg.add(var.set_proximity_counts_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_INFRARED_COUNTS, var.set_infrared_counts_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_full_spectrum_counts_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) + await sensors(CONF_PS_COUNTS, var.set_proximity_counts_sensor) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 2d2f1d6288b..382e483d0cb 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -31,6 +31,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_GROUP, CONF_ID, @@ -169,11 +170,17 @@ def generate_lv_conf_h(): all_defines = set( df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) - build_flags = ( - CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", []) + esphome_config = CORE.config[CONF_ESPHOME] + # User build flags come from esphome->build_flags and from the deprecated + # esphome->platformio_options->build_flags (a string or a list). + # Remove before 2026.12.0 + + pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get( + CONF_BUILD_FLAGS, [] ) - if not isinstance(build_flags, list): - build_flags = [build_flags] + if not isinstance(pio_build_flags, list): + pio_build_flags = [pio_build_flags] + build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags] # Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART', # or multiple defines in one string. define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?' diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 26bb433f874..05b84926eb6 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -4,6 +4,7 @@ #ifdef USE_LVGL_ANIMATION #include "lvgl_esphome.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::lvgl { @@ -110,17 +111,8 @@ template class LvAnimation : public C } void start() { - if (this->state_ > AnimationState::STOPPED) - this->stop(); - if (this->duration_ == 0) + if (!this->prepare_()) return; - // evaluate any lambdas - for (size_t i = 0; i != DATA_SIZE; i++) { - this->data_from_[i] = this->from_[i].value(); - this->data_to_[i] = this->to_[i].value(); - } - this->start_time_ = millis(); - this->state_ = AnimationState::STARTED; this->loop(); this->start_callback_.call(); } @@ -143,29 +135,26 @@ template class LvAnimation : public C if (this->state_ == AnimationState::STOPPED) return; uint32_t elapsed = millis() - this->start_time_; - float progress = static_cast(elapsed) / static_cast(this->duration_); + float progress = clamp_at_most(elapsed / static_cast(this->duration_), 1.0f); switch (this->state_) { case AnimationState::STARTED: if (elapsed < this->start_delay_) return; this->state_ = AnimationState::RUNNING; this->start_time_ = millis(); + elapsed = 0; progress = 0.0f; break; case AnimationState::RUNNING: - if (progress >= 1.0f) { - progress = 1.0f; - this->stop(); - if (this->loop_) - this->start(); - } break; default: return; } + // state here is RUNNING. for (auto *timing : this->timings_) { - progress = timing->map_progress(progress); + // avoid overshooting + progress = clamp_at_most(timing->map_progress(progress), 1.0f); } lv_coord_t data[DATA_SIZE]; for (size_t i = 0; i != DATA_SIZE; i++) { @@ -173,6 +162,12 @@ template class LvAnimation : public C roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); } this->update_callback_(data); + if (elapsed >= this->duration_) { + this->stop(); + // Restart without drawing the first frame now, so the final frame is not overwritten before it is shown + if (this->loop_ && this->prepare_()) + this->start_callback_.call(); + } } float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } @@ -187,6 +182,22 @@ template class LvAnimation : public C template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } protected: + // Arms a new run. Returns false if there is nothing to run. + bool prepare_() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return false; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + return true; + } + void (*const update_callback_)(const lv_coord_t *data); LazyCallbackManager start_callback_{}; LazyCallbackManager stop_callback_{}; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 550e1998bb5..e97b0e3a310 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,20 +246,18 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - // Toggle D/C only while holding the bus; on boards where D/C doubles as - // another bus signal, driving it while another device owns the bus - // corrupts that device's transfer. this->enable(); this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); this->dc_pin_->digital_write(true); - this->disable(); + // hold the bus between command and data to avoid a glitch on the D/C line if (len != 0) { - this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); - this->disable(); } + this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { + // Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination. + // See https://github.com/esphome/esphome/pull/18529 this->enable(); this->dc_pin_->digital_write(false); this->write_byte(cmd); @@ -271,12 +269,14 @@ class MipiSpi : public display::Display, this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->enable(); + // DC must be stable before CS as the clock is gated by CS this->dc_pin_->digital_write(false); + this->enable(); this->write_byte(cmd); - this->dc_pin_->digital_write(true); this->disable(); + this->dc_pin_->digital_write(true); for (size_t i = 0; i != len; i++) { + // must enable and disable for each byte based on empirical testing this->enable(); this->write_byte(0); this->write_byte(bytes[i]); diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 5f7c9445f21..761e5831c11 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -159,21 +159,10 @@ async def to_code(config: ConfigType) -> None: ) is not None: cg.add(var.set_min_signal_quality(minimum_signal_quality)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_LEVEL]) - cg.add(var.set_level(sens)) - if CONF_DISTANCE in config: - sens = await sensor.new_sensor(config[CONF_DISTANCE]) - cg.add(var.set_distance(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) - if CONF_SIGNAL_QUALITY in config: - sens = await sensor.new_sensor(config[CONF_SIGNAL_QUALITY]) - cg.add(var.set_signal_quality(sens)) - if CONF_IGNORED_READS in config: - sens = await sensor.new_sensor(config[CONF_IGNORED_READS]) - cg.add(var.set_ignored_reads(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_LEVEL, var.set_level) + await sensors(CONF_DISTANCE, var.set_distance) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_SIGNAL_QUALITY, var.set_signal_quality) + await sensors(CONF_IGNORED_READS, var.set_ignored_reads) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 09eb5f97dce..791e5504965 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -108,7 +108,7 @@ void MQTTBackendESP32::loop() { if ((now - this->last_dropped_log_time_) >= DROP_LOG_INTERVAL_MS) { uint16_t dropped = this->mqtt_queue_.get_and_reset_dropped_count(); if (dropped > 0) { - ESP_LOGW(TAG, "Dropped %u messages (%us)", dropped, DROP_LOG_INTERVAL_MS / 1000); + ESP_LOGW(TAG, "Dropped %u messages (%" PRIu32 "s)", dropped, DROP_LOG_INTERVAL_MS / 1000); } this->last_dropped_log_time_ = now; } diff --git a/esphome/components/nfc/nci_message.cpp b/esphome/components/nfc/nci_message.cpp index a144b8a86d7..d00c8af5ea7 100644 --- a/esphome/components/nfc/nci_message.cpp +++ b/esphome/components/nfc/nci_message.cpp @@ -2,56 +2,64 @@ #include "nci_message.h" #include "esphome/core/log.h" -#include - namespace esphome::nfc { -static const char *const TAG = "NciMessage"; - -NciMessage::NciMessage(const uint8_t message_type, const std::vector &payload) { +NciMessage::NciMessage(const uint8_t message_type, const std::span payload) { this->set_message(message_type, payload); } +NciMessage::NciMessage(const uint8_t message_type, const std::initializer_list payload) + : NciMessage(message_type, std::span(payload.begin(), payload.size())) {} + NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid) { + this->reset(); this->set_header(message_type, gid, oid); } NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid, - const std::vector &payload) { + const std::span payload) { this->set_message(message_type, gid, oid, payload); } -NciMessage::NciMessage(const std::vector &raw_packet) { this->nci_message_ = raw_packet; }; +NciMessage::NciMessage(const uint8_t message_type, const uint8_t gid, const uint8_t oid, + const std::initializer_list payload) + : NciMessage(message_type, gid, oid, std::span(payload.begin(), payload.size())) {} -std::vector NciMessage::encode() { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; - std::vector message = this->nci_message_; - return message; +NciMessage::NciMessage(const std::span raw_packet) { + this->nci_message_.assign(raw_packet.begin(), raw_packet.end()); } -void NciMessage::reset() { this->nci_message_ = {0, 0, 0}; } - -uint8_t NciMessage::get_message_type() const { - return this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_MT_MASK; +std::span NciMessage::encode() { + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; + return this->nci_message_; } -uint8_t NciMessage::get_gid() const { return this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_GID_MASK; } +void NciMessage::reset() { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = 0; + this->nci_message_[NCI_PKT_OID_OFFSET] = 0; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = 0; +} -uint8_t NciMessage::get_oid() const { return this->nci_message_[nfc::NCI_PKT_OID_OFFSET] & nfc::NCI_PKT_OID_MASK; } +uint8_t NciMessage::get_message_type() const { return this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_MT_MASK; } + +uint8_t NciMessage::get_gid() const { return this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_GID_MASK; } + +uint8_t NciMessage::get_oid() const { return this->nci_message_[NCI_PKT_OID_OFFSET] & NCI_PKT_OID_MASK; } uint8_t NciMessage::get_payload_size(const bool recompute) { if (!this->nci_message_.empty()) { if (recompute) { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } - return this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET]; + return this->nci_message_[NCI_PKT_LENGTH_OFFSET]; } return 0; } uint8_t NciMessage::get_simple_status_response() const { - if (this->nci_message_.size() > nfc::NCI_PKT_PAYLOAD_OFFSET) { - return this->nci_message_[nfc::NCI_PKT_PAYLOAD_OFFSET]; + if (this->nci_message_.size() > NCI_PKT_PAYLOAD_OFFSET) { + return this->nci_message_[NCI_PKT_PAYLOAD_OFFSET]; } return STATUS_FAILED; } @@ -63,104 +71,112 @@ uint8_t NciMessage::get_message_byte(const uint8_t offset) const { return 0; } -std::vector &NciMessage::get_message() { return this->nci_message_; } +std::span NciMessage::get_payload() const { + if (this->nci_message_.size() <= NCI_PKT_HEADER_SIZE) { + return {}; + } + return {this->nci_message_.data() + NCI_PKT_HEADER_SIZE, this->nci_message_.size() - NCI_PKT_HEADER_SIZE}; +} -bool NciMessage::has_payload() const { return this->nci_message_.size() > nfc::NCI_PKT_HEADER_SIZE; } +bool NciMessage::has_payload() const { return this->nci_message_.size() > NCI_PKT_HEADER_SIZE; } bool NciMessage::message_type_is(const uint8_t message_type) const { if (!this->nci_message_.empty()) { - return message_type == (this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_MT_MASK); + return message_type == (this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_MT_MASK); } return false; } bool NciMessage::message_length_is(const uint8_t message_length, const bool recompute) { - if (this->nci_message_.size() > nfc::NCI_PKT_LENGTH_OFFSET) { + if (this->nci_message_.size() > NCI_PKT_LENGTH_OFFSET) { if (recompute) { - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - nfc::NCI_PKT_HEADER_SIZE; + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } - return message_length == this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET]; + return message_length == this->nci_message_[NCI_PKT_LENGTH_OFFSET]; } return false; } bool NciMessage::gid_is(const uint8_t gid) const { - if (this->nci_message_.size() > nfc::NCI_PKT_MT_GID_OFFSET) { - return gid == (this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & nfc::NCI_PKT_GID_MASK); + if (this->nci_message_.size() > NCI_PKT_MT_GID_OFFSET) { + return gid == (this->nci_message_[NCI_PKT_MT_GID_OFFSET] & NCI_PKT_GID_MASK); } return false; } bool NciMessage::oid_is(const uint8_t oid) const { - if (this->nci_message_.size() > nfc::NCI_PKT_OID_OFFSET) { - return oid == (this->nci_message_[nfc::NCI_PKT_OID_OFFSET] & nfc::NCI_PKT_OID_MASK); + if (this->nci_message_.size() > NCI_PKT_OID_OFFSET) { + return oid == (this->nci_message_[NCI_PKT_OID_OFFSET] & NCI_PKT_OID_MASK); } return false; } bool NciMessage::simple_status_response_is(const uint8_t response) const { - if (this->nci_message_.size() > nfc::NCI_PKT_PAYLOAD_OFFSET) { - return response == this->nci_message_[nfc::NCI_PKT_PAYLOAD_OFFSET]; + if (this->nci_message_.size() > NCI_PKT_PAYLOAD_OFFSET) { + return response == this->nci_message_[NCI_PKT_PAYLOAD_OFFSET]; } return false; } void NciMessage::set_header(const uint8_t message_type, const uint8_t gid, const uint8_t oid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = - (message_type & nfc::NCI_PKT_MT_MASK) | (gid & nfc::NCI_PKT_GID_MASK); - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = (message_type & NCI_PKT_MT_MASK) | (gid & NCI_PKT_GID_MASK); + this->nci_message_[NCI_PKT_OID_OFFSET] = oid & NCI_PKT_OID_MASK; } -void NciMessage::set_message(const uint8_t message_type, const std::vector &payload) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = message_type & nfc::NCI_PKT_MT_MASK; - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = 0; - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_.insert(this->nci_message_.end(), payload.begin(), payload.end()); +void NciMessage::set_message(const uint8_t message_type, const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = message_type & NCI_PKT_MT_MASK; + this->nci_message_[NCI_PKT_OID_OFFSET] = 0; + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } void NciMessage::set_message(const uint8_t message_type, const uint8_t gid, const uint8_t oid, - const std::vector &payload) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = - (message_type & nfc::NCI_PKT_MT_MASK) | (gid & nfc::NCI_PKT_GID_MASK); - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; - this->nci_message_[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_.insert(this->nci_message_.end(), payload.begin(), payload.end()); + const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->set_header(message_type, gid, oid); + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } void NciMessage::set_message_type(const uint8_t message_type) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - auto mt_masked = this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & ~nfc::NCI_PKT_MT_MASK; - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = mt_masked | (message_type & nfc::NCI_PKT_MT_MASK); + auto mt_masked = this->nci_message_[NCI_PKT_MT_GID_OFFSET] & ~NCI_PKT_MT_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = mt_masked | (message_type & NCI_PKT_MT_MASK); } void NciMessage::set_gid(const uint8_t gid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - auto gid_masked = this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] & ~nfc::NCI_PKT_GID_MASK; - this->nci_message_[nfc::NCI_PKT_MT_GID_OFFSET] = gid_masked | (gid & nfc::NCI_PKT_GID_MASK); + auto gid_masked = this->nci_message_[NCI_PKT_MT_GID_OFFSET] & ~NCI_PKT_GID_MASK; + this->nci_message_[NCI_PKT_MT_GID_OFFSET] = gid_masked | (gid & NCI_PKT_GID_MASK); } void NciMessage::set_oid(const uint8_t oid) { - if (this->nci_message_.size() < nfc::NCI_PKT_HEADER_SIZE) { - this->nci_message_.resize(nfc::NCI_PKT_HEADER_SIZE); + if (this->nci_message_.size() < NCI_PKT_HEADER_SIZE) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); } - this->nci_message_[nfc::NCI_PKT_OID_OFFSET] = oid & nfc::NCI_PKT_OID_MASK; + this->nci_message_[NCI_PKT_OID_OFFSET] = oid & NCI_PKT_OID_MASK; } -void NciMessage::set_payload(const std::vector &payload) { - std::vector message(this->nci_message_.begin(), this->nci_message_.begin() + nfc::NCI_PKT_HEADER_SIZE); - - message.insert(message.end(), payload.begin(), payload.end()); - message[nfc::NCI_PKT_LENGTH_OFFSET] = payload.size(); - this->nci_message_ = message; +void NciMessage::set_payload(const std::span payload) { + this->nci_message_.resize(NCI_PKT_HEADER_SIZE); + this->append(payload); + this->nci_message_[NCI_PKT_LENGTH_OFFSET] = this->nci_message_.size() - NCI_PKT_HEADER_SIZE; } +void NciMessage::append(const std::span data) { + for (const uint8_t byte : data) { + this->nci_message_.push_back(byte); + } +} + +void NciMessage::set_payload_size(const uint8_t size) { this->nci_message_.resize(NCI_PKT_HEADER_SIZE + size); } + } // namespace esphome::nfc diff --git a/esphome/components/nfc/nci_message.h b/esphome/components/nfc/nci_message.h index 8e8b1103363..de8a4cf56ae 100644 --- a/esphome/components/nfc/nci_message.h +++ b/esphome/components/nfc/nci_message.h @@ -3,19 +3,30 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include +#include namespace esphome::nfc { +// An NCI packet is a three-byte header followed by up to 255 payload bytes +static constexpr size_t NCI_PKT_MAX_PAYLOAD_SIZE = 255; +static constexpr size_t NCI_PKT_MAX_SIZE = 3 + NCI_PKT_MAX_PAYLOAD_SIZE; + +/// One NCI packet, held in a fixed buffer so building and reading messages never allocates class NciMessage { public: - NciMessage() {} - NciMessage(uint8_t message_type, const std::vector &payload); - NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid); - NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, const std::vector &payload); - NciMessage(const std::vector &raw_packet); + using Buffer = StaticVector; - std::vector encode(); + NciMessage() { this->reset(); } + NciMessage(uint8_t message_type, std::span payload); + NciMessage(uint8_t message_type, std::initializer_list payload); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, std::span payload); + NciMessage(uint8_t message_type, uint8_t gid, uint8_t oid, std::initializer_list payload); + explicit NciMessage(std::span raw_packet); + + /// Stamps the payload length into the header and returns the packet ready to send + std::span encode(); void reset(); uint8_t get_message_type() const; @@ -24,7 +35,10 @@ class NciMessage { uint8_t get_payload_size(bool recompute = false); uint8_t get_simple_status_response() const; uint8_t get_message_byte(uint8_t offset) const; - std::vector &get_message(); + Buffer &get_message() { return this->nci_message_; } + const Buffer &get_message() const { return this->nci_message_; } + /// The payload bytes that follow the header + std::span get_payload() const; bool has_payload() const; bool message_type_is(uint8_t message_type) const; @@ -34,15 +48,25 @@ class NciMessage { bool simple_status_response_is(uint8_t response) const; void set_header(uint8_t message_type, uint8_t gid, uint8_t oid); - void set_message(uint8_t message_type, const std::vector &payload); - void set_message(uint8_t message_type, uint8_t gid, uint8_t oid, const std::vector &payload); + void set_message(uint8_t message_type, std::span payload); + void set_message(uint8_t message_type, uint8_t gid, uint8_t oid, std::span payload); void set_message_type(uint8_t message_type); void set_gid(uint8_t gid); void set_oid(uint8_t oid); - void set_payload(const std::vector &payload); + void set_payload(std::span payload); + void set_payload(std::initializer_list payload) { + this->set_payload(std::span(payload.begin(), payload.size())); + } + /// Appends bytes to the payload; bytes that do not fit are dropped + void append(std::span data); + void append(std::initializer_list data) { + this->append(std::span(data.begin(), data.size())); + } + /// Sets the packet size to the header plus `size` payload bytes, for a bus driver filling the buffer directly + void set_payload_size(uint8_t size); protected: - std::vector nci_message_{0, 0, 0}; // three bytes, MT/PBF/GID, OID, payload length/size + Buffer nci_message_; // MT/PBF/GID, OID, payload length, then the payload }; } // namespace esphome::nfc diff --git a/esphome/components/nfc/ndef_message.cpp b/esphome/components/nfc/ndef_message.cpp index d33f3f7b5cb..d9135cbdba6 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -5,7 +5,7 @@ namespace esphome::nfc { static const char *const TAG = "nfc.ndef_message"; -NdefMessage::NdefMessage(std::vector &data) { +NdefMessage::NdefMessage(const std::span data) { ESP_LOGV(TAG, "Building NdefMessage with %zu bytes", data.size()); size_t index = 0; while (index < data.size()) { diff --git a/esphome/components/nfc/ndef_message.h b/esphome/components/nfc/ndef_message.h index 7d431b22963..0d7699e9d72 100644 --- a/esphome/components/nfc/ndef_message.h +++ b/esphome/components/nfc/ndef_message.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "esphome/core/helpers.h" @@ -16,7 +17,8 @@ static constexpr uint8_t MAX_NDEF_RECORDS = 4; class NdefMessage { public: NdefMessage() = default; - NdefMessage(std::vector &data); + NdefMessage(std::span data); + NdefMessage(std::vector &data) : NdefMessage(std::span(data)) {} NdefMessage(const NdefMessage &msg) { records_.reserve(msg.records_.size()); for (const auto &r : msg.records_) { diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 64f3228b3c2..a9fc1c2b8c4 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -23,7 +23,7 @@ uint8_t guess_tag_type(uint8_t uid_length) { } } -int8_t get_mifare_classic_ndef_start_index(std::vector &data) { +int8_t get_mifare_classic_ndef_start_index(const std::span data) { for (uint8_t i = 0; i < MIFARE_CLASSIC_BLOCK_SIZE; i++) { if (data[i] == 0x00) { // Do nothing, skip @@ -36,7 +36,8 @@ int8_t get_mifare_classic_ndef_start_index(std::vector &data) { return -1; } -bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index) { +bool decode_mifare_classic_tlv(const std::span data, uint32_t &message_length, + uint8_t &message_start_index) { if (data.size() < MIFARE_CLASSIC_BLOCK_SIZE) { ESP_LOGE(TAG, "Error, data too short for NDEF detection."); return false; @@ -68,6 +69,25 @@ uint32_t get_mifare_ultralight_buffer_size(uint32_t message_length) { return buffer_size; } +void fill_ndef_tlv(const std::span message, const uint32_t buffer_length, FixedVector &buffer) { + buffer.init(buffer_length); + buffer.push_back(0x03); + if (message.size() < 255) { + buffer.push_back(message.size()); + } else { + buffer.push_back(0xFF); + buffer.push_back((message.size() >> 8) & 0xFF); + buffer.push_back(message.size() & 0xFF); + } + for (const uint8_t byte : message) { + buffer.push_back(byte); + } + buffer.push_back(0xFE); + while (buffer.size() < buffer_length) { + buffer.push_back(0x00); + } +} + uint32_t get_mifare_classic_buffer_size(uint32_t message_length) { uint32_t buffer_size = message_length; if (message_length < 255) { diff --git a/esphome/components/nfc/nfc.h b/esphome/components/nfc/nfc.h index 36b27ce5f68..603cb8d3148 100644 --- a/esphome/components/nfc/nfc.h +++ b/esphome/components/nfc/nfc.h @@ -64,9 +64,11 @@ static constexpr size_t FORMAT_BYTES_BUFFER_SIZE = 192; char *format_bytes_to(char *buffer, std::span bytes); uint8_t guess_tag_type(uint8_t uid_length); -int8_t get_mifare_classic_ndef_start_index(std::vector &data); -bool decode_mifare_classic_tlv(std::vector &data, uint32_t &message_length, uint8_t &message_start_index); +int8_t get_mifare_classic_ndef_start_index(std::span data); +bool decode_mifare_classic_tlv(std::span data, uint32_t &message_length, uint8_t &message_start_index); uint32_t get_mifare_classic_buffer_size(uint32_t message_length); +/// Fills `buffer` with the NDEF TLV (type, length, message, terminator) padded with zeros to `buffer_length` +void fill_ndef_tlv(std::span message, uint32_t buffer_length, FixedVector &buffer); bool mifare_classic_is_first_block(uint8_t block_num); bool mifare_classic_is_trailer_block(uint8_t block_num); diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index be8768fd154..b32d4f075ea 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -514,15 +514,15 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: "download": f"{storage_json.name}.hex", }, ] - if (build_dir / APP_IMAGE_PATH).is_file(): - types += [ - { - "title": "App update package", - "description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.", - "file": APP_IMAGE_PATH, - "download": f"app-{storage_json.name}.img", - }, - ] + if (build_dir / APP_IMAGE_PATH).is_file(): + types += [ + { + "title": "App update package", + "description": "For flashing via mcumgr-web using BLE or smpclient using USB CDC.", + "file": APP_IMAGE_PATH, + "download": f"app-{storage_json.name}.img", + }, + ] return types @@ -832,6 +832,26 @@ def _copy_if_exists(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +def _west_build_command( + python_executable: Path, board: str, build_dir: Path, source_dir: Path +) -> list[str]: + return [ + str(python_executable), + "-m", + "west", + "build", + "--pristine=auto", + "-b", + board, + "-d", + str(build_dir), + str(source_dir), + "--", + # Only adds -DNDEBUG (Kconfig sets the optimization level); picolibc used to force it + "-DCMAKE_BUILD_TYPE=MinSizeRel", + ] + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: # The actual build is done by PlatformIO (the caller falls through to @@ -866,18 +886,9 @@ def run_compile(args, config: ConfigType) -> bool: _LOGGER.info("Build inputs changed, cleaning %s", build_dir) rmtree(build_dir) - west_cmd = [ - str(paths["python_executable"]), - "-m", - "west", - "build", - "--pristine=auto", - "-b", - board, - "-d", - str(build_dir), - str(source_dir), - ] + west_cmd = _west_build_command( + paths["python_executable"], board, build_dir, source_dir + ) if not run_command_ok( west_cmd, diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index 2dd4b7bd09f..89272cd4066 100644 --- a/esphome/components/nrf52/clang_tidy.py +++ b/esphome/components/nrf52/clang_tidy.py @@ -20,9 +20,13 @@ commands. from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging from pathlib import Path +from .framework import bluetooth_west_projects + TIDY_PROJECT_NAME = "esphome_tidy" # Analyzed against the native toolchain's default SDK version @@ -33,19 +37,36 @@ _TIDY_BOARD = "adafruit_itsybitsy_nrf52840" # app target emits a C++ compile command to harvest flags/includes from. _TIDY_MAIN_CPP = "int main() { return 0; }\n" -# Kconfig superset enabling every subsystem an ESPHome nrf52 component may -# use, so the compile commands carry all of their include paths. -_TIDY_PRJ_CONF = """\ + +@dataclass(frozen=True) +class _TidySubsystem: + """A subsystem's Kconfig and the west projects its headers come from.""" + + name: str + prj_conf: str + # A callable when the projects differ by SDK version + west_projects: tuple[str, ...] | Callable[[], tuple[str, ...]] = () + + +# Kconfig superset for the compile commands and the projects it needs +_TIDY_SUBSYSTEMS = ( + _TidySubsystem( + "base", + """\ CONFIG_CPP=y CONFIG_STD_CPP20=y CONFIG_REQUIRES_FULL_LIBCPP=y CONFIG_NEWLIB_LIBC=y -CONFIG_BT=y CONFIG_ADC=y # posix (time sets POSIX_CLOCK, socket sets POSIX_API); without it the # Zephyr POSIX headers clash with the libc ones under analysis CONFIG_POSIX_API=y -#mcumgr begin +""", + ), + _TidySubsystem("bluetooth", "CONFIG_BT=y\n", bluetooth_west_projects), + _TidySubsystem( + "mcumgr", + """\ CONFIG_NET_BUF=y CONFIG_ZCBOR=y CONFIG_MCUMGR=y @@ -60,14 +81,23 @@ CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y CONFIG_MCUMGR_TRANSPORT_UART=y -#mcumgr end -#zigbee begin +""", + ("mcuboot", "zcbor"), + ), + _TidySubsystem( + "zigbee", + """\ CONFIG_ZIGBEE=y CONFIG_CRYPTO=y CONFIG_NVS=y CONFIG_SETTINGS=y -#zigbee end -""" +""", + ), +) + +_TIDY_PRJ_CONF = "".join( + f"# {subsystem.name}\n{subsystem.prj_conf}" for subsystem in _TIDY_SUBSYSTEMS +) def _tidy_cmakelists(library_include_dirs: str) -> str: @@ -166,6 +196,7 @@ def _setup_core(work_dir: Path) -> None: from esphome.core import CORE from . import RECOMMENDED_SDK_NRF_VERSION + from .framework import include_west_project CORE.name = TIDY_PROJECT_NAME # config_path's parent is the data-dir root for per-run artifacts. The @@ -178,6 +209,10 @@ def _setup_core(work_dir: Path) -> None: CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( RECOMMENDED_SDK_NRF_VERSION ) + for subsystem in _TIDY_SUBSYSTEMS: + projects = subsystem.west_projects + for project in projects() if callable(projects) else projects: + include_west_project(project) def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: @@ -237,6 +272,8 @@ def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: "zephyr_generated_headers", "--", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + # As in a real build, so NDEBUG is set + "-DCMAKE_BUILD_TYPE=MinSizeRel", ] if not run_command_ok( west_cmd, diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 5e2cf197fbb..48af47e8ffc 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -1,3 +1,7 @@ +from collections.abc import Iterator +import configparser +from contextlib import contextmanager +from dataclasses import dataclass, field import hashlib import logging import os @@ -7,6 +11,7 @@ import shutil import sys from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path +from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -80,6 +85,17 @@ def _get_toolchain_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "toolchains" / version +def toolchain_tool(name: str) -> Path: + """Path to one of the pinned Zephyr SDK's tools (objdump, readelf, ...). + + The single owner of the ``arm-zephyr-eabi/bin/arm-zephyr-eabi-`` + layout and the Windows suffix. + """ + suffix = ".exe" if os.name == "nt" else "" + bin_path = _get_toolchain_path(TOOLCHAIN_VERSION) / "arm-zephyr-eabi" / "bin" + return bin_path / f"arm-zephyr-eabi-{name}{suffix}" + + _SITECUSTOMIZE = """\ import os, stat, shutil _orig = shutil.rmtree @@ -247,8 +263,245 @@ def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: tmp.replace(uf2conv) +# West projects every build needs; components add others with include_west_project() +DEFAULT_WEST_PROJECTS = ("cmsis", "hal_nordic", "nrfxlib", "zephyr") + +_KEY_NRF52 = "nrf52" +# The projects a finished install fetched +_WEST_PROJECTS_FILE = ".west_projects" + + +@dataclass +class _Nrf52Data: + west_projects: set[str] = field(default_factory=lambda: set(DEFAULT_WEST_PROJECTS)) + + +def _get_data() -> _Nrf52Data: + if _KEY_NRF52 not in CORE.data: + CORE.data[_KEY_NRF52] = _Nrf52Data() + return CORE.data[_KEY_NRF52] + + +def include_west_project(name: str) -> None: + """Fetch a west project left out by default; call from to_code().""" + _get_data().west_projects.add(name) + + +def bluetooth_west_projects() -> tuple[str, ...]: + """Bluetooth's crypto: TinyCrypt up to SDK 3.1, PSA (mbedtls, Oberon) from 3.2.""" + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 2, 0): + return ("mbedtls", "oberon-psa-crypto") + return ("tinycrypt",) + + +def _wanted_west_projects() -> set[str]: + projects = set(_get_data().west_projects) + # Zephyr 4.1 moved the Cortex-M core headers to cmsis_6 + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 1, 0): + projects.add("cmsis_6") + # Sysbuild builds the MCUboot image with any bootloader + if CORE.data.get(KEY_ZEPHYR, {}).get(KEY_SYSBUILD): + projects.add("mcuboot") + return projects + + +def _set_project_filter( + env_python_path: Path, framework_path: Path, projects: set[str] +) -> bool: + # "--" keeps west from reading the leading "-" as an option + project_filter = ",".join(["-.*", *(f"+{p}" for p in sorted(projects))]) + cmd = [str(env_python_path), "-m", "west", "config", "manifest.project-filter"] + return run_command_ok([*cmd, "--", project_filter], cwd=framework_path) + + +def _check_west_projects( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Raise when the manifest lacks one of ``projects``; needs zephyr cloned. + + west update quietly skips an unknown name in the filter, west list fails on it. + """ + names = sorted(projects) + cmd = [str(env_python_path), "-m", "west", "list", "-f", "{name}", *names] + if not run_command_ok(cmd, cwd=framework_path): + raise EsphomeError( + f"west list failed for the requested nRF Connect SDK {version} projects " + f"({', '.join(names)}); a project the manifest does not have is the " + "usual cause, see west's output above" + ) + + +def _west_update( + env_python_path: Path, + framework_path: Path, + version: str, + projects: set[str], + checked: bool = False, +) -> bool: + """Fetch ``projects``; False when the fetch fails.""" + if not _set_project_filter(env_python_path, framework_path, projects): + return False + cmd = [ + str(env_python_path), + "-m", + "west", + "update", + "--narrow", + "--fetch-opt=--depth=1", + ] + # Streamed so the clone's progress reaches the log + if not run_command_ok(cmd, cwd=framework_path, stream_output=True): + return False + if not checked: + _check_west_projects(env_python_path, framework_path, version, projects) + (framework_path / _WEST_PROJECTS_FILE).write_text( + "\n".join(sorted(projects)), encoding="utf-8" + ) + return True + + +def _installed_west_projects(framework_path: Path) -> set[str] | None: + """The projects a finished install fetched; None when it has every project.""" + try: + stamp = (framework_path / _WEST_PROJECTS_FILE).read_text(encoding="utf-8") + except FileNotFoundError: + pass + else: + return set(stamp.split()) + # No stamp: an install from before the filter has every project, a filtered + # one that lost its stamp fetches again + config = configparser.ConfigParser() + if not config.read(framework_path / ".west" / "config", encoding="utf-8"): + return set() + if config.has_option("manifest", "project-filter"): + return set() + return None + + +def _restore_project_filter( + env_python_path: Path, framework_path: Path, version: str, installed: set[str] +) -> None: + """Put the filter back to the stamp's projects; the defaults always stay in.""" + projects = installed | set(DEFAULT_WEST_PROJECTS) + if not _set_project_filter(env_python_path, framework_path, projects): + _LOGGER.warning( + "Couldn't put the nRF Connect SDK %s project filter back; " + "the next build that fetches a project sets it again", + version, + ) + + +# Lock wait slices, so Ctrl-C stays responsive +_INSTALL_LOCK_POLL = 1 + + +@contextmanager +def _install_lock(name: str) -> Iterator[None]: + """Serialize a shared install step across builds running at once.""" + from filelock import FileLock, Timeout + + lock_path = get_sdk_nrf_tools_path() / f"{name}.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + # No soft lock: its marker outlives a killed build and hangs every later one + lock = FileLock(str(lock_path), fallback_to_soft=False) + waiting = False + while True: + try: + lock.acquire(timeout=_INSTALL_LOCK_POLL) + break + except Timeout: # before OSError, which it subclasses + if not waiting: + waiting = True + _LOGGER.info("Waiting for another build installing %s ...", name) + except OSError as err: + _LOGGER.warning( + "Can't lock %s (%s), continuing without a lock", lock_path, err + ) + break + try: + yield + finally: + lock.release() + + +def _fetch_missing_west_projects( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Fetch the wanted projects a finished install lacks; it only ever gains.""" + if projects <= set(DEFAULT_WEST_PROJECTS): + return + installed = _installed_west_projects(framework_path) + # Before the fetch, so an unknown name costs nothing on any install + _check_west_projects(env_python_path, framework_path, version, projects) + if installed is None or not (missing := projects - installed): + return + _LOGGER.info( + "Fetching nRF Connect SDK %s projects: %s", version, ", ".join(sorted(missing)) + ) + wanted = installed | projects + if not _west_update(env_python_path, framework_path, version, wanted, checked=True): + _restore_project_filter(env_python_path, framework_path, version, installed) + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + + +def _install_framework( + env_python_path: Path, framework_path: Path, version: str, projects: set[str] +) -> None: + """Clone the nRF Connect SDK into ``framework_path`` with west. + + A download cut short after ``west init`` leaves the workspace behind; + rerunning ``west update`` there only fetches what is missing, so it resumes + instead of cloning about 2 GB again. A resume that fails keeps what was + fetched (a flaky network is the likely cause) and is retried on the next + build; only a second failure in a row starts over clean. + """ + resume_failed = framework_path / ".resume_failed" + # Resume only a workspace whose ``west init`` finished (it writes the + # config last). ``.ready`` with missing requirements is a damaged install, + # not an interrupted one, so it goes the clean way. + initialized = (framework_path / ".west" / "config").is_file() + if initialized and not (framework_path / ".ready").exists(): + _LOGGER.info("Resuming the nRF Connect SDK %s download ...", version) + if _west_update(env_python_path, framework_path, version, projects): + resume_failed.unlink(missing_ok=True) + return + if not resume_failed.exists(): + resume_failed.touch() + raise EsphomeError( + f"Can't resume the nRF Connect SDK {version} download; " + "the next build retries it" + ) + _LOGGER.warning( + "Resuming failed again; downloading nRF Connect SDK %s anew", version + ) + rmdir(framework_path, msg=f"Clean up {version} framework environment") + _LOGGER.info("Initializing nRF Connect SDK %s ...", version) + cmd = [ + str(env_python_path), + "-m", + "west", + "init", + "-m", + "https://github.com/nrfconnect/sdk-nrf", + "-o=--depth=1", + "--mr", + version, + str(framework_path), + ] + if not run_command_ok(cmd, stream_output=True): + raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") + _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) + if not _west_update(env_python_path, framework_path, version, projects): + raise EsphomeError(f"Can't update nRF Connect SDK {version}") + + def check_and_install() -> None: version = _get_version_str() + with _install_lock(f"sdk-{version}"): + _check_and_install(version) + + +def _check_and_install(version: str) -> None: python_env_path = _get_python_env_path(version) env_python_path = get_python_env_executable_path(python_env_path, "python") sentinel = python_env_path / ".ready" @@ -279,38 +532,15 @@ def check_and_install() -> None: framework_path = _get_framework_path(version) sentinel = framework_path / ".ready" zephyr_reqs = framework_path / "zephyr" / "scripts" / "requirements.txt" + projects = _wanted_west_projects() if not sentinel.exists() or not zephyr_reqs.exists(): - rmdir(framework_path, msg=f"Clean up {version} framework environment") - _LOGGER.info("Initializing nRF Connect SDK %s ...", version) - cmd = [ - str(env_python_path), - "-m", - "west", - "init", - "-m", - "https://github.com/nrfconnect/sdk-nrf", - "-o=--depth=1", - "--mr", - version, - str(framework_path), - ] - if not run_command_ok(cmd): - raise EsphomeError(f"Can't initialize nRF Connect SDK {version}") - _LOGGER.info("Updating nRF Connect SDK %s (this may take a while) ...", version) - cmd = [ - str(env_python_path), - "-m", - "west", - "update", - "--narrow", - "--fetch-opt=--depth=1", - ] - if not run_command_ok(cmd, cwd=framework_path): - raise EsphomeError(f"Can't update nRF Connect SDK {version}") + _install_framework(env_python_path, framework_path, version, projects) framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] if framework_ver < cv.Version(2, 9, 2): _patch_uf2conv_escape_sequences(framework_path) sentinel.touch() + else: + _fetch_missing_west_projects(env_python_path, framework_path, version, projects) zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" if ( @@ -331,6 +561,13 @@ def check_and_install() -> None: raise EsphomeError(f"Install Zephyr requirements for {version} failure") zephyr_sentinel.touch() + # Shared by every SDK version; locked only while missing + if not (_get_toolchain_path(TOOLCHAIN_VERSION) / ".ready").exists(): + with _install_lock(f"toolchain-{TOOLCHAIN_VERSION}"): + _install_toolchain() + + +def _install_toolchain() -> None: toolchains_dir = _get_toolchain_path(TOOLCHAIN_VERSION) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): diff --git a/esphome/components/nrf52/toolchain.py b/esphome/components/nrf52/toolchain.py new file mode 100644 index 00000000000..aaa317aa5f5 --- /dev/null +++ b/esphome/components/nrf52/toolchain.py @@ -0,0 +1,28 @@ +"""Binutils and the linked image of an nRF52 sdk-nrf build, for memory analysis.""" + +from pathlib import Path + +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + +from .framework import toolchain_tool + + +def get_objdump_path() -> Path: + return toolchain_tool("objdump") + + +def get_readelf_path() -> Path: + return toolchain_tool("readelf") + + +def get_elf_path() -> Path: + """The linked Zephyr image, at the layout the configured SDK version writes. + + Chosen by version rather than by probing so a leftover image from another + SDK layout is never analyzed in place of the current build. + """ + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] < cv.Version(2, 9, 2): + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf") + return CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf") diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index afa2fac483e..8edbdecee5f 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -79,16 +79,18 @@ from esphome.const import ( DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ @@ -325,6 +327,13 @@ async def new_number(config, *args, min_value: float, max_value: float, step: fl return var +def sub_numbers( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_number.""" + return SubEntities(new_number, config, parent) + + NUMBER_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Number), diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 907200d5ec2..ac14efc8e68 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -19,6 +19,7 @@ from esphome.components.esp32 import ( ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage from esphome.components.network import add_use_address +from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -30,6 +31,8 @@ from esphome.const import ( CONF_LOG_LEVEL, CONF_OUTPUT_POWER, CONF_USE_ADDRESS, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, PlatformFramework, ) @@ -317,6 +320,11 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) elif CORE.using_zephyr: + # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 + include_west_project("mbedtls") + include_west_project("openthread") + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 7, 0): + include_west_project("oberon-psa-crypto") zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) zephyr_add_prj_conf( f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index dc853802032..5eac5415ed3 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -323,64 +323,21 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_type(config[CONF_TYPE])) - if CONF_PM_1_0_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0_STD]) - cg.add(var.set_pm_1_0_std_sensor(sens)) - - if CONF_PM_2_5_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5_STD]) - cg.add(var.set_pm_2_5_std_sensor(sens)) - - if CONF_PM_10_0_STD in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0_STD]) - cg.add(var.set_pm_10_0_std_sensor(sens)) - - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - - if CONF_PM_0_3UM in config: - sens = await sensor.new_sensor(config[CONF_PM_0_3UM]) - cg.add(var.set_pm_particles_03um_sensor(sens)) - - if CONF_PM_0_5UM in config: - sens = await sensor.new_sensor(config[CONF_PM_0_5UM]) - cg.add(var.set_pm_particles_05um_sensor(sens)) - - if CONF_PM_1_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0UM]) - cg.add(var.set_pm_particles_10um_sensor(sens)) - - if CONF_PM_2_5UM in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5UM]) - cg.add(var.set_pm_particles_25um_sensor(sens)) - - if CONF_PM_5_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_5_0UM]) - cg.add(var.set_pm_particles_50um_sensor(sens)) - - if CONF_PM_10_0UM in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0UM]) - cg.add(var.set_pm_particles_100um_sensor(sens)) - - if CONF_FORMALDEHYDE in config: - sens = await sensor.new_sensor(config[CONF_FORMALDEHYDE]) - cg.add(var.set_formaldehyde_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0_STD, var.set_pm_1_0_std_sensor) + await sensors(CONF_PM_2_5_STD, var.set_pm_2_5_std_sensor) + await sensors(CONF_PM_10_0_STD, var.set_pm_10_0_std_sensor) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PM_0_3UM, var.set_pm_particles_03um_sensor) + await sensors(CONF_PM_0_5UM, var.set_pm_particles_05um_sensor) + await sensors(CONF_PM_1_0UM, var.set_pm_particles_10um_sensor) + await sensors(CONF_PM_2_5UM, var.set_pm_particles_25um_sensor) + await sensors(CONF_PM_5_0UM, var.set_pm_particles_50um_sensor) + await sensors(CONF_PM_10_0UM, var.set_pm_particles_100um_sensor) + await sensors(CONF_FORMALDEHYDE, var.set_formaldehyde_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index d396050511f..f4f16a8b890 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -20,10 +20,6 @@ CONF_PN532_ID = "pn532_id" pn532_ns = cg.esphome_ns.namespace("pn532") PN532 = pn532_ns.class_("PN532", cg.PollingComponent) -PN532IsWritingCondition = pn532_ns.class_( - "PN532IsWritingCondition", automation.Condition -) - PN532_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(PN532), @@ -50,6 +46,11 @@ def CONFIG_SCHEMA(conf: ConfigType) -> None: ) +_request_ontag_trigger_slot = cg.slot_counter("PN532_ON_TAG_TRIGGER_COUNT") +_request_ontagremoved_trigger_slot = cg.slot_counter( + "PN532_ON_TAG_REMOVED_TRIGGER_COUNT" +) + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_FINISHED_WRITE, "add_on_finished_write_callback" @@ -62,6 +63,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontag_trigger_slot(str(var)) cg.add(var.register_ontag_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf @@ -69,6 +71,7 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG_REMOVED, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontagremoved_trigger_slot(str(var)) cg.add(var.register_ontagremoved_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf @@ -77,12 +80,12 @@ async def setup_pn532(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -automation.register_parented_condition( +automation.register_apply_condition( "pn532.is_writing", - PN532IsWritingCondition, cv.Schema( { cv.GenerateID(): cv.use_id(PN532), } ), + "is_writing()", ) diff --git a/esphome/components/pn532/binary_sensor.py b/esphome/components/pn532/binary_sensor.py index 8f490ba7d0f..b9cf9a1c279 100644 --- a/esphome/components/pn532/binary_sensor.py +++ b/esphome/components/pn532/binary_sensor.py @@ -34,6 +34,8 @@ def validate_uid(value: Any) -> str: PN532BinarySensor = pn532_ns.class_("PN532BinarySensor", binary_sensor.BinarySensor) +_request_binary_sensor_slot = cg.slot_counter("PN532_BINARY_SENSOR_COUNT") + CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(PN532BinarySensor).extend( { cv.GenerateID(CONF_PN532_ID): cv.use_id(PN532), @@ -46,6 +48,7 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_PN532_ID]) + _request_binary_sensor_slot(str(hub)) cg.add(hub.register_tag(var)) addr = [HexInt(int(x, 16)) for x in config[CONF_UID].split("-")] cg.add(var.set_uid(addr)) diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 17ee11d7d94..91593484c87 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -1,5 +1,7 @@ #include "pn532.h" +#include +#include #include #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -25,7 +27,7 @@ void PN532::setup() { } } - std::vector version_data; + PN532Frame version_data; // GetFirmwareVersion returns IC, Ver, Rev and Support if (!this->read_response(PN532_COMMAND_VERSION_DATA, version_data) || version_data.size() < 3) { ESP_LOGE(TAG, "Error getting version"); @@ -45,7 +47,7 @@ void PN532::setup() { return; } - std::vector wakeup_result; + PN532Frame wakeup_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, wakeup_result)) { this->error_code_ = WAKEUP_FAILED; this->mark_failed(); @@ -65,7 +67,7 @@ void PN532::setup() { return; } - std::vector sam_result; + PN532Frame sam_result; if (!this->read_response(PN532_COMMAND_SAMCONFIGURATION, sam_result)) { ESP_LOGV(TAG, "Invalid SAM result: (%u)", sam_result.size()); // NOLINT for (uint8_t dat : sam_result) { @@ -87,7 +89,7 @@ bool PN532::powerdown() { ESP_LOGE(TAG, "Error writing powerdown command to PN532"); return false; } - std::vector response; + PN532Frame response; if (!this->read_response(PN532_COMMAND_POWERDOWN, response)) { ESP_LOGE(TAG, "Error reading PN532 powerdown response"); return false; @@ -105,8 +107,10 @@ void PN532::update() { if (!updates_enabled_) return; +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *obj : this->binary_sensors_) obj->on_scan_end(); +#endif if (!this->write_command_({ PN532_COMMAND_INLISTPASSIVETARGET, @@ -130,7 +134,7 @@ void PN532::loop() { return; bool success = false; - std::vector read; + PN532Frame read; if (ready == READY) { success = this->read_response(PN532_COMMAND_INLISTPASSIVETARGET, read); @@ -142,11 +146,13 @@ void PN532::loop() { if (!success) { // Something failed +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT if (!this->current_uid_.empty()) { auto tag = make_unique(this->current_uid_); for (auto *trigger : this->triggers_ontagremoved_) trigger->process(tag); } +#endif this->current_uid_ = {}; this->turn_off_rf_(); return; @@ -155,11 +161,13 @@ void PN532::loop() { uint8_t num_targets = read.empty() ? 0 : read[0]; if (num_targets != 1) { // no tags found or too many +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT if (!this->current_uid_.empty()) { auto tag = make_unique(this->current_uid_); for (auto *trigger : this->triggers_ontagremoved_) trigger->process(tag); } +#endif this->current_uid_ = {}; this->turn_off_rf_(); return; @@ -181,11 +189,13 @@ void PN532::loop() { const uint8_t tag_type = tag_type_from_sel_res(sel_res); bool report = true; +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *bin_sens : this->binary_sensors_) { if (bin_sens->process(nfcid)) { report = false; } } +#endif if (nfcid.size() == this->current_uid_.size()) { bool same_uid = true; @@ -199,8 +209,10 @@ void PN532::loop() { if (next_task_ == READ) { auto tag = this->read_tag_(nfcid, tag_type); +#ifdef PN532_ON_TAG_TRIGGER_COUNT for (auto *trigger : this->triggers_ontag_) trigger->process(tag); +#endif if (report) { char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; @@ -249,39 +261,42 @@ void PN532::loop() { this->turn_off_rf_(); } -bool PN532::write_command_(const std::vector &data) { - std::vector write_data; +bool PN532::write_command_(const std::span data) { + if (data.size() > PN532_FRAME_MAX_DATA_SIZE) { + return false; + } + PN532Frame frame; // Preamble - write_data.push_back(0x00); + frame.push_back(0x00); // Start code - write_data.push_back(0x00); - write_data.push_back(0xFF); + frame.push_back(0x00); + frame.push_back(0xFF); // Length of message, TFI + data bytes const uint8_t real_length = data.size() + 1; // LEN - write_data.push_back(real_length); + frame.push_back(real_length); // LCS (Length checksum) - write_data.push_back(~real_length + 1); + frame.push_back(~real_length + 1); // TFI (Frame Identifier, 0xD4 means to PN532, 0xD5 means from PN532) - write_data.push_back(0xD4); + frame.push_back(0xD4); // calculate checksum, TFI is part of checksum uint8_t checksum = 0xD4; // DATA for (uint8_t dat : data) { - write_data.push_back(dat); + frame.push_back(dat); checksum += dat; } // DCS (Data checksum) - write_data.push_back(~checksum + 1); + frame.push_back(~checksum + 1); // Postamble - write_data.push_back(0x00); + frame.push_back(0x00); - this->write_data(write_data); + this->write_data(frame); return this->read_ack_(); } @@ -289,8 +304,8 @@ bool PN532::write_command_(const std::vector &data) { bool PN532::read_ack_() { ESP_LOGV(TAG, "Reading ACK"); - std::vector data; - if (!this->read_data(data, 6)) { + PN532Frame data; + if (!this->read_data(data, 6) || data.size() < 7) { return false; } @@ -303,13 +318,15 @@ bool PN532::read_ack_() { } void PN532::send_ack_() { + static constexpr std::array ACK_FRAME = {0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; ESP_LOGV(TAG, "Sending ACK for abort"); - this->write_data({0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}); + this->write_data(ACK_FRAME); delay(10); } void PN532::send_nack_() { + static constexpr std::array NACK_FRAME = {0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00}; ESP_LOGV(TAG, "Sending NACK for retransmit"); - this->write_data({0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00}); + this->write_data(NACK_FRAME); delay(10); } @@ -425,7 +442,7 @@ bool PN532::write_tag_(nfc::NfcTagUid &uid, const uint8_t tag_type, nfc::NdefMes return false; } -bool PN532::in_data_exchange_(const std::vector &command, std::vector &response) { +bool PN532::in_data_exchange_(const std::span command, PN532Frame &response) { // formatting a tag takes seconds of back-to-back exchanges inside loop(), longer than the task watchdog allows App.feed_wdt(); if (!this->write_command_(command)) { @@ -439,7 +456,25 @@ bool PN532::in_data_exchange_(const std::vector &command, std::vectorin_data_exchange_( + { + PN532_COMMAND_INDATAEXCHANGE, + 0x01, // One card + nfc::MIFARE_CMD_READ, + address, + }, + response) || + response.size() != data.size()) { + return false; + } + std::copy(response.begin(), response.end(), data.begin()); return true; } @@ -458,9 +493,11 @@ void PN532::dump_config() { LOG_UPDATE_INTERVAL(this); +#ifdef PN532_BINARY_SENSOR_COUNT for (auto *child : this->binary_sensors_) { LOG_BINARY_SENSOR(" ", "Tag", child); } +#endif } bool PN532BinarySensor::process(const nfc::NfcTagUid &data) { diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index c518afc4914..5502eac6ec8 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -1,14 +1,16 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/components/nfc/nfc_tag.h" #include "esphome/components/nfc/nfc.h" #include "esphome/components/nfc/automation.h" +#include #include -#include +#include +#include namespace esphome::pn532 { @@ -41,6 +43,21 @@ inline uint8_t tag_type_from_sel_res(uint8_t sel_res) { return nfc::TAG_TYPE_UNKNOWN; } +/// Most data bytes a normal information frame carries: LEN is one byte and counts the TFI byte plus the data +static constexpr size_t PN532_FRAME_MAX_DATA_SIZE = 254; +/// A received frame at its longest: the status byte the I2C bus prepends, preamble, start code (2), LEN, LCS, TFI, +/// the data, DCS and postamble +static constexpr size_t PN532_FRAME_MAX_SIZE = 1 + 6 + PN532_FRAME_MAX_DATA_SIZE + 2; +/// Holds one frame in either direction so bus traffic never allocates +using PN532Frame = StaticVector; +/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads +using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) +static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; +/// A MIFARE READ answers with 16 bytes: one Classic block or four Ultralight pages +static constexpr size_t MIFARE_READ_SIZE = 16; +using MifareReadData = std::array; + class PN532BinarySensor; class PN532 : public PollingComponent { @@ -54,9 +71,15 @@ class PN532 : public PollingComponent { void loop() override; void on_powerdown() override { powerdown(); } +#ifdef PN532_BINARY_SENSOR_COUNT void register_tag(PN532BinarySensor *tag) { this->binary_sensors_.push_back(tag); } +#endif +#ifdef PN532_ON_TAG_TRIGGER_COUNT void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } +#endif +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } +#endif template void add_on_finished_write_callback(F &&callback) { this->on_finished_write_callback_.add(std::forward(callback)); @@ -72,16 +95,21 @@ class PN532 : public PollingComponent { protected: void turn_off_rf_(); - bool write_command_(const std::vector &data); + bool write_command_(std::span data); + bool write_command_(std::initializer_list data) { + return this->write_command_(std::span(data.begin(), data.size())); + } bool read_ack_(); void send_ack_(); void send_nack_(); enum PN532ReadReady read_ready_(bool block); virtual bool is_read_ready() = 0; - virtual bool write_data(const std::vector &data) = 0; - virtual bool read_data(std::vector &data, uint8_t len) = 0; - virtual bool read_response(uint8_t command, std::vector &data) = 0; + virtual bool write_data(std::span data) = 0; + /// Reads `len` frame bytes into `data` behind a leading status byte, so every bus presents the I2C layout + virtual bool read_data(PN532Frame &data, size_t len) = 0; + /// Reads the response to `command`; on success `data` holds only the bytes that follow the response code + virtual bool read_response(uint8_t command, PN532Frame &data) = 0; std::unique_ptr read_tag_(nfc::NfcTagUid &uid, uint8_t tag_type); @@ -90,23 +118,28 @@ class PN532 : public PollingComponent { bool write_tag_(nfc::NfcTagUid &uid, uint8_t tag_type, nfc::NdefMessage *message); /// Sends an InDataExchange command and reads the response; returns false unless the status byte reports success. /// On success, `response` holds the data returned by the target, without the status byte. - bool in_data_exchange_(const std::vector &command, std::vector &response); + bool in_data_exchange_(std::span command, PN532Frame &response); + bool in_data_exchange_(std::initializer_list command, PN532Frame &response) { + return this->in_data_exchange_(std::span(command.begin(), command.size()), response); + } + /// Sends MIFARE READ for `address` and returns the 16 bytes the tag answers with + bool mifare_read_(uint8_t address, MifareReadData &data); std::unique_ptr read_mifare_classic_tag_(nfc::NfcTagUid &uid); - bool read_mifare_classic_block_(uint8_t block_num, std::vector &data); - bool write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); + bool read_mifare_classic_block_(uint8_t block_num, MifareReadData &data); + bool write_mifare_classic_block_(uint8_t block_num, std::span data); bool auth_mifare_classic_block_(nfc::NfcTagUid &uid, uint8_t block_num, uint8_t key_num, const uint8_t *key); bool format_mifare_classic_mifare_(nfc::NfcTagUid &uid); bool format_mifare_classic_ndef_(nfc::NfcTagUid &uid); bool write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); std::unique_ptr read_mifare_ultralight_tag_(nfc::NfcTagUid &uid); - bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); + bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - bool find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, + bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index); - bool write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); + bool write_mifare_ultralight_page_(uint8_t page_num, std::span write_data); bool write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); bool clean_mifare_ultralight_(); @@ -123,10 +156,16 @@ class PN532 : public PollingComponent { }; // members are ordered by alignment, widest first, to minimize padding - CallbackManager on_finished_write_callback_; - std::vector binary_sensors_; - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; + LazyCallbackManager on_finished_write_callback_; +#ifdef PN532_BINARY_SENSOR_COUNT + StaticVector binary_sensors_; +#endif +#ifdef PN532_ON_TAG_TRIGGER_COUNT + StaticVector triggers_ontag_; +#endif +#ifdef PN532_ON_TAG_REMOVED_TRIGGER_COUNT + StaticVector triggers_ontagremoved_; +#endif std::unique_ptr next_task_message_to_write_; nfc::NfcTagUid current_uid_; uint32_t rd_start_time_{0}; // valid only while rd_started_ is set @@ -156,9 +195,4 @@ class PN532BinarySensor final : public binary_sensor::BinarySensor { bool found_{false}; }; -template class PN532IsWritingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_writing(); } -}; - } // namespace esphome::pn532 diff --git a/esphome/components/pn532/pn532_mifare_classic.cpp b/esphome/components/pn532/pn532_mifare_classic.cpp index 166f4ab7761..9f937b3e288 100644 --- a/esphome/components/pn532/pn532_mifare_classic.cpp +++ b/esphome/components/pn532/pn532_mifare_classic.cpp @@ -1,4 +1,6 @@ +#include #include +#include #include #include "pn532.h" @@ -13,10 +15,10 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid uint8_t message_start_index = 0; uint32_t message_length = 0; + MifareReadData block_data; if (this->auth_mifare_classic_block_(uid, current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)) { - std::vector data; - if (this->read_mifare_classic_block_(current_block, data)) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { + if (this->read_mifare_classic_block_(current_block, block_data)) { + if (!nfc::decode_mifare_classic_tlv(block_data, message_length, message_start_index)) { return make_unique(uid, nfc::ERROR); } } else { @@ -27,52 +29,50 @@ std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid ESP_LOGV(TAG, "Tag is not NDEF formatted"); return make_unique(uid, nfc::MIFARE_CLASSIC); } + if (message_length > MIFARE_CLASSIC_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return make_unique(uid, nfc::MIFARE_CLASSIC); + } - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; + const uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); + FixedVector buffer; + if (!buffer.try_init(buffer_size)) { + ESP_LOGE(TAG, "Out of memory reading NDEF message of %" PRIu32 " bytes", buffer_size); + return make_unique(uid, nfc::MIFARE_CLASSIC); + } - while (index < buffer_size) { + while (buffer.size() < buffer_size) { if (nfc::mifare_classic_is_first_block(current_block)) { if (!this->auth_mifare_classic_block_(uid, current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY)) { ESP_LOGE(TAG, "Error, Block authentication failed for %d", current_block); return make_unique(uid, nfc::MIFARE_CLASSIC); } } - std::vector block_data; if (!this->read_mifare_classic_block_(current_block, block_data)) { ESP_LOGE(TAG, "Error reading block %d", current_block); return make_unique(uid, nfc::MIFARE_CLASSIC); } - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); + for (const uint8_t byte : block_data) { + buffer.push_back(byte); + } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; - if (nfc::mifare_classic_is_trailer_block(current_block)) { current_block++; } } - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { + if (message_start_index >= buffer.size()) { return make_unique(uid, nfc::MIFARE_CLASSIC); } - return make_unique(uid, nfc::MIFARE_CLASSIC, buffer); + return make_unique( + uid, nfc::MIFARE_CLASSIC, + make_unique(std::span(buffer).subspan(message_start_index))); } -bool PN532::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { - if (!this->in_data_exchange_( - { - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - block_num, - }, - data) || - data.size() != nfc::MIFARE_CLASSIC_BLOCK_SIZE) { +bool PN532::read_mifare_classic_block_(uint8_t block_num, MifareReadData &data) { + if (!this->mifare_read_(block_num, data)) { return false; } @@ -82,20 +82,25 @@ bool PN532::read_mifare_classic_block_(uint8_t block_num, std::vector & } bool PN532::auth_mifare_classic_block_(nfc::NfcTagUid &uid, uint8_t block_num, uint8_t key_num, const uint8_t *key) { - std::vector data({ + // InDataExchange, Tg, key slot, block, key (6), UID (4) + StaticVector data = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card key_num, // Mifare Key slot block_num, // Block number - }); - data.insert(data.end(), key, key + 6); + }; + for (size_t i = 0; i < 6; i++) { + data.push_back(key[i]); + } // the command takes exactly 4 UID bytes (UM0701-02, 7.3.8); for 7-byte UIDs these are the last 4, as in libnfc if (uid.size() < 4) { return false; } - data.insert(data.end(), uid.end() - 4, uid.end()); + for (size_t i = uid.size() - 4; i < uid.size(); i++) { + data.push_back(uid[i]); + } - std::vector response; + PN532Frame response; if (!this->in_data_exchange_(data, response)) { ESP_LOGE(TAG, "Authentication failed - Block 0x%02x", block_num); return false; @@ -117,20 +122,20 @@ bool PN532::format_mifare_classic_mifare_(nfc::NfcTagUid &uid) { continue; } if (block != 0) { - if (!this->write_mifare_classic_block_(block, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block); error = true; } } - if (!this->write_mifare_classic_block_(block + 1, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 1, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 1); error = true; } - if (!this->write_mifare_classic_block_(block + 2, BLANK_BUFFER.data(), BLANK_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 2, BLANK_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 2); error = true; } - if (!this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER.data(), TRAILER_BUFFER.size())) { + if (!this->write_mifare_classic_block_(block + 3, TRAILER_BUFFER)) { ESP_LOGE(TAG, "Unable to write block %d", block + 3); error = true; } @@ -157,11 +162,11 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { ESP_LOGE(TAG, "Unable to authenticate block 0 for formatting!"); return false; } - if (!this->write_mifare_classic_block_(1, BLOCK_1_DATA.data(), BLOCK_1_DATA.size())) + if (!this->write_mifare_classic_block_(1, BLOCK_1_DATA)) return false; - if (!this->write_mifare_classic_block_(2, BLOCK_2_DATA.data(), BLOCK_2_DATA.size())) + if (!this->write_mifare_classic_block_(2, BLOCK_2_DATA)) return false; - if (!this->write_mifare_classic_block_(3, BLOCK_3_TRAILER.data(), BLOCK_3_TRAILER.size())) + if (!this->write_mifare_classic_block_(3, BLOCK_3_TRAILER)) return false; ESP_LOGD(TAG, "Sector 0 formatted to NDEF"); @@ -173,25 +178,25 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { return false; } if (block == 4) { - if (!this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE.data(), EMPTY_NDEF_MESSAGE.size())) { + if (!this->write_mifare_classic_block_(block, EMPTY_NDEF_MESSAGE)) { ESP_LOGE(TAG, "Unable to write block %d", block); error = true; } } else { - if (!this->write_mifare_classic_block_(block, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block); error = true; } } - if (!this->write_mifare_classic_block_(block + 1, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block + 1, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block + 1); error = true; } - if (!this->write_mifare_classic_block_(block + 2, BLANK_BLOCK.data(), BLANK_BLOCK.size())) { + if (!this->write_mifare_classic_block_(block + 2, BLANK_BLOCK)) { ESP_LOGE(TAG, "Unable to write block %d", block + 2); error = true; } - if (!this->write_mifare_classic_block_(block + 3, NDEF_TRAILER.data(), NDEF_TRAILER.size())) { + if (!this->write_mifare_classic_block_(block + 3, NDEF_TRAILER)) { ESP_LOGE(TAG, "Unable to write trailer block %d", block + 3); error = true; } @@ -199,16 +204,18 @@ bool PN532::format_mifare_classic_ndef_(nfc::NfcTagUid &uid) { return !error; } -bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { - std::vector cmd({ +bool PN532::write_mifare_classic_block_(uint8_t block_num, const std::span data) { + StaticVector cmd = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card nfc::MIFARE_CMD_WRITE, block_num, - }); - cmd.insert(cmd.end(), data, data + len); + }; + for (const uint8_t byte : data) { + cmd.push_back(byte); + } - std::vector response; + PN532Frame response; if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing block %d", block_num); return false; @@ -218,22 +225,10 @@ bool PN532::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, } bool PN532::write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(encoded.size()); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_block = 4; @@ -245,7 +240,8 @@ bool PN532::write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *mes } } - if (!this->write_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE)) { + if (!this->write_mifare_classic_block_(current_block, + std::span(&buffer[index], nfc::MIFARE_CLASSIC_BLOCK_SIZE))) { return false; } index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index d918c9d52d2..90c85237224 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -10,7 +10,7 @@ namespace esphome::pn532 { static const char *const TAG = "pn532.mifare_ultralight"; std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid &uid) { - std::vector data; + UltralightReadBuffer data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (!this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, data)) { @@ -41,31 +41,30 @@ std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid & return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); + // skip page 3 as well as any bytes ahead of message_start_index + const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + if (skip >= data.size()) { + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } - return make_unique(uid, nfc::NFC_FORUM_TYPE_2, data); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2, + make_unique(std::span(data).subspan(skip))); } -bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { - const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - std::vector response; +bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { + MifareReadData chunk; - for (uint8_t i = 0; i * read_increment < num_bytes; i++) { + for (uint8_t i = 0; i * MIFARE_READ_SIZE < num_bytes; i++) { // a READ returns 4 pages (16 bytes) - if (!this->in_data_exchange_( - { - PN532_COMMAND_INDATAEXCHANGE, - 0x01, // One card - nfc::MIFARE_CMD_READ, - uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), - }, - response) || - response.size() != read_increment) { + if (!this->mifare_read_(uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), chunk)) { return false; } - const uint16_t remaining = num_bytes - i * read_increment; - data.insert(data.end(), response.begin(), response.begin() + std::min(read_increment, remaining)); + // keep only the bytes still wanted from this read + const uint16_t remaining = num_bytes - i * MIFARE_READ_SIZE; + const size_t count = std::min(MIFARE_READ_SIZE, remaining); + for (const uint8_t byte : std::span(chunk).subspan(0, count)) { + data.push_back(byte); + } } char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -74,7 +73,7 @@ bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes return true; } -bool PN532::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN532::is_mifare_ultralight_formatted_(const std::span page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -83,7 +82,7 @@ bool PN532::is_mifare_ultralight_formatted_(const std::vector &page_3_t } uint16_t PN532::read_mifare_ultralight_capacity_() { - std::vector data; + UltralightReadBuffer data; if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) && data.size() > 2) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; @@ -91,7 +90,7 @@ uint16_t PN532::read_mifare_ultralight_capacity_() { return 0; } -bool PN532::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -114,33 +113,23 @@ bool PN532::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6 bool PN532::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(encoded.size()); if (buffer_length > capacity) { ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); return false; } - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; while (index < buffer_length) { - if (!this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE)) { + if (!this->write_mifare_ultralight_page_( + current_page, std::span(&buffer[index], nfc::MIFARE_ULTRALIGHT_PAGE_SIZE))) { return false; } index += nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; @@ -156,23 +145,25 @@ bool PN532::clean_mifare_ultralight_() { static constexpr std::array BLANK_DATA = {0x00, 0x00, 0x00, 0x00}; for (int i = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; i < pages; i++) { - if (!this->write_mifare_ultralight_page_(i, BLANK_DATA.data(), BLANK_DATA.size())) { + if (!this->write_mifare_ultralight_page_(i, BLANK_DATA)) { return false; } } return true; } -bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector cmd({ +bool PN532::write_mifare_ultralight_page_(uint8_t page_num, const std::span write_data) { + StaticVector cmd = { PN532_COMMAND_INDATAEXCHANGE, 0x01, // One card nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num, - }); - cmd.insert(cmd.end(), write_data, write_data + len); + }; + for (const uint8_t byte : write_data) { + cmd.push_back(byte); + } - std::vector response; + PN532Frame response; if (!this->in_data_exchange_(cmd, response)) { ESP_LOGE(TAG, "Error writing page %u", page_num); return false; diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 4160076a7de..7f00e18d4c0 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include + // Based on: // - https://cdn-shop.adafruit.com/datasheets/PN532C106_Application+Note_v1.2.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf @@ -20,11 +22,14 @@ bool PN532I2C::is_read_ready() { return status & 0x01; } -bool PN532I2C::write_data(const std::vector &data) { +bool PN532I2C::write_data(const std::span data) { return this->write(data.data(), data.size()) == i2c::ERROR_OK; } -bool PN532I2C::read_data(std::vector &data, uint8_t len) { +bool PN532I2C::read_data(pn532::PN532Frame &data, size_t len) { + if (len + 1 > pn532::PN532_FRAME_MAX_SIZE) { + return false; + } delay(1); if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { @@ -36,7 +41,7 @@ bool PN532I2C::read_data(std::vector &data, uint8_t len) { return this->read_bytes_raw(data.data(), len + 1); } -bool PN532I2C::read_response(uint8_t command, std::vector &data) { +bool PN532I2C::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Reading response"); uint8_t len = this->read_response_length_(); if (len == 0) { @@ -64,33 +69,33 @@ bool PN532I2C::read_response(uint8_t command, std::vector &data) { return false; } - data.erase(data.begin(), data.begin() + 6); // Remove headers - + // frame: status, preamble, start code (2), LEN, LCS, TFI, command response code, data, DCS, postamble + constexpr size_t tfi_offset = 6; uint8_t checksum = 0; - for (int i = 0; i < len + 1; i++) { - uint8_t dat = data[i]; - checksum += dat; + for (size_t i = 0; i < len + 1U; i++) { + checksum += data[tfi_offset + i]; } checksum = ~checksum + 1; - if (data[len + 1] != checksum) { - ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len + 1], checksum); + if (data[tfi_offset + len + 1] != checksum) { + ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[tfi_offset + len + 1], checksum); return false; } - if (data[len + 2] != 0x00) { + if (data[tfi_offset + len + 2] != 0x00) { ESP_LOGV(TAG, "read data invalid postamble!"); return false; } - data.erase(data.begin(), data.begin() + 2); // Remove TFI and command code - data.erase(data.end() - 2, data.end()); // Remove checksum and postamble + // keep only the data bytes that follow the command response code + std::copy(data.begin() + tfi_offset + 2, data.begin() + tfi_offset + len + 1, data.begin()); + data.resize(len - 1); return true; } uint8_t PN532I2C::read_response_length_() { - std::vector data; + pn532::PN532Frame data; if (!this->read_data(data, 6)) { return 0; } diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index 6495f175999..f148e196a02 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -4,7 +4,7 @@ #include "esphome/components/pn532/pn532.h" #include "esphome/components/i2c/i2c.h" -#include +#include namespace esphome::pn532_i2c { @@ -14,9 +14,9 @@ class PN532I2C final : public pn532::PN532, public i2c::I2CDevice { protected: bool is_read_ready() override; - bool write_data(const std::vector &data) override; - bool read_data(std::vector &data, uint8_t len) override; - bool read_response(uint8_t command, std::vector &data) override; + bool write_data(std::span data) override; + bool read_data(pn532::PN532Frame &data, size_t len) override; + bool read_response(uint8_t command, pn532::PN532Frame &data) override; uint8_t read_response_length_(); }; diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 112adc4ecb8..73d65e4330d 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -2,6 +2,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + // Based on: // - https://cdn-shop.adafruit.com/datasheets/PN532C106_Application+Note_v1.2.pdf // - https://www.nxp.com/docs/en/nxp/application-notes/AN133910.pdf @@ -31,7 +33,7 @@ bool PN532Spi::is_read_ready() { return ready; } -bool PN532Spi::write_data(const std::vector &data) { +bool PN532Spi::write_data(const std::span data) { this->enable(); delay(2); // First byte, communication mode: Write data @@ -46,8 +48,8 @@ bool PN532Spi::write_data(const std::vector &data) { return true; } -bool PN532Spi::read_data(std::vector &data, uint8_t len) { - if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { +bool PN532Spi::read_data(pn532::PN532Frame &data, size_t len) { + if (len + 1 > pn532::PN532_FRAME_MAX_SIZE || this->read_ready_(true) != pn532::PN532ReadReady::READY) { return false; } @@ -58,10 +60,11 @@ bool PN532Spi::read_data(std::vector &data, uint8_t len) { ESP_LOGV(TAG, "Reading data"); - data.resize(len); - this->read_array(data.data(), len); + // lead with a status byte so callers see the same layout as on the I2C bus + data.resize(len + 1); + data[0] = 0x01; + this->read_array(data.data() + 1, len); this->disable(); - data.insert(data.begin(), 0x01); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; #endif @@ -69,7 +72,7 @@ bool PN532Spi::read_data(std::vector &data, uint8_t len) { return true; } -bool PN532Spi::read_response(uint8_t command, std::vector &data) { +bool PN532Spi::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Reading response"); if (this->read_ready_(true) != pn532::PN532ReadReady::READY) { @@ -80,8 +83,8 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { delay(2); this->write_byte(0x03); - std::vector header(7); - this->read_array(header.data(), 7); + std::array header; + this->read_array(header.data(), header.size()); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(PN532_MAX_LOG_BYTES)]; @@ -141,7 +144,7 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { return false; } - data.erase(data.end() - 2, data.end()); // Remove checksum and postamble + data.resize(len - 1); // Remove checksum and postamble return true; } diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index f29950c423c..b051cd781e5 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -4,7 +4,7 @@ #include "esphome/components/pn532/pn532.h" #include "esphome/components/spi/spi.h" -#include +#include namespace esphome::pn532_spi { @@ -18,9 +18,9 @@ class PN532Spi final : public pn532::PN532, protected: bool is_read_ready() override; - bool write_data(const std::vector &data) override; - bool read_data(std::vector &data, uint8_t len) override; - bool read_response(uint8_t command, std::vector &data) override; + bool write_data(std::span data) override; + bool read_data(pn532::PN532Frame &data, size_t len) override; + bool read_response(uint8_t command, pn532::PN532Frame &data) override; }; } // namespace esphome::pn532_spi diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index a4e45b8584a..58d9daa311e 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -12,17 +12,15 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); if (!this->read_bytes_raw(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE)) { return nfc::STATUS_FAILED; } - uint8_t length = rx.get_payload_size(); - if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); - if (!this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { - return nfc::STATUS_FAILED; - } + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); + if (length > 0 && !this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { + return nfc::STATUS_FAILED; } // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short // low pulse may be missed; that means more data is waiting, not that this read failed (UM10936, 3.4). @@ -33,7 +31,7 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7150I2C::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160/pn7160.cpp b/esphome/components/pn7160/pn7160.cpp index 6dcb73bee12..185ff0ce097 100644 --- a/esphome/components/pn7160/pn7160.cpp +++ b/esphome/components/pn7160/pn7160.cpp @@ -68,9 +68,8 @@ uint8_t PN7160::verify_reset(nfc::NciMessage &rx, const bool reset_config) { uint8_t PN7160::process_init_response(nfc::NciMessage &rx) { // the chip's version information is logged from CORE_RESET_NTF in verify_reset() if (rx.get_message().size() >= 8) { - std::vector features(rx.get_message().begin() + 4, rx.get_message().begin() + 8); char feat_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "NFCC features: %s", nfc::format_bytes_to(feat_buf, features)); + ESP_LOGD(TAG, "NFCC features: %s", nfc::format_bytes_to(feat_buf, rx.get_payload().subspan(1, 4))); } return rx.get_simple_status_response(); diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index 799a258b983..d559c6c2517 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -12,17 +12,15 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); if (!this->read_bytes_raw(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE)) { return nfc::STATUS_FAILED; } - uint8_t length = rx.get_payload_size(); - if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); - if (!this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { - return nfc::STATUS_FAILED; - } + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); + if (length > 0 && !this->read_bytes_raw(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length)) { + return nfc::STATUS_FAILED; } // IRQ normally drops at the end of the read. If another message is queued it rises again at once, and the short // low pulse may be missed; that means more data is waiting, not that this read failed (UM11495, 6.2.4). @@ -33,7 +31,7 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7160I2C::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } diff --git a/esphome/components/pn7160_spi/pn7160_spi.cpp b/esphome/components/pn7160_spi/pn7160_spi.cpp index 43420f9efdf..85a6c1fb7d0 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.cpp +++ b/esphome/components/pn7160_spi/pn7160_spi.cpp @@ -16,14 +16,14 @@ uint8_t PN7160Spi::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { return nfc::STATUS_FAILED; } - rx.get_message().resize(nfc::NCI_PKT_HEADER_SIZE); + rx.reset(); this->enable(); this->write_byte(TDD_SPI_READ); // send "transfer direction detector" this->read_array(rx.get_message().data(), nfc::NCI_PKT_HEADER_SIZE); - uint8_t length = rx.get_payload_size(); + const uint8_t length = rx.get_payload_size(); + rx.set_payload_size(length); if (length > 0) { - rx.get_message().resize(length + nfc::NCI_PKT_HEADER_SIZE); this->read_array(rx.get_message().data() + nfc::NCI_PKT_HEADER_SIZE, length); } this->disable(); @@ -36,7 +36,7 @@ uint8_t PN7160Spi::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7160Spi::write_nfcc(nfc::NciMessage &tx) { - auto encoded = tx.encode(); + const auto encoded = tx.encode(); this->enable(); // send "transfer direction detector"; the NFCC answers 0xFF when it is ready to receive (UM11495, 6.3.3) const uint8_t status = this->transfer_byte(TDD_SPI_WRITE); diff --git a/esphome/components/pn71xx/__init__.py b/esphome/components/pn71xx/__init__.py index 8cda915febc..a8825747d17 100644 --- a/esphome/components/pn71xx/__init__.py +++ b/esphome/components/pn71xx/__init__.py @@ -117,6 +117,12 @@ _CALLBACK_AUTOMATIONS = ( ) +_request_ontag_trigger_slot = cg.slot_counter("PN71XX_ON_TAG_TRIGGER_COUNT") +_request_ontagremoved_trigger_slot = cg.slot_counter( + "PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT" +) + + async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) @@ -135,6 +141,7 @@ async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontag_trigger_slot(str(var)) cg.add(var.register_ontag_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf @@ -142,6 +149,7 @@ async def setup_pn71xx(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_TAG_REMOVED, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + _request_ontagremoved_trigger_slot(str(var)) cg.add(var.register_ontagremoved_trigger(trigger)) await automation.build_automation( trigger, [(cg.std_string, "x"), (nfc.NfcTag, "tag")], conf diff --git a/esphome/components/pn71xx/pn71xx.cpp b/esphome/components/pn71xx/pn71xx.cpp index f13e2de7026..3d0b46efb47 100644 --- a/esphome/components/pn71xx/pn71xx.cpp +++ b/esphome/components/pn71xx/pn71xx.cpp @@ -56,7 +56,10 @@ void PN71xx::set_tag_emulation_message(const std::shared_ptr & ESP_LOGE(TAG, "Tag emulation message too long: %zu > %u bytes", encoded.size(), CARD_EMU_T4T_MAX_NDEF_SIZE); return; } - this->card_emulation_ndef_ = std::move(encoded); + this->card_emulation_ndef_.init(encoded.size()); + for (const uint8_t byte : encoded) { + this->card_emulation_ndef_.push_back(byte); + } ESP_LOGD(TAG, "Tag emulation message set"); } @@ -190,8 +193,9 @@ uint8_t PN71xx::set_test_mode(const TestMode test_mode, const std::vectornci_fsm_set_state_(NCIState::NFCC_RESET); result.clear(); } else { - result = rx.get_message(); - result.erase(result.begin(), result.begin() + 4); // remove NCI header + // the payload after the status byte, if the NFCC sent one + const auto payload = rx.get_payload(); + result.assign(payload.begin() + std::min(1, payload.size()), payload.end()); if (!result.empty()) { char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; ESP_LOGW(TAG, "Test results: %s", nfc::format_bytes_to(buf, result)); @@ -256,9 +260,7 @@ uint8_t PN71xx::send_init_config_() { return nfc::STATUS_FAILED; } - const auto pmu_config = this->pmu_config(); - tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(pmu_config.begin(), pmu_config.end())); + tx.set_message(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, this->pmu_config()); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error sending PMU config"); @@ -269,19 +271,16 @@ uint8_t PN71xx::send_init_config_() { } uint8_t PN71xx::send_core_config_() { - const auto *core_config_begin = std::begin(CORE_CONFIG_SOLO); - const auto *core_config_end = std::end(CORE_CONFIG_SOLO); + std::span core_config = CORE_CONFIG_SOLO; this->core_config_is_solo_ = true; if (this->listening_enabled_ && this->polling_enabled_) { - core_config_begin = std::begin(CORE_CONFIG_RW_CE); - core_config_end = std::end(CORE_CONFIG_RW_CE); + core_config = CORE_CONFIG_RW_CE; this->core_config_is_solo_ = false; } nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, - std::vector(core_config_begin, core_config_end)); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::NCI_CORE_GID, nfc::NCI_CORE_SET_CONFIG_OID, core_config); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGW(TAG, "Error sending core config"); @@ -313,11 +312,10 @@ uint8_t PN71xx::refresh_core_config_() { } uint8_t PN71xx::set_discover_map_() { - std::vector discover_map = {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}; - discover_map.insert(discover_map.end(), std::begin(RF_DISCOVER_MAP_CONFIG), std::end(RF_DISCOVER_MAP_CONFIG)); - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, discover_map); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_MAP_OID, + {sizeof(RF_DISCOVER_MAP_CONFIG) / 3}); + tx.append(RF_DISCOVER_MAP_CONFIG); if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error sending discover map poll config"); @@ -327,10 +325,9 @@ uint8_t PN71xx::set_discover_map_() { } uint8_t PN71xx::set_listen_mode_routing_() { - const auto routing_config = this->listen_mode_routing_config(); nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_SET_LISTEN_MODE_ROUTING_OID, - std::vector(routing_config.begin(), routing_config.end())); + this->listen_mode_routing_config()); if (this->transceive_(tx, rx, NFCC_INIT_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error setting listen mode routing config"); @@ -340,27 +337,20 @@ uint8_t PN71xx::set_listen_mode_routing_() { } uint8_t PN71xx::start_discovery_() { - const uint8_t *rf_discovery_config = RF_DISCOVERY_CONFIG; - uint8_t length = sizeof(RF_DISCOVERY_CONFIG); + std::span rf_discovery_config = RF_DISCOVERY_CONFIG; if (!this->listening_enabled_) { - length = sizeof(RF_DISCOVERY_POLL_CONFIG); rf_discovery_config = RF_DISCOVERY_POLL_CONFIG; } else if (!this->polling_enabled_) { - length = sizeof(RF_DISCOVERY_LISTEN_CONFIG); rf_discovery_config = RF_DISCOVERY_LISTEN_CONFIG; } - std::vector discover_config = std::vector((length * 2) + 1); - - discover_config[0] = length; - for (uint8_t i = 0; i < length; i++) { - discover_config[(i * 2) + 1] = rf_discovery_config[i]; - discover_config[(i * 2) + 2] = 0x01; // RF Technology and Mode will be executed in every discovery period - } - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, discover_config); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, + {static_cast(rf_discovery_config.size())}); + for (const uint8_t mode_tech : rf_discovery_config) { + tx.append({mode_tech, 0x01}); // RF Technology and Mode will be executed in every discovery period + } if (this->transceive_(tx, rx) != nfc::STATUS_OK) { switch (rx.get_simple_status_response()) { @@ -416,10 +406,9 @@ void PN71xx::select_endpoint_() { } else if (endpoint.protocol == nfc::PROT_MIFARE) { interface = nfc::INTF_TAGCMD; } - std::vector endpoint_data = {endpoint.id, endpoint.protocol, interface}; - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, endpoint_data); + nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_SELECT_OID, + {endpoint.id, endpoint.protocol, interface}); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error selecting endpoint"); @@ -491,31 +480,57 @@ uint8_t PN71xx::write_endpoint_(const uint8_t protocol, nfc::NfcTagUid &uid, return nfc::STATUS_FAILED; } -std::unique_ptr PN71xx::build_tag_(const uint8_t mode_tech, const uint8_t protocol, - const std::vector &data) { - switch (mode_tech) { - case (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA): { - // RF technology parameters: SENS_RES (2 bytes), NFCID1 length, NFCID1, ... - if (data.size() < 3) { - ESP_LOGE(TAG, "NFC-A parameters too short"); - return nullptr; - } - uint8_t uid_length = data[2]; - if (uid_length == 0 || uid_length > nfc::NFC_UID_MAX_LENGTH || data.size() < 3u + uid_length) { - ESP_LOGE(TAG, "Invalid UID length: %u", uid_length); - return nullptr; - } - nfc::NfcTagUid uid(data.begin() + 3, data.begin() + 3 + uid_length); - if (protocol == nfc::PROT_MIFARE) { - return make_unique(uid, nfc::MIFARE_CLASSIC); - } - if (protocol == nfc::PROT_T2T) { - return make_unique(uid, nfc::NFC_FORUM_TYPE_2); - } - return make_unique(uid); - } +bool PN71xx::parse_uid_(const uint8_t mode_tech, const std::span rf_tech_params, nfc::NfcTagUid &uid) { + if (mode_tech != (nfc::MODE_POLL | nfc::TECH_PASSIVE_NFCA)) { + return false; } - return nullptr; + // RF technology parameters: SENS_RES (2 bytes), NFCID1 length, NFCID1, ... + if (rf_tech_params.size() < 3) { + ESP_LOGE(TAG, "NFC-A parameters too short"); + return false; + } + const uint8_t uid_length = rf_tech_params[2]; + if (uid_length == 0 || uid_length > nfc::NFC_UID_MAX_LENGTH || rf_tech_params.size() < 3u + uid_length) { + ESP_LOGE(TAG, "Invalid UID length: %u", uid_length); + return false; + } + uid.assign(rf_tech_params.begin() + 3, rf_tech_params.begin() + 3 + uid_length); + return true; +} + +std::unique_ptr PN71xx::build_tag_(const uint8_t protocol, const nfc::NfcTagUid &uid) { + if (protocol == nfc::PROT_MIFARE) { + return make_unique(uid, nfc::MIFARE_CLASSIC); + } + if (protocol == nfc::PROT_T2T) { + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + return make_unique(uid); +} + +size_t PN71xx::find_or_add_tag_(const uint8_t protocol, const nfc::NfcTagUid &uid) { + const auto tag_loc = this->find_tag_uid_(uid); + if (tag_loc.has_value()) { + ESP_LOGVV(TAG, "Tag cache updated"); + return tag_loc.value(); + } + if (this->discovered_endpoint_.size() >= MAX_DISCOVERED_ENDPOINTS) { + size_t oldest = 0; + for (size_t i = 1; i < this->discovered_endpoint_.size(); i++) { + if (this->discovered_endpoint_[i].last_seen < this->discovered_endpoint_[oldest].last_seen) { + oldest = i; + } + } + ESP_LOGW(TAG, "Tag cache full; dropping the tag seen longest ago"); + this->erase_tag_(oldest); + } + this->discovered_endpoint_.emplace_next() = DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), + .tag = this->build_tag_(protocol, uid), + .id = 0, + .protocol = protocol, + .trig_called = false}; + ESP_LOGVV(TAG, "Tag added to cache"); + return this->discovered_endpoint_.size() - 1; } optional PN71xx::find_tag_uid_(const nfc::NfcTagUid &uid) { @@ -549,15 +564,23 @@ void PN71xx::purge_old_tags_() { void PN71xx::erase_tag_(const uint8_t tag_index) { if (tag_index < this->discovered_endpoint_.size()) { +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT for (auto *trigger : this->triggers_ontagremoved_) { trigger->process(this->discovered_endpoint_[tag_index].tag); } +#endif for (auto *listener : this->tag_listeners_) { listener->tag_off(*this->discovered_endpoint_[tag_index].tag); } char uid_buf[nfc::FORMAT_UID_BUFFER_SIZE]; ESP_LOGI(TAG, "Tag %s removed", nfc::format_uid_to(uid_buf, this->discovered_endpoint_[tag_index].tag->get_uid())); - this->discovered_endpoint_.erase(this->discovered_endpoint_.begin() + tag_index); + // keep the remaining entries in order; selecting_endpoint_ indexes into this list + for (size_t i = tag_index; i + 1 < this->discovered_endpoint_.size(); i++) { + this->discovered_endpoint_[i] = std::move(this->discovered_endpoint_[i + 1]); + } + // StaticVector::resize() does not destroy the dropped slot; free its tag now, not when the slot is reused + this->discovered_endpoint_[this->discovered_endpoint_.size() - 1].tag.reset(); + this->discovered_endpoint_.resize(this->discovered_endpoint_.size() - 1); } } @@ -815,31 +838,16 @@ void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoi this->nci_fsm_set_state_(NCIState::EP_DEACTIVATING); return; } - auto incoming_tag = - this->build_tag_(mode_tech, protocol, - std::vector(rx.get_message().begin() + nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS, - rx.get_message().end())); - - if (incoming_tag == nullptr) { + nfc::NfcTagUid uid; + if (!this->parse_uid_(mode_tech, + std::span(rx.get_message()).subspan(nfc::RF_INTF_ACTIVATED_NTF_RF_TECH_PARAMS), + uid)) { ESP_LOGE(TAG, "Could not build tag"); } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = discovery_id; - this->discovered_endpoint_[tag_loc.value()].protocol = protocol; - this->discovered_endpoint_[tag_loc.value()].last_seen = App.get_loop_component_start_time(); - ESP_LOGVV(TAG, "Tag cache updated"); - } else { - this->discovered_endpoint_.emplace_back(DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), - .tag = std::move(incoming_tag), - .id = discovery_id, - .protocol = protocol, - .trig_called = false}); - tag_loc = this->discovered_endpoint_.size() - 1; - ESP_LOGVV(TAG, "Tag added to cache"); - } - - auto &working_endpoint = this->discovered_endpoint_[tag_loc.value()]; + auto &working_endpoint = this->discovered_endpoint_[this->find_or_add_tag_(protocol, uid)]; + working_endpoint.id = discovery_id; + working_endpoint.protocol = protocol; + working_endpoint.last_seen = App.get_loop_component_start_time(); switch (this->next_task_) { case EP_CLEAN: @@ -886,8 +894,8 @@ void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoi if (this->read_endpoint_data_(working_endpoint.protocol, *working_endpoint.tag) != nfc::STATUS_OK) { ESP_LOGW(TAG, " Unable to read NDEF record(s)"); } else if (working_endpoint.tag->has_ndef_message()) { - const auto message = working_endpoint.tag->get_ndef_message(); - const auto records = message->get_records(); + const auto &message = working_endpoint.tag->get_ndef_message(); + const auto &records = message->get_records(); ESP_LOGD(TAG, " NDEF record(s):"); for (const auto &record : records) { ESP_LOGD(TAG, " %s - %s", record->get_type().c_str(), record->get_payload().c_str()); @@ -895,9 +903,11 @@ void PN71xx::process_rf_intf_activated_oid_(nfc::NciMessage &rx) { // an endpoi } else { ESP_LOGW(TAG, " No NDEF records found"); } +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT for (auto *trigger : this->triggers_ontag_) { trigger->process(working_endpoint.tag); } +#endif for (auto *listener : this->tag_listeners_) { listener->tag_on(*working_endpoint.tag); } @@ -924,31 +934,20 @@ void PN71xx::process_rf_discover_oid_(nfc::NciMessage &rx) { ESP_LOGE(TAG, "RF_DISCOVER_NTF too short"); return; } - auto incoming_tag = this->build_tag_( - rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - std::vector(rx.get_message().begin() + nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS, rx.get_message().end())); - - if (incoming_tag == nullptr) { + const uint8_t protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); + nfc::NfcTagUid uid; + if (!this->parse_uid_(rx.get_message_byte(nfc::RF_DISCOVER_NTF_MODE_TECH), + std::span(rx.get_message()).subspan(nfc::RF_DISCOVER_NTF_RF_TECH_PARAMS), uid)) { ESP_LOGE(TAG, "Could not build tag!"); } else { - auto tag_loc = this->find_tag_uid_(incoming_tag->get_uid()); - if (tag_loc.has_value()) { - this->discovered_endpoint_[tag_loc.value()].id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); - this->discovered_endpoint_[tag_loc.value()].protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL); - this->discovered_endpoint_[tag_loc.value()].last_seen = App.get_loop_component_start_time(); - ESP_LOGVV(TAG, "Tag found & updated"); - } else { - this->discovered_endpoint_.emplace_back( - DiscoveredEndpoint{.last_seen = App.get_loop_component_start_time(), - .tag = std::move(incoming_tag), - .id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID), - .protocol = rx.get_message_byte(nfc::RF_DISCOVER_NTF_PROTOCOL), - .trig_called = false}); - ESP_LOGVV(TAG, "Tag saved"); - } + auto &endpoint = this->discovered_endpoint_[this->find_or_add_tag_(protocol, uid)]; + endpoint.id = rx.get_message_byte(nfc::RF_DISCOVER_NTF_DISCOVERY_ID); + endpoint.protocol = protocol; + endpoint.last_seen = App.get_loop_component_start_time(); } - if (rx.get_message().back() != nfc::RF_DISCOVER_NTF_NT_MORE) { + const auto &ntf = rx.get_message(); + if (ntf[ntf.size() - 1] != nfc::RF_DISCOVER_NTF_NT_MORE) { this->nci_fsm_set_state_(NCIState::RFST_W4_HOST_SELECT); ESP_LOGVV(TAG, "Discovered %zu endpoints", this->discovered_endpoint_.size()); } @@ -986,17 +985,12 @@ void PN71xx::process_data_message_(nfc::NciMessage &rx) { char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; ESP_LOGVV(TAG, "Received data message: %s", nfc::format_bytes_to(buf, rx.get_message())); - std::vector ndef_response; + CardEmuResponse ndef_response; this->card_emu_t4t_get_response_(rx.get_message(), ndef_response); if (ndef_response.empty()) { return; // no message returned, we cannot respond } - // the CC limits MLe so every response fits in a single data packet (payload length is one byte) - if (ndef_response.size() > UINT8_MAX) { - ESP_LOGE(TAG, "Card emulation response too long: %zu bytes", ndef_response.size()); - return; - } nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, ndef_response); ESP_LOGVV(TAG, "Sending data message: %s", nfc::format_bytes_to(buf, tx.get_message())); @@ -1005,12 +999,14 @@ void PN71xx::process_data_message_(nfc::NciMessage &rx) { } } -bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length, std::vector &ndef_response) { +bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length, CardEmuResponse &ndef_response) { const auto &ndef_message = this->card_emulation_ndef_; // the NDEF file is a two-byte big-endian length (NLEN) followed by the message const uint16_t ndef_msg_size = ndef_message.size(); const uint32_t file_size = ndef_msg_size + 2; - if (offset + static_cast(length) > file_size) { + // the reply must also hold the two status bytes; the CC's MLe keeps well-behaved readers below this + if (offset + static_cast(length) > file_size || + length + sizeof(CARD_EMU_T4T_OK) > CardEmuResponse::capacity()) { return false; } for (uint32_t i = offset; i < offset + static_cast(length); i++) { @@ -1029,7 +1025,7 @@ bool PN71xx::card_emu_t4t_read_ndef_(const uint16_t offset, const uint8_t length return true; } -void PN71xx::card_emu_t4t_get_response_(const std::vector &response, std::vector &ndef_response) { +void PN71xx::card_emu_t4t_get_response_(const std::span response, CardEmuResponse &ndef_response) { ndef_response.clear(); if (this->card_emulation_ndef_.empty()) { ESP_LOGE(TAG, "No NDEF message is set; tag emulation not possible"); @@ -1047,6 +1043,11 @@ void PN71xx::card_emu_t4t_get_response_(const std::vector &response, st auto apdu_starts_with = [&](const uint8_t *cmd, size_t cmd_size) { return apdu_size >= cmd_size && std::equal(cmd, cmd + cmd_size, apdu_begin); }; + auto append = [&](std::span bytes) { + for (const uint8_t byte : bytes) { + ndef_response.push_back(byte); + } + }; bool ok = false; if (apdu_is(CARD_EMU_T4T_APP_SELECT, sizeof(CARD_EMU_T4T_APP_SELECT)) || @@ -1071,8 +1072,7 @@ void PN71xx::card_emu_t4t_get_response_(const std::vector &response, st if (this->ce_state_ == CardEmulationState::CARD_EMU_CC_SELECTED) { ESP_LOGVV(TAG, "CARD_EMU_T4T_READ with CARD_EMU_CC_SELECTED"); if (offset + static_cast(length) <= sizeof(CARD_EMU_T4T_CC)) { - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_CC) + offset, - std::begin(CARD_EMU_T4T_CC) + offset + length); + append(std::span(CARD_EMU_T4T_CC).subspan(offset, length)); ok = true; } } else if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED) { @@ -1084,18 +1084,18 @@ void PN71xx::card_emu_t4t_get_response_(const std::vector &response, st const uint8_t length = apdu_begin[4]; if (this->ce_state_ == CardEmulationState::CARD_EMU_NDEF_SELECTED && apdu_size >= 5u + length) { ESP_LOGVV(TAG, "CARD_EMU_T4T_WRITE"); - std::vector ndef_msg_written(apdu_begin + 5, apdu_begin + 5 + length); char write_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; - ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, nfc::format_bytes_to(write_buf, ndef_msg_written)); + ESP_LOGD(TAG, "Received %u-byte NDEF message: %s", length, + nfc::format_bytes_to(write_buf, response.subspan(nfc::NCI_PKT_HEADER_SIZE + 5, length))); ok = true; } } if (ok) { - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_OK), std::end(CARD_EMU_T4T_OK)); + append(CARD_EMU_T4T_OK); } else { ndef_response.clear(); - ndef_response.insert(ndef_response.end(), std::begin(CARD_EMU_T4T_NOK), std::end(CARD_EMU_T4T_NOK)); + append(CARD_EMU_T4T_NOK); this->ce_state_ = CardEmulationState::CARD_EMU_IDLE; } } diff --git a/esphome/components/pn71xx/pn71xx.h b/esphome/components/pn71xx/pn71xx.h index f9cfddc2e92..940d1874fc0 100644 --- a/esphome/components/pn71xx/pn71xx.h +++ b/esphome/components/pn71xx/pn71xx.h @@ -9,6 +9,7 @@ #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include #include #include @@ -68,7 +69,8 @@ static constexpr uint8_t CORE_CONFIG_RW_CE[] = {0x01, // Number of parameter f 0xF8, // TOTAL_DURATION (low)... 0x02}; // TOTAL_DURATION (high): 760 ms -static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { // poll modes +static constexpr uint8_t RF_DISCOVER_MAP_CONFIG[] = { + // poll modes nfc::PROT_T1T, nfc::RF_DISCOVER_MAP_MODE_POLL, nfc::INTF_FRAME, // poll mode nfc::PROT_T2T, nfc::RF_DISCOVER_MAP_MODE_POLL, @@ -131,6 +133,16 @@ enum class TestMode : uint8_t { TEST_GET_REGISTER, }; +/// A card emulation reply; the CC limits reads so every reply fits one NCI data packet +using CardEmuResponse = StaticVector; +/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads +using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) +static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; +/// Tags tracked at once. A device with a random UID looks like a new tag on every activation, but each entry +/// expires after tag_ttl, so a handful is enough; when the cache is full the entry seen longest ago is evicted. +static constexpr size_t MAX_DISCOVERED_ENDPOINTS = 8; + struct DiscoveredEndpoint { uint32_t last_seen; std::unique_ptr tag; @@ -162,8 +174,12 @@ class PN71xx : public nfc::Nfcc, public Component { void set_polling_on(); bool polling_enabled() { return this->polling_enabled_; } +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT void register_ontag_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontag_.push_back(trig); } +#endif +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT void register_ontagremoved_trigger(nfc::NfcOnTagTrigger *trig) { this->triggers_ontagremoved_.push_back(trig); } +#endif template void add_on_emulated_tag_scan_callback(F &&callback) { this->on_emulated_tag_scan_callback_.add(std::forward(callback)); @@ -219,7 +235,11 @@ class PN71xx : public nfc::Nfcc, public Component { uint8_t format_endpoint_(uint8_t protocol); uint8_t write_endpoint_(uint8_t protocol, nfc::NfcTagUid &uid, std::shared_ptr &message); - std::unique_ptr build_tag_(uint8_t mode_tech, uint8_t protocol, const std::vector &data); + /// Reads the UID from the RF technology parameters of a discovery or activation notification + bool parse_uid_(uint8_t mode_tech, std::span rf_tech_params, nfc::NfcTagUid &uid); + std::unique_ptr build_tag_(uint8_t protocol, const nfc::NfcTagUid &uid); + /// Finds a cached endpoint by UID, or caches a new one, evicting the entry seen longest ago if the cache is full + size_t find_or_add_tag_(uint8_t protocol, const nfc::NfcTagUid &uid); optional find_tag_uid_(const nfc::NfcTagUid &uid); void purge_old_tags_(); void erase_tag_(uint8_t tag_index); @@ -237,8 +257,8 @@ class PN71xx : public nfc::Nfcc, public Component { void process_rf_deactivate_oid_(nfc::NciMessage &rx); void process_data_message_(nfc::NciMessage &rx); - void card_emu_t4t_get_response_(const std::vector &response, std::vector &ndef_response); - bool card_emu_t4t_read_ndef_(uint16_t offset, uint8_t length, std::vector &ndef_response); + void card_emu_t4t_get_response_(std::span response, CardEmuResponse &ndef_response); + bool card_emu_t4t_read_ndef_(uint16_t offset, uint8_t length, CardEmuResponse &ndef_response); uint8_t transceive_(nfc::NciMessage &tx, nfc::NciMessage &rx, uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool expect_notification = true); @@ -248,7 +268,7 @@ class PN71xx : public nfc::Nfcc, public Component { uint8_t wait_for_irq_(uint16_t timeout = NFCC_DEFAULT_TIMEOUT, bool pin_state = true); uint8_t read_mifare_classic_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_classic_block_(uint8_t block_num, std::vector &data); + uint8_t read_mifare_classic_block_(uint8_t block_num, std::array &data); uint8_t write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len); uint8_t auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key); uint8_t sect_to_auth_(uint8_t block_num); @@ -258,10 +278,10 @@ class PN71xx : public nfc::Nfcc, public Component { uint8_t halt_mifare_classic_tag_(); uint8_t read_mifare_ultralight_tag_(nfc::NfcTag &tag); - uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data); - bool is_mifare_ultralight_formatted_(const std::vector &page_3_to_6); + uint8_t read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - uint8_t find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, + uint8_t find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index); uint8_t write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len); uint8_t write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message); @@ -275,13 +295,17 @@ class PN71xx : public nfc::Nfcc, public Component { }; // members are ordered by alignment, widest first, to minimize padding - CallbackManager on_emulated_tag_scan_callback_; - CallbackManager on_finished_write_callback_; + LazyCallbackManager on_emulated_tag_scan_callback_; + LazyCallbackManager on_finished_write_callback_; - std::vector discovered_endpoint_; - std::vector card_emulation_ndef_; // encoded emulation message; empty when none is set - std::vector triggers_ontag_; - std::vector triggers_ontagremoved_; + StaticVector discovered_endpoint_; + FixedVector card_emulation_ndef_; // encoded emulation message; empty when none is set +#ifdef PN71XX_ON_TAG_TRIGGER_COUNT + StaticVector triggers_ontag_; +#endif +#ifdef PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT + StaticVector triggers_ontagremoved_; +#endif std::shared_ptr next_task_message_to_write_; GPIOPin *irq_pin_{nullptr}; diff --git a/esphome/components/pn71xx/pn71xx_mifare_classic.cpp b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp index 0bd6148e8a8..c1c927634d4 100644 --- a/esphome/components/pn71xx/pn71xx_mifare_classic.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp @@ -1,4 +1,6 @@ +#include #include +#include #include #include "pn71xx.h" @@ -17,56 +19,59 @@ uint8_t PN71xx::read_mifare_classic_tag_(nfc::NfcTag &tag) { ESP_LOGE(TAG, "Tag auth failed while attempting to read tag data"); return nfc::STATUS_FAILED; } - std::vector data; + std::array block_data; - if (this->read_mifare_classic_block_(current_block, data) == nfc::STATUS_OK) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { + if (this->read_mifare_classic_block_(current_block, block_data) == nfc::STATUS_OK) { + if (!nfc::decode_mifare_classic_tlv(block_data, message_length, message_start_index)) { return nfc::STATUS_FAILED; } } else { ESP_LOGE(TAG, "Failed to read block %u", current_block); return nfc::STATUS_FAILED; } + if (message_length > MIFARE_CLASSIC_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return nfc::STATUS_FAILED; + } - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; + const uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); + FixedVector buffer; + if (!buffer.try_init(buffer_size)) { + ESP_LOGE(TAG, "Out of memory reading NDEF message of %" PRIu32 " bytes", buffer_size); + return nfc::STATUS_FAILED; + } - while (index < buffer_size) { + while (buffer.size() < buffer_size) { if (nfc::mifare_classic_is_first_block(current_block)) { if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Block authentication failed for %u", current_block); return nfc::STATUS_FAILED; } } - std::vector block_data; if (this->read_mifare_classic_block_(current_block, block_data) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error reading block %u", current_block); return nfc::STATUS_FAILED; - } else { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); + } + for (const uint8_t byte : block_data) { + buffer.push_back(byte); } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; - if (nfc::mifare_classic_is_trailer_block(current_block)) { current_block++; } } - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { + if (message_start_index >= buffer.size()) { return nfc::STATUS_FAILED; } - - tag.set_ndef_message(make_unique(buffer)); + tag.set_ndef_message(make_unique(std::span(buffer).subspan(message_start_index))); return nfc::STATUS_OK; } -uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { +uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, + std::array &data) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_READ, block_num}); char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -84,32 +89,35 @@ uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, std::vectorsect_to_auth_(block_num), key_num}); - + uint8_t key_select = key_num; switch (key_num) { case nfc::MIFARE_CMD_AUTH_A: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_A; + key_select = MFC_AUTHENTICATE_PARAM_KS_A; break; case nfc::MIFARE_CMD_AUTH_B: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_B; + key_select = MFC_AUTHENTICATE_PARAM_KS_B; break; default: break; } - if (key != nullptr) { - tx.get_message().back() |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; - tx.get_message().insert(tx.get_message().end(), key, key + 6); + key_select |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {MFC_AUTHENTICATE_OID, this->sect_to_auth_(block_num), key_select}); + if (key != nullptr) { + tx.append(std::span(key, 6)); } char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -249,7 +257,7 @@ uint8_t PN71xx::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da } // write command part two tx.set_payload({XCHG_DATA_OID}); - tx.get_message().insert(tx.get_message().end(), data, data + len); + tx.append(std::span(data, len)); ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 2: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { @@ -268,22 +276,10 @@ uint8_t PN71xx::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da } uint8_t PN71xx::write_mifare_classic_tag_(const std::shared_ptr &message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(encoded.size()); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_block = 4; @@ -295,7 +291,7 @@ uint8_t PN71xx::write_mifare_classic_tag_(const std::shared_ptrwrite_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE) != + if (this->write_mifare_classic_block_(current_block, &buffer[index], nfc::MIFARE_CLASSIC_BLOCK_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } diff --git a/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp b/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp index b8fac21bbbe..53874e18de5 100644 --- a/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_ultralight.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -10,7 +11,7 @@ namespace esphome::pn71xx { static const char *const TAG = "pn71xx.mifare_ultralight"; uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { - std::vector data; + UltralightReadBuffer data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, data) != nfc::STATUS_OK) { @@ -42,21 +43,24 @@ uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { return nfc::STATUS_FAILED; } } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); - - tag.set_ndef_message(make_unique(data)); + // skip page 3 as well as any bytes ahead of message_start_index + const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + if (skip >= data.size()) { + return nfc::STATUS_FAILED; + } + tag.set_ndef_message(make_unique(std::span(data).subspan(skip))); return nfc::STATUS_OK; } -uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { +uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_READ, start_page}); for (size_t i = 0; i * read_increment < num_bytes; i++) { - tx.get_message().back() = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; + const uint8_t page = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; + tx.set_payload({nfc::MIFARE_CMD_READ, page}); // a short answer (e.g. a NAK for a page beyond the end of the tag) is retried a limited number of times uint8_t attempts = 0; do { @@ -65,16 +69,15 @@ uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_b return nfc::STATUS_FAILED; } if (++attempts > NFCC_MAX_COMM_FAILS && rx.get_payload_size() < read_increment) { - ESP_LOGE(TAG, "Short read from page %u", tx.get_message().back()); + ESP_LOGE(TAG, "Short read from page %u", page); return nfc::STATUS_FAILED; } } while (rx.get_payload_size() < read_increment); - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? rx.get_message().end() - 1 - : rx.get_message().end() - (bytes_offset - num_bytes + 1); - - if ((pages_in_end_itr > rx.get_message().begin()) && (pages_in_end_itr < rx.get_message().end())) { - data.insert(data.end(), rx.get_message().begin() + nfc::NCI_PKT_HEADER_SIZE, pages_in_end_itr); + // the payload ends with a status byte; keep only the bytes still wanted from this read + const uint16_t wanted = num_bytes - i * read_increment; + const size_t count = std::min(read_increment, wanted); + for (const uint8_t byte : rx.get_payload().subspan(0, count)) { + data.push_back(byte); } } @@ -84,7 +87,7 @@ uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_b return nfc::STATUS_OK; } -bool PN71xx::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN71xx::is_mifare_ultralight_formatted_(const std::span page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -93,7 +96,7 @@ bool PN71xx::is_mifare_ultralight_formatted_(const std::vector &page_3_ } uint16_t PN71xx::read_mifare_ultralight_capacity_() { - std::vector data; + UltralightReadBuffer data; if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) == nfc::STATUS_OK) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; @@ -101,7 +104,7 @@ uint16_t PN71xx::read_mifare_ultralight_capacity_() { return 0; } -uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -124,33 +127,22 @@ uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::vector &page_3_ uint8_t PN71xx::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(encoded.size()); if (buffer_length > capacity) { ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); return nfc::STATUS_FAILED; } - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; while (index < buffer_length) { - if (this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != + if (this->write_mifare_ultralight_page_(current_page, &buffer[index], nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } @@ -175,11 +167,9 @@ uint8_t PN71xx::clean_mifare_ultralight_() { } uint8_t PN71xx::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector payload = {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}; - payload.insert(payload.end(), write_data, write_data + len); - nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, payload); + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}); + tx.append(std::span(write_data, len)); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error writing page %u", page_num); diff --git a/esphome/components/pvvx_mithermometer/sensor.py b/esphome/components/pvvx_mithermometer/sensor.py index ad13bdcca77..e4a7781538d 100644 --- a/esphome/components/pvvx_mithermometer/sensor.py +++ b/esphome/components/pvvx_mithermometer/sensor.py @@ -85,18 +85,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) - if CONF_BATTERY_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_VOLTAGE]) - cg.add(var.set_battery_voltage(sens)) - if CONF_SIGNAL_STRENGTH in config: - sens = await sensor.new_sensor(config[CONF_SIGNAL_STRENGTH]) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index bf5e31b48ab..2b19cf84985 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -69,19 +69,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index ff4cedc8146..90e0abb14c1 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -103,27 +103,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_VOLTAGE in config: - conf = config[CONF_VOLTAGE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_voltage_sensor(sens)) - if CONF_CURRENT in config: - conf = config[CONF_CURRENT] - sens = await sensor.new_sensor(conf) - cg.add(var.set_current_sensor(sens)) - if CONF_POWER in config: - conf = config[CONF_POWER] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_sensor(sens)) - if CONF_ENERGY in config: - conf = config[CONF_ENERGY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_energy_sensor(sens)) - if CONF_FREQUENCY in config: - conf = config[CONF_FREQUENCY] - sens = await sensor.new_sensor(conf) - cg.add(var.set_frequency_sensor(sens)) - if CONF_POWER_FACTOR in config: - conf = config[CONF_POWER_FACTOR] - sens = await sensor.new_sensor(conf) - cg.add(var.set_power_factor_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER, var.set_power_sensor) + await sensors(CONF_ENERGY, var.set_energy_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) diff --git a/esphome/components/ruuvitag/sensor.py b/esphome/components/ruuvitag/sensor.py index 1ea9c457f3e..abbda5872b2 100644 --- a/esphome/components/ruuvitag/sensor.py +++ b/esphome/components/ruuvitag/sensor.py @@ -129,36 +129,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_PRESSURE in config: - sens = await sensor.new_sensor(config[CONF_PRESSURE]) - cg.add(var.set_pressure(sens)) - if CONF_ACCELERATION in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION]) - cg.add(var.set_acceleration(sens)) - if CONF_ACCELERATION_X in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_X]) - cg.add(var.set_acceleration_x(sens)) - if CONF_ACCELERATION_Y in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_Y]) - cg.add(var.set_acceleration_y(sens)) - if CONF_ACCELERATION_Z in config: - sens = await sensor.new_sensor(config[CONF_ACCELERATION_Z]) - cg.add(var.set_acceleration_z(sens)) - if CONF_BATTERY_VOLTAGE in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_VOLTAGE]) - cg.add(var.set_battery_voltage(sens)) - if CONF_TX_POWER in config: - sens = await sensor.new_sensor(config[CONF_TX_POWER]) - cg.add(var.set_tx_power(sens)) - if CONF_MOVEMENT_COUNTER in config: - sens = await sensor.new_sensor(config[CONF_MOVEMENT_COUNTER]) - cg.add(var.set_movement_counter(sens)) - if CONF_MEASUREMENT_SEQUENCE_NUMBER in config: - sens = await sensor.new_sensor(config[CONF_MEASUREMENT_SEQUENCE_NUMBER]) - cg.add(var.set_measurement_sequence_number(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_ACCELERATION, var.set_acceleration) + await sensors(CONF_ACCELERATION_X, var.set_acceleration_x) + await sensors(CONF_ACCELERATION_Y, var.set_acceleration_y) + await sensors(CONF_ACCELERATION_Z, var.set_acceleration_z) + await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage) + await sensors(CONF_TX_POWER, var.set_tx_power) + await sensors(CONF_MOVEMENT_COUNTER, var.set_movement_counter) + await sensors(CONF_MEASUREMENT_SEQUENCE_NUMBER, var.set_measurement_sequence_number) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index aadc85ddb7e..f3f4ede8c93 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -99,17 +99,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_update_interval(config[CONF_UPDATE_INTERVAL])) - if CONF_CO2 in config: - sens = await sensor.new_sensor(config[CONF_CO2]) - cg.add(var.set_co2_sensor(sens)) - - if CONF_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity_sensor(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) automation.register_apply_action( diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8d7e981d933..74c56acf120 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -160,29 +160,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_TOTAL_POWER in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) - cg.add(var.set_total_power_sensor(sens)) - - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_frequency_sensor(sens)) - - if CONF_IMPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_ACTIVE_ENERGY]) - cg.add(var.set_import_active_energy_sensor(sens)) - - if CONF_EXPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_ACTIVE_ENERGY]) - cg.add(var.set_export_active_energy_sensor(sens)) - - if CONF_IMPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_REACTIVE_ENERGY]) - cg.add(var.set_import_reactive_energy_sensor(sens)) - - if CONF_EXPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_REACTIVE_ENERGY]) - cg.add(var.set_export_reactive_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: diff --git a/esphome/components/seeed_mr24hpc1/binary_sensor.py b/esphome/components/seeed_mr24hpc1/binary_sensor.py index 121eb2b4b36..98f3995ed98 100644 --- a/esphome/components/seeed_mr24hpc1/binary_sensor.py +++ b/esphome/components/seeed_mr24hpc1/binary_sensor.py @@ -15,7 +15,6 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if has_target_config := config.get(CONF_HAS_TARGET): - sens = await binary_sensor.new_binary_sensor(has_target_config) - cg.add(mr24hpc1_component.set_has_target_binary_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_has_target_binary_sensor) diff --git a/esphome/components/seeed_mr24hpc1/button/__init__.py b/esphome/components/seeed_mr24hpc1/button/__init__.py index 3386118bcff..58c62652b09 100644 --- a/esphome/components/seeed_mr24hpc1/button/__init__.py +++ b/esphome/components/seeed_mr24hpc1/button/__init__.py @@ -33,12 +33,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_restart_button(b)) - if custom_set_end_config := config.get(CONF_CUSTOM_SET_END): - b = await button.new_button(custom_set_end_config) - await cg.register_parented(b, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_custom_set_end_button(b)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_RESTART, hub.set_restart_button) + await buttons(CONF_CUSTOM_SET_END, hub.set_custom_set_end_button) diff --git a/esphome/components/seeed_mr24hpc1/number/__init__.py b/esphome/components/seeed_mr24hpc1/number/__init__.py index d01618b0e60..0dc6d07a96d 100644 --- a/esphome/components/seeed_mr24hpc1/number/__init__.py +++ b/esphome/components/seeed_mr24hpc1/number/__init__.py @@ -65,67 +65,46 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if sensitivity_config := config.get(CONF_SENSITIVITY): - n = await number.new_number( - sensitivity_config, - min_value=0, - max_value=3, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_sensitivity_number(n)) - if custom_mode_config := config.get(CONF_CUSTOM_MODE): - n = await number.new_number( - custom_mode_config, - min_value=0, - max_value=4, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_custom_mode_number(n)) - if existence_threshold_config := config.get(CONF_EXISTENCE_THRESHOLD): - n = await number.new_number( - existence_threshold_config, - min_value=0, - max_value=250, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_existence_threshold_number(n)) - if motion_threshold_config := config.get(CONF_MOTION_THRESHOLD): - n = await number.new_number( - motion_threshold_config, - min_value=0, - max_value=250, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_threshold_number(n)) - if motion_trigger_config := config.get(CONF_MOTION_TRIGGER): - n = await number.new_number( - motion_trigger_config, - min_value=0, - max_value=150, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_trigger_number(n)) - if motion_to_rest_config := config.get(CONF_MOTION_TO_REST): - n = await number.new_number( - motion_to_rest_config, - min_value=0, - max_value=3000, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_motion_to_rest_number(n)) - if custom_unman_time_config := config.get(CONF_CUSTOM_UNMAN_TIME): - n = await number.new_number( - custom_unman_time_config, - min_value=0, - max_value=3600, - step=1, - ) - await cg.register_parented(n, mr24hpc1_component) - cg.add(mr24hpc1_component.set_custom_unman_time_number(n)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_SENSITIVITY, hub.set_sensitivity_number, min_value=0, max_value=3, step=1 + ) + await numbers( + CONF_CUSTOM_MODE, hub.set_custom_mode_number, min_value=0, max_value=4, step=1 + ) + await numbers( + CONF_EXISTENCE_THRESHOLD, + hub.set_existence_threshold_number, + min_value=0, + max_value=250, + step=1, + ) + await numbers( + CONF_MOTION_THRESHOLD, + hub.set_motion_threshold_number, + min_value=0, + max_value=250, + step=1, + ) + await numbers( + CONF_MOTION_TRIGGER, + hub.set_motion_trigger_number, + min_value=0, + max_value=150, + step=1, + ) + await numbers( + CONF_MOTION_TO_REST, + hub.set_motion_to_rest_number, + min_value=0, + max_value=3000, + step=1, + ) + await numbers( + CONF_CUSTOM_UNMAN_TIME, + hub.set_custom_unman_time_number, + min_value=0, + max_value=3600, + step=1, + ) diff --git a/esphome/components/seeed_mr24hpc1/select/__init__.py b/esphome/components/seeed_mr24hpc1/select/__init__.py index 9d46dee6f69..208040e9e05 100644 --- a/esphome/components/seeed_mr24hpc1/select/__init__.py +++ b/esphome/components/seeed_mr24hpc1/select/__init__.py @@ -40,64 +40,57 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if scenemode_config := config.get(CONF_SCENE_MODE): - s = await select.new_select( - scenemode_config, - options=["None", "Living Room", "Bedroom", "Washroom", "Area Detection"], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_scene_mode_select(s)) - if unmantime_config := config.get(CONF_UNMAN_TIME): - s = await select.new_select( - unmantime_config, - options=[ - "None", - "10s", - "30s", - "1min", - "2min", - "5min", - "10min", - "30min", - "60min", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_unman_time_select(s)) - if existence_boundary_config := config.get(CONF_EXISTENCE_BOUNDARY): - s = await select.new_select( - existence_boundary_config, - options=[ - "0.5m", - "1.0m", - "1.5m", - "2.0m", - "2.5m", - "3.0m", - "3.5m", - "4.0m", - "4.5m", - "5.0m", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_existence_boundary_select(s)) - if motion_boundary_config := config.get(CONF_MOTION_BOUNDARY): - s = await select.new_select( - motion_boundary_config, - options=[ - "0.5m", - "1.0m", - "1.5m", - "2.0m", - "2.5m", - "3.0m", - "3.5m", - "4.0m", - "4.5m", - "5.0m", - ], - ) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_motion_boundary_select(s)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_SCENE_MODE, + hub.set_scene_mode_select, + options=["None", "Living Room", "Bedroom", "Washroom", "Area Detection"], + ) + await selects( + CONF_UNMAN_TIME, + hub.set_unman_time_select, + options=[ + "None", + "10s", + "30s", + "1min", + "2min", + "5min", + "10min", + "30min", + "60min", + ], + ) + await selects( + CONF_EXISTENCE_BOUNDARY, + hub.set_existence_boundary_select, + options=[ + "0.5m", + "1.0m", + "1.5m", + "2.0m", + "2.5m", + "3.0m", + "3.5m", + "4.0m", + "4.5m", + "5.0m", + ], + ) + await selects( + CONF_MOTION_BOUNDARY, + hub.set_motion_boundary_select, + options=[ + "0.5m", + "1.0m", + "1.5m", + "2.0m", + "2.5m", + "3.0m", + "3.5m", + "4.0m", + "4.5m", + "5.0m", + ], + ) diff --git a/esphome/components/seeed_mr24hpc1/sensor.py b/esphome/components/seeed_mr24hpc1/sensor.py index 36ee2c0087e..e2e45656ce8 100644 --- a/esphome/components/seeed_mr24hpc1/sensor.py +++ b/esphome/components/seeed_mr24hpc1/sensor.py @@ -62,27 +62,18 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if custompresenceofdetection_config := config.get( - CONF_CUSTOM_PRESENCE_OF_DETECTION - ): - sens = await sensor.new_sensor(custompresenceofdetection_config) - cg.add(mr24hpc1_component.set_custom_presence_of_detection_sensor(sens)) - if movementsigns_config := config.get(CONF_MOVEMENT_SIGNS): - sens = await sensor.new_sensor(movementsigns_config) - cg.add(mr24hpc1_component.set_movement_signs_sensor(sens)) - if custommotiondistance_config := config.get(CONF_CUSTOM_MOTION_DISTANCE): - sens = await sensor.new_sensor(custommotiondistance_config) - cg.add(mr24hpc1_component.set_custom_motion_distance_sensor(sens)) - if customspatialstaticvalue_config := config.get(CONF_CUSTOM_SPATIAL_STATIC_VALUE): - sens = await sensor.new_sensor(customspatialstaticvalue_config) - cg.add(mr24hpc1_component.set_custom_spatial_static_value_sensor(sens)) - if customspatialmotionvalue_config := config.get(CONF_CUSTOM_SPATIAL_MOTION_VALUE): - sens = await sensor.new_sensor(customspatialmotionvalue_config) - cg.add(mr24hpc1_component.set_custom_spatial_motion_value_sensor(sens)) - if custommotionspeed_config := config.get(CONF_CUSTOM_MOTION_SPEED): - sens = await sensor.new_sensor(custommotionspeed_config) - cg.add(mr24hpc1_component.set_custom_motion_speed_sensor(sens)) - if custommodenum_config := config.get(CONF_CUSTOM_MODE_NUM): - sens = await sensor.new_sensor(custommodenum_config) - cg.add(mr24hpc1_component.set_custom_mode_num_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + sensors = sensor.sub_sensors(config) + await sensors( + CONF_CUSTOM_PRESENCE_OF_DETECTION, hub.set_custom_presence_of_detection_sensor + ) + await sensors(CONF_MOVEMENT_SIGNS, hub.set_movement_signs_sensor) + await sensors(CONF_CUSTOM_MOTION_DISTANCE, hub.set_custom_motion_distance_sensor) + await sensors( + CONF_CUSTOM_SPATIAL_STATIC_VALUE, hub.set_custom_spatial_static_value_sensor + ) + await sensors( + CONF_CUSTOM_SPATIAL_MOTION_VALUE, hub.set_custom_spatial_motion_value_sensor + ) + await sensors(CONF_CUSTOM_MOTION_SPEED, hub.set_custom_motion_speed_sensor) + await sensors(CONF_CUSTOM_MODE_NUM, hub.set_custom_mode_num_sensor) diff --git a/esphome/components/seeed_mr24hpc1/switch/__init__.py b/esphome/components/seeed_mr24hpc1/switch/__init__.py index f9588d783e3..76e8a2089bc 100644 --- a/esphome/components/seeed_mr24hpc1/switch/__init__.py +++ b/esphome/components/seeed_mr24hpc1/switch/__init__.py @@ -24,8 +24,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if underlying_open_function_config := config.get(CONF_UNDERLYING_OPEN_FUNCTION): - s = await switch.new_switch(underlying_open_function_config) - await cg.register_parented(s, config[CONF_MR24HPC1_ID]) - cg.add(mr24hpc1_component.set_underlying_open_function_switch(s)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches( + CONF_UNDERLYING_OPEN_FUNCTION, hub.set_underlying_open_function_switch + ) diff --git a/esphome/components/seeed_mr24hpc1/text_sensor.py b/esphome/components/seeed_mr24hpc1/text_sensor.py index 8f284cb20ab..2f926b1eda9 100644 --- a/esphome/components/seeed_mr24hpc1/text_sensor.py +++ b/esphome/components/seeed_mr24hpc1/text_sensor.py @@ -49,28 +49,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr24hpc1_component = await cg.get_variable(config[CONF_MR24HPC1_ID]) - if heartbeat_config := config.get(CONF_HEART_BEAT): - sens = await text_sensor.new_text_sensor(heartbeat_config) - cg.add(mr24hpc1_component.set_heartbeat_state_text_sensor(sens)) - if productmodel_config := config.get(CONF_PRODUCT_MODEL): - sens = await text_sensor.new_text_sensor(productmodel_config) - cg.add(mr24hpc1_component.set_product_model_text_sensor(sens)) - if productid_config := config.get(CONF_PRODUCT_ID): - sens = await text_sensor.new_text_sensor(productid_config) - cg.add(mr24hpc1_component.set_product_id_text_sensor(sens)) - if hardwaremodel_config := config.get(CONF_HARDWARE_MODEL): - sens = await text_sensor.new_text_sensor(hardwaremodel_config) - cg.add(mr24hpc1_component.set_hardware_model_text_sensor(sens)) - if firwareversion_config := config.get(CONF_HARDWARE_VERSION): - sens = await text_sensor.new_text_sensor(firwareversion_config) - cg.add(mr24hpc1_component.set_firware_version_text_sensor(sens)) - if keepaway_config := config.get(CONF_KEEP_AWAY): - sens = await text_sensor.new_text_sensor(keepaway_config) - cg.add(mr24hpc1_component.set_keep_away_text_sensor(sens)) - if motionstatus_config := config.get(CONF_MOTION_STATUS): - sens = await text_sensor.new_text_sensor(motionstatus_config) - cg.add(mr24hpc1_component.set_motion_status_text_sensor(sens)) - if custommodeend_config := config.get(CONF_CUSTOM_MODE_END): - sens = await text_sensor.new_text_sensor(custommodeend_config) - cg.add(mr24hpc1_component.set_custom_mode_end_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_MR24HPC1_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_HEART_BEAT, hub.set_heartbeat_state_text_sensor) + await text_sensors(CONF_PRODUCT_MODEL, hub.set_product_model_text_sensor) + await text_sensors(CONF_PRODUCT_ID, hub.set_product_id_text_sensor) + await text_sensors(CONF_HARDWARE_MODEL, hub.set_hardware_model_text_sensor) + await text_sensors(CONF_HARDWARE_VERSION, hub.set_firware_version_text_sensor) + await text_sensors(CONF_KEEP_AWAY, hub.set_keep_away_text_sensor) + await text_sensors(CONF_MOTION_STATUS, hub.set_motion_status_text_sensor) + await text_sensors(CONF_CUSTOM_MODE_END, hub.set_custom_mode_end_text_sensor) diff --git a/esphome/components/seeed_mr60fda2/binary_sensor.py b/esphome/components/seeed_mr60fda2/binary_sensor.py index 63bd02acd01..e1dc4289972 100644 --- a/esphome/components/seeed_mr60fda2/binary_sensor.py +++ b/esphome/components/seeed_mr60fda2/binary_sensor.py @@ -23,12 +23,8 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) - if people_exist_config := config.get(CONF_PEOPLE_EXIST): - sens = await binary_sensor.new_binary_sensor(people_exist_config) - cg.add(mr60fda2_component.set_people_exist_binary_sensor(sens)) - - if is_fall_config := config.get(CONF_FALL_DETECTED): - sens = await binary_sensor.new_binary_sensor(is_fall_config) - cg.add(mr60fda2_component.set_fall_detected_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_PEOPLE_EXIST, hub.set_people_exist_binary_sensor) + await binary_sensors(CONF_FALL_DETECTED, hub.set_fall_detected_binary_sensor) diff --git a/esphome/components/seeed_mr60fda2/button/__init__.py b/esphome/components/seeed_mr60fda2/button/__init__.py index 82f0fc9aeaf..582c72bebe9 100644 --- a/esphome/components/seeed_mr60fda2/button/__init__.py +++ b/esphome/components/seeed_mr60fda2/button/__init__.py @@ -35,12 +35,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) - if get_radar_parameters_config := config.get(CONF_GET_RADAR_PARAMETERS): - b = await button.new_button(get_radar_parameters_config) - await cg.register_parented(b, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_get_radar_parameters_button(b)) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_factory_reset_button(b)) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_GET_RADAR_PARAMETERS, hub.set_get_radar_parameters_button) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) diff --git a/esphome/components/seeed_mr60fda2/select/__init__.py b/esphome/components/seeed_mr60fda2/select/__init__.py index 6d8864455f0..0b3359b71cd 100644 --- a/esphome/components/seeed_mr60fda2/select/__init__.py +++ b/esphome/components/seeed_mr60fda2/select/__init__.py @@ -35,25 +35,16 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - mr60fda2_component = await cg.get_variable(config[CONF_MR60FDA2_ID]) - if install_height_config := config.get(CONF_INSTALL_HEIGHT): - s = await select.new_select( - install_height_config, - options=["2.4m", "2.5m", "2.6m", "2.7m", "2.8m", "2.9m", "3.0m"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_install_height_select(s)) - if height_threshold_config := config.get(CONF_HEIGHT_THRESHOLD): - s = await select.new_select( - height_threshold_config, - options=["0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_height_threshold_select(s)) - if sensitivity_config := config.get(CONF_SENSITIVITY): - s = await select.new_select( - sensitivity_config, - options=["1", "2", "3"], - ) - await cg.register_parented(s, config[CONF_MR60FDA2_ID]) - cg.add(mr60fda2_component.set_sensitivity_select(s)) + hub = await cg.get_variable(config[CONF_MR60FDA2_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_INSTALL_HEIGHT, + hub.set_install_height_select, + options=["2.4m", "2.5m", "2.6m", "2.7m", "2.8m", "2.9m", "3.0m"], + ) + await selects( + CONF_HEIGHT_THRESHOLD, + hub.set_height_threshold_select, + options=["0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"], + ) + await selects(CONF_SENSITIVITY, hub.set_sensitivity_select, options=["1", "2", "3"]) diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 6b7461bfa8d..ae78e729e6e 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -20,12 +20,14 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass, TemplateArguments +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArguments from esphome.cpp_types import global_ns +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -123,6 +125,13 @@ async def new_select(config, *args, options: list[str]): return var +def sub_selects( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_select.""" + return SubEntities(new_select, config, parent) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(select_ns.using) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 62d78a32dd7..f3293d4ed56 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -111,9 +111,10 @@ from esphome.const import ( DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, @@ -122,6 +123,7 @@ from esphome.core.entity_helpers import ( ) from esphome.cpp_generator import MockObj, MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -1013,6 +1015,13 @@ async def new_sensor(config, *args): return var +def sub_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_sensor.""" + return SubEntities(new_sensor, config, parent) + + SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All( { cv.Required(CONF_ID): cv.use_id(Sensor), diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index 75244abee80..b56df3fb2e1 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -87,21 +87,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_eco2_sensor(sens)) - - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc_sensor(sens)) - - if eco2_baseline_config := config.get(CONF_ECO2_BASELINE): - sens = await sensor.new_sensor(eco2_baseline_config) - cg.add(var.set_eco2_baseline_sensor(sens)) - - if tvoc_baseline_config := config.get(CONF_TVOC_BASELINE): - sens = await sensor.new_sensor(tvoc_baseline_config) - cg.add(var.set_tvoc_baseline_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_eco2_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_ECO2_BASELINE, var.set_eco2_baseline_sensor) + await sensors(CONF_TVOC_BASELINE, var.set_tvoc_baseline_sensor) if (store_baseline := config.get(CONF_STORE_BASELINE)) is not None: cg.add(var.set_store_baseline(store_baseline)) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 0026e38979b..b8a4e080213 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -121,45 +121,17 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_4_0]) - cg.add(var.set_pm_4_0_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - - if CONF_PMC_0_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_0_5]) - cg.add(var.set_pmc_0_5_sensor(sens)) - - if CONF_PMC_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_1_0]) - cg.add(var.set_pmc_1_0_sensor(sens)) - - if CONF_PMC_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_2_5]) - cg.add(var.set_pmc_2_5_sensor(sens)) - - if CONF_PMC_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_4_0]) - cg.add(var.set_pmc_4_0_sensor(sens)) - - if CONF_PMC_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_10_0]) - cg.add(var.set_pmc_10_0_sensor(sens)) - - if CONF_PM_SIZE in config: - sens = await sensor.new_sensor(config[CONF_PM_SIZE]) - cg.add(var.set_pm_size_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_4_0, var.set_pm_4_0_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PMC_0_5, var.set_pmc_0_5_sensor) + await sensors(CONF_PMC_1_0, var.set_pmc_1_0_sensor) + await sensors(CONF_PMC_2_5, var.set_pmc_2_5_sensor) + await sensors(CONF_PMC_4_0, var.set_pmc_4_0_sensor) + await sensors(CONF_PMC_10_0, var.set_pmc_10_0_sensor) + await sensors(CONF_PM_SIZE, var.set_pm_size_sensor) if CONF_AUTO_CLEANING_INTERVAL in config: cg.add(var.set_auto_cleaning_interval(config[CONF_AUTO_CLEANING_INTERVAL])) diff --git a/esphome/components/sun_gtil2/sensor.py b/esphome/components/sun_gtil2/sensor.py index 26435cfa67d..2959f365187 100644 --- a/esphome/components/sun_gtil2/sensor.py +++ b/esphome/components/sun_gtil2/sensor.py @@ -76,21 +76,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) - if ac_voltage_config := config.get(CONF_AC_VOLTAGE): - sens = await sensor.new_sensor(ac_voltage_config) - cg.add(hub.set_ac_voltage(sens)) - if dc_voltage_config := config.get(CONF_DC_VOLTAGE): - sens = await sensor.new_sensor(dc_voltage_config) - cg.add(hub.set_dc_voltage(sens)) - if ac_power_config := config.get(CONF_AC_POWER): - sens = await sensor.new_sensor(ac_power_config) - cg.add(hub.set_ac_power(sens)) - if dc_power_config := config.get(CONF_DC_POWER): - sens = await sensor.new_sensor(dc_power_config) - cg.add(hub.set_dc_power(sens)) - if limiter_power_config := config.get(CONF_LIMITER_POWER): - sens = await sensor.new_sensor(limiter_power_config) - cg.add(hub.set_limiter_power(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(hub.set_temperature(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_VOLTAGE, hub.set_ac_voltage) + await sensors(CONF_DC_VOLTAGE, hub.set_dc_voltage) + await sensors(CONF_AC_POWER, hub.set_ac_power) + await sensors(CONF_DC_POWER, hub.set_dc_power) + await sensors(CONF_LIMITER_POWER, hub.set_limiter_power) + await sensors(CONF_TEMPERATURE, hub.set_temperature) diff --git a/esphome/components/sun_gtil2/text_sensor.py b/esphome/components/sun_gtil2/text_sensor.py index eae69fb4df2..c0a60cacbf3 100644 --- a/esphome/components/sun_gtil2/text_sensor.py +++ b/esphome/components/sun_gtil2/text_sensor.py @@ -25,9 +25,6 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_SUN_GTIL2_ID]) - if state_config := config.get(CONF_STATE): - sens = await text_sensor.new_text_sensor(state_config) - cg.add(hub.set_state(sens)) - if serial_number_config := config.get(CONF_SERIAL_NUMBER): - sens = await text_sensor.new_text_sensor(serial_number_config) - cg.add(hub.set_serial_number(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_STATE, hub.set_state) + await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index d6cd061e040..c34a73a36d1 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -20,14 +20,16 @@ from esphome.const import ( DEVICE_CLASS_OUTLET, DEVICE_CLASS_SWITCH, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -173,6 +175,13 @@ async def new_switch(config, *args): return var +def sub_switches( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_switch.""" + return SubEntities(new_switch, config, parent) + + SWITCH_ACTION_SCHEMA = maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Switch), diff --git a/esphome/components/sy6970/binary_sensor/__init__.py b/esphome/components/sy6970/binary_sensor/__init__.py index c95850aadc1..a46d7344b68 100644 --- a/esphome/components/sy6970/binary_sensor/__init__.py +++ b/esphome/components/sy6970/binary_sensor/__init__.py @@ -42,16 +42,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if vbus_connected_config := config.get(CONF_VBUS_CONNECTED): - sens = await binary_sensor.new_binary_sensor(vbus_connected_config) - cg.add(parent.add_listener(sens)) - - if charging_config := config.get(CONF_CHARGING): - sens = await binary_sensor.new_binary_sensor(charging_config) - cg.add(parent.add_listener(sens)) - - if charge_done_config := config.get(CONF_CHARGE_DONE): - sens = await binary_sensor.new_binary_sensor(charge_done_config) - cg.add(parent.add_listener(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_VBUS_CONNECTED, hub.add_listener) + await binary_sensors(CONF_CHARGING, hub.add_listener) + await binary_sensors(CONF_CHARGE_DONE, hub.add_listener) diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index 8f8090b6ee8..16db2aa96b3 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -73,24 +73,11 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if vbus_voltage_config := config.get(CONF_VBUS_VOLTAGE): - sens = await sensor.new_sensor(vbus_voltage_config) - cg.add(parent.add_listener(sens)) - - if battery_voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(battery_voltage_config) - cg.add(parent.add_listener(sens)) - - if system_voltage_config := config.get(CONF_SYSTEM_VOLTAGE): - sens = await sensor.new_sensor(system_voltage_config) - cg.add(parent.add_listener(sens)) - - if charge_current_config := config.get(CONF_CHARGE_CURRENT): - sens = await sensor.new_sensor(charge_current_config) - cg.add(parent.add_listener(sens)) - - if precharge_current_config := config.get(CONF_PRECHARGE_CURRENT): - sens = await sensor.new_sensor(precharge_current_config) - cg.add(parent.add_listener(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VBUS_VOLTAGE, hub.add_listener) + await sensors(CONF_BATTERY_VOLTAGE, hub.add_listener) + await sensors(CONF_SYSTEM_VOLTAGE, hub.add_listener) + await sensors(CONF_CHARGE_CURRENT, hub.add_listener) + await sensors(CONF_PRECHARGE_CURRENT, hub.add_listener) diff --git a/esphome/components/sy6970/text_sensor/__init__.py b/esphome/components/sy6970/text_sensor/__init__.py index 03a55393b9f..db756960e1b 100644 --- a/esphome/components/sy6970/text_sensor/__init__.py +++ b/esphome/components/sy6970/text_sensor/__init__.py @@ -38,16 +38,9 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_SY6970_ID]) + hub = await cg.get_variable(config[CONF_SY6970_ID]) - if bus_status_config := config.get(CONF_BUS_STATUS): - sens = await text_sensor.new_text_sensor(bus_status_config) - cg.add(parent.add_listener(sens)) - - if charge_status_config := config.get(CONF_CHARGE_STATUS): - sens = await text_sensor.new_text_sensor(charge_status_config) - cg.add(parent.add_listener(sens)) - - if ntc_status_config := config.get(CONF_NTC_STATUS): - sens = await text_sensor.new_text_sensor(ntc_status_config) - cg.add(parent.add_listener(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_BUS_STATUS, hub.add_listener) + await text_sensors(CONF_CHARGE_STATUS, hub.add_listener) + await text_sensors(CONF_NTC_STATUS, hub.add_listener) diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index da6edfd62ab..898aa945438 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -113,21 +113,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_gain(config[CONF_GAIN])) cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR])) - if CONF_RED_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_RED_CHANNEL]) - cg.add(var.set_red_sensor(sens)) - if CONF_GREEN_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_GREEN_CHANNEL]) - cg.add(var.set_green_sensor(sens)) - if CONF_BLUE_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_BLUE_CHANNEL]) - cg.add(var.set_blue_sensor(sens)) - if CONF_CLEAR_CHANNEL in config: - sens = await sensor.new_sensor(config[CONF_CLEAR_CHANNEL]) - cg.add(var.set_clear_sensor(sens)) - if CONF_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance_sensor(sens)) - if CONF_COLOR_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_COLOR_TEMPERATURE]) - cg.add(var.set_color_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, var.set_red_sensor) + await sensors(CONF_GREEN_CHANNEL, var.set_green_sensor) + await sensors(CONF_BLUE_CHANNEL, var.set_blue_sensor) + await sensors(CONF_CLEAR_CHANNEL, var.set_clear_sensor) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, var.set_color_temperature_sensor) diff --git a/esphome/components/template/time/__init__.py b/esphome/components/template/time/__init__.py new file mode 100644 index 00000000000..0332198e78d --- /dev/null +++ b/esphome/components/template/time/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +from esphome.components import time as time_ +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_LAMBDA +from esphome.types import ConfigType + +from .. import template_ns + +TemplateRealTimeClock = template_ns.class_("TemplateRealTimeClock", time_.RealTimeClock) + + +CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TemplateRealTimeClock), + cv.Required(CONF_LAMBDA): cv.returning_lambda, + cv.Optional(time_.CONF_ON_TIME_SYNC): cv.invalid( + "on_time_sync is not supported for template time, use on the source clock instead." + ), + } +).extend(cv.polling_component_schema("never")) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await time_.register_time(var, config) + + template_ = await cg.process_lambda( + config[CONF_LAMBDA], [], return_type=cg.optional.template(cg.int64) + ) + cg.add(var.set_template(template_)) diff --git a/esphome/components/template/time/template_real_time_clock.cpp b/esphome/components/template/time/template_real_time_clock.cpp new file mode 100644 index 00000000000..4b523def251 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.cpp @@ -0,0 +1,20 @@ +#include "template_real_time_clock.h" + +namespace esphome::template_ { + +static const char *const TAG = "template.time"; + +time_t TemplateRealTimeClock::timestamp_now() { + auto val = this->f_.call(); + if (val.has_value()) { + auto value = *val; + if (value < 0 || value > std::numeric_limits::max()) { + ESP_LOGW(TAG, "timestamp value out of range: %lld", static_cast(value)); + return 0; + } + return static_cast(value); + } + return 0; +} + +} // namespace esphome::template_ diff --git a/esphome/components/template/time/template_real_time_clock.h b/esphome/components/template/time/template_real_time_clock.h new file mode 100644 index 00000000000..ec381115d63 --- /dev/null +++ b/esphome/components/template/time/template_real_time_clock.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esphome/components/time/real_time_clock.h" +#include "esphome/core/template_lambda.h" + +namespace esphome::template_ { + +class TemplateRealTimeClock : public time::RealTimeClock { + public: + template void set_template(F &&f) { this->f_.set(std::forward(f)); } + + /// The time is computed on demand and never written to the system clock, so there is nothing to poll. + void update() override {} + + /// Returns 0 (an invalid time) if the lambda gives no value. + time_t timestamp_now() override; + + protected: + TemplateLambda f_; +}; + +} // namespace esphome::template_ diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 34c46d5451b..08870db64aa 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -20,14 +20,16 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_TIMESTAMP, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType from esphome.util import Registry DEVICE_CLASSES = [ @@ -231,6 +233,13 @@ async def new_text_sensor(config, *args): return var +def sub_text_sensors( + config: ConfigType, *, parent: MockObj | ID | None = None +) -> SubEntities: + """Return a SubEntities bound to new_text_sensor.""" + return SubEntities(new_text_sensor, config, parent) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(text_sensor_ns.using) diff --git a/esphome/components/tfluna/button/__init__.py b/esphome/components/tfluna/button/__init__.py index deb2ba8ffb3..753ac7de333 100644 --- a/esphome/components/tfluna/button/__init__.py +++ b/esphome/components/tfluna/button/__init__.py @@ -61,12 +61,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_TFLUNA_ID]) - cg.add(tfluna_component.set_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_TFLUNA_ID]) - cg.add(tfluna_component.set_restart_button(b)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/tfluna/sensor.py b/esphome/components/tfluna/sensor.py index d101d12ae01..3fb9ed20a66 100644 --- a/esphome/components/tfluna/sensor.py +++ b/esphome/components/tfluna/sensor.py @@ -60,16 +60,9 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if distance_config := config.get(CONF_DISTANCE): - sens = await sensor.new_sensor(distance_config) - cg.add(tfluna_component.set_distance_sensor(sens)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(tfluna_component.set_temperature_sensor(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(tfluna_component.set_signal_strength_sensor(sens)) - if timestamp_config := config.get(CONF_TIMESTAMP): - sens = await sensor.new_sensor(timestamp_config) - cg.add(tfluna_component.set_timestamp_sensor(sens)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_SIGNAL_STRENGTH, hub.set_signal_strength_sensor) + await sensors(CONF_TIMESTAMP, hub.set_timestamp_sensor) diff --git a/esphome/components/tfluna/text_sensor.py b/esphome/components/tfluna/text_sensor.py index 6bce14252b0..6844f3a7267 100644 --- a/esphome/components/tfluna/text_sensor.py +++ b/esphome/components/tfluna/text_sensor.py @@ -17,7 +17,6 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - tfluna_component = await cg.get_variable(config[CONF_TFLUNA_ID]) - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(tfluna_component.set_version_text_sensor(sens)) + hub = await cg.get_variable(config[CONF_TFLUNA_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 90994d90f41..f98adb67b5b 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -29,7 +29,7 @@ class RealTimeClock : public PollingComponent { ESPTime utcnow() { return ESPTime::from_epoch_utc(this->timestamp_now()); } /// Get the current time as the UTC epoch since January 1st 1970. - time_t timestamp_now() { return ::time(nullptr); } + virtual time_t timestamp_now() { return ::time(nullptr); } template void add_on_time_sync_callback(F &&callback) { this->time_sync_callback_.add(std::forward(callback)); diff --git a/esphome/components/tmp1075/sensor.py b/esphome/components/tmp1075/sensor.py index 3cbda4a57a8..ab510db0c53 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -41,7 +42,6 @@ CONF_LIMIT_LOW = "limit_low" CONF_LIMIT_HIGH = "limit_high" CONF_FAULT_COUNT = "fault_count" CONF_POLARITY = "polarity" -CONF_CONVERSION_RATE = "conversion_rate" CONF_FUNCTION = "function" CONFIG_SCHEMA = ( diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 5c09a025f89..d3b26d64d8b 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -260,154 +260,81 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + binary_sensors = binary_sensor.sub_binary_sensors(config) if config[CONF_MODEL] == CONF_DELTASOL_BS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4221)) cg.add(var.set_dest(0x0010)) - if CONF_RELAY1 in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RELAY1]) - cg.add(var.set_relay1_bsensor(sens)) - if CONF_RELAY2 in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RELAY2]) - cg.add(var.set_relay2_bsensor(sens)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) - if CONF_COLLECTOR_MAX in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_MAX]) - cg.add(var.set_collector_max_bsensor(sens)) - if CONF_COLLECTOR_MIN in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_MIN]) - cg.add(var.set_collector_min_bsensor(sens)) - if CONF_COLLECTOR_FROST in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_COLLECTOR_FROST]) - cg.add(var.set_collector_frost_bsensor(sens)) - if CONF_TUBE_COLLECTOR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_TUBE_COLLECTOR]) - cg.add(var.set_tube_collector_bsensor(sens)) - if CONF_RECOOLING in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_RECOOLING]) - cg.add(var.set_recooling_bsensor(sens)) - if CONF_HQM in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_HQM]) - cg.add(var.set_hqm_bsensor(sens)) + await binary_sensors(CONF_RELAY1, var.set_relay1_bsensor) + await binary_sensors(CONF_RELAY2, var.set_relay2_bsensor) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) + await binary_sensors(CONF_COLLECTOR_MAX, var.set_collector_max_bsensor) + await binary_sensors(CONF_COLLECTOR_MIN, var.set_collector_min_bsensor) + await binary_sensors(CONF_COLLECTOR_FROST, var.set_collector_frost_bsensor) + await binary_sensors(CONF_TUBE_COLLECTOR, var.set_tube_collector_bsensor) + await binary_sensors(CONF_RECOOLING, var.set_recooling_bsensor) + await binary_sensors(CONF_HQM, var.set_hqm_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS_2009: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x427B)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) - if CONF_FROST_PROTECTION_ACTIVE in config: - sens = await binary_sensor.new_binary_sensor( - config[CONF_FROST_PROTECTION_ACTIVE] - ) - cg.add(var.set_frost_protection_active_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) + await binary_sensors( + CONF_FROST_PROTECTION_ACTIVE, var.set_frost_protection_active_bsensor + ) elif config[CONF_MODEL] == CONF_DELTASOL_BS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4278)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_C: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4212)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1121)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS4: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1122)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) cg.add(var.set_dest(0x0010)) - if CONF_SENSOR1_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR1_ERROR]) - cg.add(var.set_s1_error_bsensor(sens)) - if CONF_SENSOR2_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR2_ERROR]) - cg.add(var.set_s2_error_bsensor(sens)) - if CONF_SENSOR3_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR3_ERROR]) - cg.add(var.set_s3_error_bsensor(sens)) - if CONF_SENSOR4_ERROR in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) - cg.add(var.set_s4_error_bsensor(sens)) + await binary_sensors(CONF_SENSOR1_ERROR, var.set_s1_error_bsensor) + await binary_sensors(CONF_SENSOR2_ERROR, var.set_s2_error_bsensor) + await binary_sensors(CONF_SENSOR3_ERROR, var.set_s3_error_bsensor) + await binary_sensors(CONF_SENSOR4_ERROR, var.set_s4_error_bsensor) elif config[CONF_MODEL] == CONF_CUSTOM: if CONF_COMMAND in config: diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index e8a6ea7bfaa..84e78900900 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -654,269 +654,118 @@ CONFIG_SCHEMA = cv.typed_schema( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) if config[CONF_MODEL] == CONF_DELTASOL_BS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4221)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS_2009: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x427B)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_BS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4278)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_C: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x4212)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS2: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1121)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_PUMP_SPEED in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED]) - cg.add(var.set_pump_speed_sensor(sens)) - if CONF_OPERATING_HOURS in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS]) - cg.add(var.set_operating_hours_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) + await sensors(CONF_OPERATING_HOURS, var.set_operating_hours_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS4: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x1122)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_TEMPERATURE_5 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_5]) - cg.add(var.set_temperature5_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) - if CONF_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_FLOW_RATE]) - cg.add(var.set_flow_rate_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_TEMPERATURE_5, var.set_temperature5_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) + await sensors(CONF_FLOW_RATE, var.set_flow_rate_sensor) elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) cg.add(var.set_dest(0x0010)) - if CONF_TEMPERATURE_1 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_1]) - cg.add(var.set_temperature1_sensor(sens)) - if CONF_TEMPERATURE_2 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_2]) - cg.add(var.set_temperature2_sensor(sens)) - if CONF_TEMPERATURE_3 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_3]) - cg.add(var.set_temperature3_sensor(sens)) - if CONF_TEMPERATURE_4 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_4]) - cg.add(var.set_temperature4_sensor(sens)) - if CONF_TEMPERATURE_5 in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE_5]) - cg.add(var.set_temperature5_sensor(sens)) - if CONF_PUMP_SPEED_1 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_1]) - cg.add(var.set_pump_speed1_sensor(sens)) - if CONF_PUMP_SPEED_2 in config: - sens = await sensor.new_sensor(config[CONF_PUMP_SPEED_2]) - cg.add(var.set_pump_speed2_sensor(sens)) - if CONF_OPERATING_HOURS_1 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_1]) - cg.add(var.set_operating_hours1_sensor(sens)) - if CONF_OPERATING_HOURS_2 in config: - sens = await sensor.new_sensor(config[CONF_OPERATING_HOURS_2]) - cg.add(var.set_operating_hours2_sensor(sens)) - if CONF_HEAT_QUANTITY in config: - sens = await sensor.new_sensor(config[CONF_HEAT_QUANTITY]) - cg.add(var.set_heat_quantity_sensor(sens)) - if CONF_TIME in config: - sens = await sensor.new_sensor(config[CONF_TIME]) - cg.add(var.set_time_sensor(sens)) - if CONF_VERSION in config: - sens = await sensor.new_sensor(config[CONF_VERSION]) - cg.add(var.set_version_sensor(sens)) - if CONF_FLOW_RATE in config: - sens = await sensor.new_sensor(config[CONF_FLOW_RATE]) - cg.add(var.set_flow_rate_sensor(sens)) + await sensors(CONF_TEMPERATURE_1, var.set_temperature1_sensor) + await sensors(CONF_TEMPERATURE_2, var.set_temperature2_sensor) + await sensors(CONF_TEMPERATURE_3, var.set_temperature3_sensor) + await sensors(CONF_TEMPERATURE_4, var.set_temperature4_sensor) + await sensors(CONF_TEMPERATURE_5, var.set_temperature5_sensor) + await sensors(CONF_PUMP_SPEED_1, var.set_pump_speed1_sensor) + await sensors(CONF_PUMP_SPEED_2, var.set_pump_speed2_sensor) + await sensors(CONF_OPERATING_HOURS_1, var.set_operating_hours1_sensor) + await sensors(CONF_OPERATING_HOURS_2, var.set_operating_hours2_sensor) + await sensors(CONF_HEAT_QUANTITY, var.set_heat_quantity_sensor) + await sensors(CONF_TIME, var.set_time_sensor) + await sensors(CONF_VERSION, var.set_version_sensor) + await sensors(CONF_FLOW_RATE, var.set_flow_rate_sensor) elif config[CONF_MODEL] == CONF_CUSTOM: if CONF_COMMAND in config: @@ -925,7 +774,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_source(config[CONF_SOURCE])) if CONF_DEST in config: cg.add(var.set_dest(config[CONF_DEST])) - sensors = [] + custom_sensors = [] for conf in config[CONF_SENSORS]: sens = await sensor.new_sensor(conf) lambda_ = await cg.process_lambda( @@ -934,8 +783,8 @@ async def to_code(config: ConfigType) -> None: return_type=cg.float_, ) cg.add(sens.set_message_parser(lambda_)) - sensors.append(sens) - cg.add(var.set_sensors(sensors)) + custom_sensors.append(sens) + cg.add(var.set_sensors(custom_sensors)) vbus = await cg.get_variable(config[CONF_VBUS_ID]) cg.add(vbus.register_listener(var)) diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 02820505edb..9ed98fc4dfd 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -160,33 +160,14 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if als_config := config.get(CONF_AMBIENT_LIGHT): - sens = await sensor.new_sensor(als_config) - cg.add(var.set_ambient_light_sensor(sens)) - - if als_cnt_config := config.get(CONF_AMBIENT_LIGHT_COUNTS): - sens = await sensor.new_sensor(als_cnt_config) - cg.add(var.set_ambient_light_counts_sensor(sens)) - - if full_spect_config := config.get(CONF_FULL_SPECTRUM): - sens = await sensor.new_sensor(full_spect_config) - cg.add(var.set_white_sensor(sens)) - - if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS): - sens = await sensor.new_sensor(full_spect_cnt_config) - cg.add(var.set_white_counts_sensor(sens)) - - if infrared_config := config.get(CONF_INFRARED): - sens = await sensor.new_sensor(infrared_config) - cg.add(var.set_infrared_sensor(sens)) - - if act_gain_config := config.get(CONF_ACTUAL_GAIN): - sens = await sensor.new_sensor(act_gain_config) - cg.add(var.set_actual_gain_sensor(sens)) - - if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME): - sens = await sensor.new_sensor(act_itime_config) - cg.add(var.set_actual_integration_time_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor) + await sensors(CONF_AMBIENT_LIGHT_COUNTS, var.set_ambient_light_counts_sensor) + await sensors(CONF_FULL_SPECTRUM, var.set_white_sensor) + await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_white_counts_sensor) + await sensors(CONF_INFRARED, var.set_infrared_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) + await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor) cg.add(var.set_enable_automatic_mode(config[CONF_AUTO_MODE])) cg.add(var.set_enable_lux_compensation(config[CONF_LUX_COMPENSATION])) diff --git a/esphome/components/xiaomi_body_scale/sensor.py b/esphome/components/xiaomi_body_scale/sensor.py index c8cbc0e820a..cb0802d270a 100644 --- a/esphome/components/xiaomi_body_scale/sensor.py +++ b/esphome/components/xiaomi_body_scale/sensor.py @@ -78,16 +78,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await ble_device_base.register_ble_device(var, config) - for key, setter in ( - (CONF_WEIGHT, var.set_weight), - (CONF_IMPEDANCE_LOW, var.set_impedance_low), - (CONF_IMPEDANCE_HIGH, var.set_impedance_high), - (CONF_HEART_RATE, var.set_heart_rate), - (CONF_PROFILE_ID, var.set_profile_id), - ): - if (conf := config.get(key)) is not None: - sens = await sensor.new_sensor(conf) - cg.add(setter(sens)) - if (conf := config.get(CONF_STABILIZED)) is not None: - sens = await binary_sensor.new_binary_sensor(conf) - cg.add(var.set_stabilized(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_WEIGHT, var.set_weight) + await sensors(CONF_IMPEDANCE_LOW, var.set_impedance_low) + await sensors(CONF_IMPEDANCE_HIGH, var.set_impedance_high) + await sensors(CONF_HEART_RATE, var.set_heart_rate) + await sensors(CONF_PROFILE_ID, var.set_profile_id) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_STABILIZED, var.set_stabilized) diff --git a/esphome/components/xiaomi_hhccjcy01/sensor.py b/esphome/components/xiaomi_hhccjcy01/sensor.py index 13e8e509c33..11bbceef5e3 100644 --- a/esphome/components/xiaomi_hhccjcy01/sensor.py +++ b/esphome/components/xiaomi_hhccjcy01/sensor.py @@ -81,18 +81,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) - if CONF_MOISTURE in config: - sens = await sensor.new_sensor(config[CONF_MOISTURE]) - cg.add(var.set_moisture(sens)) - if CONF_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance(sens)) - if CONF_CONDUCTIVITY in config: - sens = await sensor.new_sensor(config[CONF_CONDUCTIVITY]) - cg.add(var.set_conductivity(sens)) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_MOISTURE, var.set_moisture) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) + await sensors(CONF_CONDUCTIVITY, var.set_conductivity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py index 57420125cb6..c122b1ce983 100644 --- a/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/binary_sensor.py @@ -47,18 +47,11 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_MOTION in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_MOTION]) - cg.add(parent.set_motion(sens)) - cg.add(parent.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) - - if CONF_LIGHT in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_LIGHT]) - cg.add(parent.set_light(sens)) - - if CONF_BUTTON in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_BUTTON]) - cg.add(parent.set_button(sens)) - cg.add(parent.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) + binary_sensors = binary_sensor.sub_binary_sensors(config) + if await binary_sensors(CONF_MOTION, hub.set_motion): + cg.add(hub.set_motion_timeout(config[CONF_MOTION][CONF_TIMEOUT])) + await binary_sensors(CONF_LIGHT, hub.set_light) + if await binary_sensors(CONF_BUTTON, hub.set_button): + cg.add(hub.set_button_timeout(config[CONF_BUTTON][CONF_TIMEOUT])) diff --git a/esphome/components/xiaomi_rtcgq02lm/sensor.py b/esphome/components/xiaomi_rtcgq02lm/sensor.py index e0e4b4640bd..5e849b26bc5 100644 --- a/esphome/components/xiaomi_rtcgq02lm/sensor.py +++ b/esphome/components/xiaomi_rtcgq02lm/sensor.py @@ -31,8 +31,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ID]) + hub = await cg.get_variable(config[CONF_ID]) - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(parent.set_battery_level(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_LEVEL, hub.set_battery_level) diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 59e5bc556bf..2493bd9f959 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -1,11 +1,56 @@ +from dataclasses import dataclass + from esphome import automation import esphome.codegen as cg +from esphome.components.nrf52.framework import ( + bluetooth_west_projects, + include_west_project, +) from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import CONF_ID, Framework -from esphome.core import CORE +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType +# BLE LE Data Length Extension maximum LL PDU payload (BLE spec) +_DLE_MAX_PDU = 251 + +DOMAIN = "zephyr_ble_server" + + +@dataclass +class _BLEServerData: + requested_l2cap_mtu: int = 0 + mtu_emitted: bool = False + + +def _get_data() -> _BLEServerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _BLEServerData() + return CORE.data[DOMAIN] + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _emit_ble_mtu() -> None: + data = _get_data() + data.mtu_emitted = True + mtu = data.requested_l2cap_mtu + if mtu > 0: + zephyr_add_prj_conf("BT_L2CAP_TX_MTU", mtu) + zephyr_add_prj_conf("BT_BUF_ACL_TX_SIZE", min(mtu + 4, _DLE_MAX_PDU)) + zephyr_add_prj_conf("BT_BUF_ACL_RX_SIZE", mtu + 4) + + +def request_ble_l2cap_mtu(l2cap_mtu: int) -> None: + """Request a minimum BLE L2CAP MTU. The maximum of all callers wins.""" + data = _get_data() + if data.mtu_emitted: + raise RuntimeError("BLE L2CAP MTU requested after it was written to prj.conf") + if data.requested_l2cap_mtu == 0: + CORE.add_job(_emit_ble_mtu) + data.requested_l2cap_mtu = max(data.requested_l2cap_mtu, l2cap_mtu) + + zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server") BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component) @@ -36,6 +81,8 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) zephyr_add_prj_conf("BT", True) + for project in bluetooth_west_projects(): + include_west_project(project) zephyr_add_prj_conf("BT_PERIPHERAL", True) zephyr_add_prj_conf("BT_RX_STACK_SIZE", 1536) zephyr_add_prj_conf("BT_DEVICE_NAME", CORE.name) @@ -45,6 +92,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("BT_SETTINGS", True) zephyr_add_prj_conf("BT_SMP_SC_ONLY", True) zephyr_add_prj_conf("BT_KEYS_OVERWRITE_OLDEST", True) + request_ble_l2cap_mtu(65) # BT_SMP Kconfig default and range minimum await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index ad89c1ac794..9fc02d87080 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.nrf52.boards import BOOTLOADER_CONFIG +from esphome.components.nrf52.framework import include_west_project from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.components.zephyr import ( HexValue, @@ -13,6 +14,7 @@ from esphome.components.zephyr.const import ( KEY_BOOTLOADER, KEY_SYSBUILD, ) +from esphome.components.zephyr_ble_server import request_ble_l2cap_mtu import esphome.config_validation as cv from esphome.const import ( CONF_HARDWARE_UART, @@ -115,6 +117,9 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_BUF", True) zephyr_add_prj_conf("ZCBOR", True) + include_west_project("zcbor") + # The image manager includes MCUboot headers with any bootloader + include_west_project("mcuboot") zephyr_add_prj_conf("MCUMGR", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG", True) @@ -140,6 +145,7 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True) + request_ble_l2cap_mtu(498) # matches NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP if CONF_HARDWARE_UART in transport: uart = UARTS[transport[CONF_HARDWARE_UART]] uart_name = uart[0] diff --git a/esphome/const.py b/esphome/const.py index 5ffbf8c49ac..fb02d702810 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -23,12 +23,14 @@ class Toolchain(StrEnum): SDK_NRF = "sdk-nrf" # ESP8266: the Arduino core built directly (no PlatformIO) ARDUINO = "arduino" + # host: the system C/C++ compiler driven by ninja (no PlatformIO) + HOST = "host" # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. -NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO}) +NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST}) class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index fb752850817..fa3d6bb64ed 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -996,6 +996,12 @@ class EsphomeCore: ``using_arduino``, which is the target framework).""" return self.toolchain == Toolchain.ARDUINO + @property + def using_toolchain_host(self): + """The native host build toolchain: the system compiler driven by + ninja (the only toolchain the host platform serves).""" + return self.toolchain == Toolchain.HOST + @property def using_native_toolchain(self): """Whether the selected toolchain builds natively, without reading diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 703c7b40e33..8762cbe7110 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -253,30 +253,19 @@ template class StatelessLambdaAction : public Action { void (*f_)(Ts...); }; -/// Runs one codegen-generated function that has the parent and every field baked in, so the -/// action holds one pointer. Args pass by const reference so a std::string arg is never copied; +/// Runs one codegen-generated function that has the parent and every field baked in. The +/// function is a template argument, so play() calls it directly and the object is just the +/// Action base. Args pass by const reference so a std::string arg is never copied; /// StatelessLambdaAction keeps by-value parameters because user `lambda:` code owns them. -template class ApplyAction final : public Action { +template class ApplyAction final : public Action { public: - using ApplyFn = void (*)(const std::remove_cvref_t &...); - explicit ApplyAction(ApplyFn apply) : apply_(apply) {} - - void play(const Ts &...x) override { this->apply_(x...); } - - protected: - ApplyFn apply_; + void play(const Ts &...x) override { Fn(x...); } }; /// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. -template class ApplyCondition final : public Condition { +template class ApplyCondition final : public Condition { public: - using CheckFn = bool (*)(const std::remove_cvref_t &...); - explicit ApplyCondition(CheckFn check) : check_(check) {} - - bool check(const Ts &...x) override { return this->check_(x...); } - - protected: - CheckFn check_; + bool check(const Ts &...x) override { return Fn(x...); } }; /// Simple continuation action that calls play_next_ on a parent action. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 75c7a76a408..da03f0e3d16 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -77,6 +77,7 @@ #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU +#define USE_HOERMANN_HCP_IDENTITY #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT @@ -88,7 +89,9 @@ #define USE_JSON #define USE_RADIO_FREQUENCY #define USE_LIGHT +#define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK #define USE_LOGGER @@ -138,6 +141,11 @@ #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 +#define PN532_BINARY_SENSOR_COUNT 1 +#define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN532_ON_TAG_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_TRIGGER_COUNT 1 #define REMOTE_BASE_DUMPER_COUNT 1 #define REMOTE_BASE_LISTENER_COUNT 1 #define USE_REMOTE_PROTOCOL_ABBWELCOME diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 54e2551cb4e..4f446050253 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -1,7 +1,8 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import functools import logging +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -32,7 +33,7 @@ from esphome.helpers import ( sanitize, snake_case, ) -from esphome.types import ConfigType, EntityMetadata +from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType _LOGGER = logging.getLogger(__name__) @@ -467,6 +468,34 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) -> config[_KEY_ICON_IDX] = icon_idx +@dataclass(frozen=True, slots=True) +class SubEntities: + """Create the child entity configured under key, set parent (if bound) and pass it to setter. + + Extra arguments go to new_entity. Returns None only when key is absent, so the result can be + used directly as a condition. + """ + + new_entity: Callable[..., Awaitable[MockObj]] + config: ConfigType + parent: MockObj | ID | None = None + + async def __call__( + self, + key: str, + setter: Callable[[MockObj], Expression], + *args: SafeExpType, + **kwargs: Any, + ) -> MockObj | None: + if (conf := self.config.get(key)) is None: + return None + var = await self.new_entity(conf, *args, **kwargs) + if self.parent is not None: + await cg.register_parented(var, self.parent) + add(setter(var)) + return var + + def inherit_property_from(property_to_inherit, parent_id_property, transform=None): """Validator that inherits a configuration property from another entity, for use with FINAL_VALIDATE_SCHEMA. If a property is already set, it will not be inherited. diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 13ba0a9791c..6d00e187999 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -276,6 +276,9 @@ template class StaticVector { // Clear all elements void clear() { count_ = 0; } + // Set the element count, capped at N. Elements are neither initialized when growing nor destroyed when + // shrinking; release owning elements before shrinking past them. + void resize(size_t n) { count_ = n < N ? n : N; } // Assign from iterator range template void assign(InputIt first, InputIt last) { diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a681583053e..b0c3533e040 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,7 +19,7 @@ from esphome.core import ( TimePeriodNanoseconds, TimePeriodSeconds, ) -from esphome.helpers import cpp_string_escape, indent_all_but_first_and_last +from esphome.helpers import cpp_string_escape, indent, indent_all_but_first_and_last from esphome.types import Expression, SafeExpType, TemplateArgsType from esphome.util import OrderedDict from esphome.yaml_util import ESPHomeDataBase @@ -670,6 +670,28 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj": return Pvariable(id_, rhs) +def static_function( + name: str, + return_type: SafeExpType, + parameters: TemplateArgsType, + body: list[str], +) -> RawExpression: + """Emit ``static (parameters) { body }`` at global scope and return an + expression naming it, for use as a template argument or a function pointer. + + Every id the body names must already be declared, which holds when the statements were + rendered through ``get_variable`` or ``process_lambda``. + """ + params = ParameterListExpression(*parameters) + add_global( + RawStatement( + f"static {safe_exp(return_type)} {name}({params}) {{\n" + f"{indent(chr(10).join(body))}\n}}" + ) + ) + return RawExpression(name) + + def add(expression: Expression | Statement, prepend: bool = False): """Add an expression to the codegen section. diff --git a/esphome/host/__init__.py b/esphome/host/__init__.py new file mode 100644 index 00000000000..003b47eb99a --- /dev/null +++ b/esphome/host/__init__.py @@ -0,0 +1,10 @@ +"""Native (PlatformIO-free) build support for the host platform. + +Builds the generated sources with the machine's own C/C++ compiler through a +ninja file and drives the build directly -- the host equivalent of +``esphome.espidf``. Nothing is downloaded: the compiler comes from PATH and +registry libraries go through the shared library converter. + +Deliberately importable without the host component to avoid circular +imports; the component wires these modules in via lazy imports. +""" diff --git a/esphome/host/toolchain.py b/esphome/host/toolchain.py new file mode 100644 index 00000000000..47b5bda48c7 --- /dev/null +++ b/esphome/host/toolchain.py @@ -0,0 +1,196 @@ +"""Native host build driver (the PlatformIO ``run`` equivalent). + +The compiler and binutils come from PATH (``CC``/``CXX``/``AR``/``OBJDUMP``/ +``READELF`` override the lookup, like make and CMake), ninja from PATH or +the ninja PyPI wheel, and ccache is used when found. The build lives under +``.pioenvs//`` so ``CORE.firmware_bin`` and the clean paths stay the +ones the PlatformIO build used. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +from typing import NamedTuple + +from esphome.build_helpers.ccache import ccache_env, resolve_absolute_ccache_path +from esphome.build_helpers.native import warn_ignored_platformio_options +from esphome.build_helpers.ninja import ( + absolute_tool, + find_ninja, + refresh_compile_commands, +) +from esphome.build_helpers.tools_cache import HOST_TOOLS_CACHE +from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME +from esphome.core import CORE, EsphomeError +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# The output name PlatformIO's native platform produced; CORE.firmware_bin +# and the integration-test harness resolve it by this name +PROGRAM_NAME = "program" + +# platformio_options keys the host build reads (lib_ignore feeds the library +# converter); anything else has no native equivalent and is warned about +CONSUMED_PIO_OPTIONS = frozenset({"lib_ignore"}) + + +class HostCompilers(NamedTuple): + """The resolved C and C++ compiler commands: the program, then any + arguments its override carried (``CC="gcc -m32"``).""" + + cc: tuple[str, ...] + cxx: tuple[str, ...] + + +def find_command(env_var: str, candidates: tuple[str, ...]) -> tuple[str, ...]: + """Resolve a build tool: ``env_var`` when set, else the first candidate + found on PATH. Returns the program and the override's arguments. + + An override that does not resolve fails by name rather than falling back + silently to a different compiler than the user asked for. + """ + if override := os.environ.get(env_var, "").strip(): + program, *args = shlex.split(override) + if Path(program).stem == "ccache" and args: + # The build adds ccache itself; twice would hide the compiler + program, *args = args + # which() accepts an absolute path as well as a bare program name + if (resolved := shutil.which(program)) is None: + raise EsphomeError( + f"{env_var}={override!r} does not name a runnable program" + ) + return (absolute_tool(resolved), *args) + for name in candidates: + if (found := shutil.which(name)) is not None: + return (absolute_tool(found),) + raise EsphomeError( + f"{candidates[0]} not found on PATH (tried {', '.join(candidates)}); " + f"install it or set {env_var}" + ) + + +def find_tool(env_var: str, candidates: tuple[str, ...]) -> str: + """Resolve a build tool that is called as a bare program.""" + program, *args = find_command(env_var, candidates) + if args: + raise EsphomeError(f"{env_var} must name a program without arguments") + return program + + +def find_compilers() -> HostCompilers: + """The C and C++ compilers the build uses (gcc first, as PlatformIO did).""" + return HostCompilers( + cc=find_command("CC", ("gcc", "clang", "cc")), + cxx=find_command("CXX", ("g++", "clang++", "c++")), + ) + + +def get_build_dir() -> Path: + return CORE.relative_pioenvs_path(CORE.name) + + +def get_elf_path() -> Path: + return get_build_dir() / PROGRAM_NAME + + +def get_objdump_path() -> Path: + return Path(find_tool("OBJDUMP", ("objdump",))) + + +def get_readelf_path() -> Path: + return Path(find_tool("READELF", ("readelf",))) + + +def check_analysis_supported() -> None: + """Refuse analyze-memory where the program is not an ELF file. + + Called before the compile, so an unsupported machine fails at once. + """ + if sys.platform != "linux": + raise EsphomeError( + "analyze-memory reads ELF files; the host build on " + f"{sys.platform} produces a different format" + ) + + +def get_build_env(ccache: str | None) -> dict[str, str]: + return {**os.environ, **ccache_env(ccache, HOST_TOOLS_CACHE)} + + +def run_compile(config: ConfigType, verbose: bool) -> int: + from esphome.build_gen import host as build_gen + + warn_ignored_platformio_options(CONSUMED_PIO_OPTIONS) + # Probe the cheap local dependencies before resolving libraries + ninja_path = find_ninja() + compilers = find_compilers() + # Resolved once per build: the resolution probes PATH and spawns the + # runnability check, and three consumers need the same answer + ccache = resolve_absolute_ccache_path() + ninja_changed = build_gen.write_project(compilers, ccache) + + build_dir = get_build_dir() + env = get_build_env(ccache) + refresh_compile_commands(ninja_path, build_dir, env, ninja_changed) + + cmd = [str(ninja_path)] + if verbose: + cmd.append("-v") + if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT): + cmd += ["-j", str(jobs)] + # The explicit target, not the default statement: a generator defect + # that drops it fails loudly with "unknown target" instead of a green + # no-op run that leaves a stale program in place + cmd.append(PROGRAM_NAME) + + _LOGGER.debug("Running: %s", " ".join(cmd)) + # cwd instead of -C also drops the "Entering directory" banner + rc = subprocess.run( + cmd, cwd=build_dir, env=env, check=False, close_fds=False + ).returncode + if rc != 0: + return rc + + elf = get_elf_path() + if not elf.is_file(): + # ninja refused a manifest missing the target above; this covers a + # rule that ran but wrote elsewhere + _LOGGER.error("Build produced no %s", elf) + return 1 + + from esphome.build_helpers.idedata import warn_if_idedata_missing + + warn_if_idedata_missing(lambda: _load_idedata(ccache)) + return 0 + + +def get_idedata() -> dict | None: + """Derive idedata from the build's compile_commands.json. + + Same contract as ``espidf.toolchain.get_idedata``: the fields IDE + integrations, clang-tidy, and the memory analyzer expect. Returns None + when nothing has been built yet. + """ + # Deliberately uncached: env/PATH can change between builds in a + # long-lived host process + return _load_idedata(resolve_absolute_ccache_path()) + + +def _load_idedata(ccache: str | None) -> dict | None: + from esphome.build_helpers.idedata import load_or_build_idedata + + return load_or_build_idedata( + get_build_dir() / "compile_commands.json", + get_elf_path(), + CORE.relative_internal_path("idedata", f"{CORE.name}.json"), + # The compile DB's commands carry the same ccache prefix the ninja + # rules were generated with + launcher=ccache, + ) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index fb6779b8078..af2010711f6 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -360,7 +360,7 @@ class LibraryBackend: """ platform: str | None - framework: str + framework: str | None emit: Callable[["ConvertedLibrary"], None] cache_key: str # Owner-less names this returns True for are skipped by the walk; @@ -368,6 +368,10 @@ class LibraryBackend: # reconciles provided_requests after resolving provides: Callable[[str], bool] | None = None provided_requests: set[str] = field(default_factory=set) + # Accept a library without library.json/library.properties, as + # PlatformIO does (its defaults: src/ or the root, plus include/). Off + # for backends whose emitted build files need the manifest. + manifest_optional: bool = False def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -859,6 +863,11 @@ def _url_or_none(value: Any) -> str | None: return value if parsed.scheme and parsed.netloc else None +# URL schemes that name a local library folder; symlink:// is PlatformIO's +# spelling for one it links instead of copying, which is the same to us +_LOCAL_SCHEMES = ("file", "symlink") + + def _node_key( name: str | None, version: str | None, repository: str | None ) -> tuple[str, str, tuple[str | None, str | None]]: @@ -896,7 +905,7 @@ def _node_key( scheme = urlsplit(candidate).scheme except ValueError: scheme = "" - if scheme == "file" or _url_or_none(candidate): + if scheme in _LOCAL_SCHEMES or _url_or_none(candidate): name, repository = custom_name, candidate else: # Anything with ``://`` was meant to be a URL; failing it fast @@ -905,7 +914,7 @@ def _node_key( if repository: is_git_prefixed = repository.startswith("git+") split_result = urlsplit(repository.removeprefix("git+")) - if split_result.scheme == "file" and not is_git_prefixed: + if (scheme := split_result.scheme) in _LOCAL_SCHEMES and not is_git_prefixed: # A plain file:// URL points at a local library directory. A local # file URL is written file:///absolute/path (empty host) or, less # commonly, file://localhost/path. Anything else -- a real host, or @@ -913,8 +922,8 @@ def _node_key( # rejected rather than silently resolved to the wrong directory. if split_result.netloc not in ("", "localhost"): raise RuntimeError( - f"Unsupported host in file:// library URL '{repository}'; " - "use an absolute path, e.g. file:///path/to/lib" + f"Unsupported host in {scheme}:// library URL '{repository}'; " + f"use an absolute path, e.g. {scheme}:///path/to/lib" ) # Validate the URL path itself (always POSIX-style, leading slash), # not the OS path: on Windows a "/foo" path is not is_absolute() @@ -924,8 +933,8 @@ def _node_key( url_path = split_result.path if not url_path.startswith("/") or not PurePosixPath(url_path).name: raise RuntimeError( - f"file:// library URL '{repository}' must be an absolute " - "directory path, e.g. file:///path/to/lib" + f"{scheme}:// library URL '{repository}' must be an absolute " + f"directory path, e.g. {scheme}:///path/to/lib" ) path = url2pathname(url_path) return (name or PurePosixPath(url_path).name), "local", (path, None) @@ -1244,9 +1253,16 @@ def convert_libraries( library_properties_path = source_dir / "library.properties" has_json = library_json_path.is_file() has_properties = library_properties_path.is_file() - if not has_json and not has_properties and not node.is_local: + if ( + not has_json + and not has_properties + and not node.is_local + and not backend.manifest_optional + ): # An interrupted clone/extraction self-heals with one forced - # re-download; a local source has nothing to re-download + # re-download; a local source has nothing to re-download. + # A backend accepting manifest-less libraries cannot tell + # one from a torn download and would re-fetch every build. _LOGGER.warning( "Library %s at %s is missing library.json and library.properties; " "re-downloading", @@ -1260,6 +1276,12 @@ def convert_libraries( component.data = parse_library_json(library_json_path) elif has_properties: component.data = parse_library_properties(library_properties_path) + elif backend.manifest_optional: + _LOGGER.debug( + "Library %s has no manifest; using PlatformIO's default layout", + key, + ) + component.data = {"name": component.name} else: # Local sources are user input (EsphomeError); a registry/git # miss means a corrupt cache (RuntimeError) @@ -1286,7 +1308,7 @@ def convert_libraries( # cross-platform skip stays at debug, other causes warn if key in top_level_keys: reason = ( - f"is not compatible with {backend.framework}" + f"is not compatible with {backend.framework or backend.platform}" if isinstance(e, IncompatiblePlatform) else "has a malformed manifest" ) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 9219914529b..adaa809d6e3 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -155,7 +155,7 @@ class StorageJSON: self.framework = framework # The core platform of this firmware. Like "esp32", "rp2", "host" etc. self.core_platform = core_platform - # The toolchain used for the build ("platformio" / "esp-idf") + # The toolchain used for the build ("platformio" / "esp-idf" / "host") self.toolchain = toolchain # The area of the node self.area = area diff --git a/requirements.txt b/requirements.txt index ff0ad9c944a..81f3cbcdd2d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.12 # native esp-idf toolchain global cache dir -ninja==1.13.2 # native esp8266 arduino toolchain build driver +ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this diff --git a/script/build_helpers.py b/script/build_helpers.py index b4b25924c3b..f0b354a8a7f 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -22,8 +22,8 @@ from esphome.__main__ import command_compile, parse_args from esphome.config import validate_config from esphome.const import CONF_PLATFORM from esphome.core import CORE +from esphome.host.toolchain import get_elf_path from esphome.loader import get_component, get_platform -from esphome.platformio.toolchain import get_idedata from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest # This must coincide with the version in /platformio.ini @@ -220,7 +220,7 @@ def create_host_config( friendly_name: str, libraries: str | list[str], includes: list[str], - platformio_options: dict, + build_flags: list[str], ) -> dict: """Create an ESPHome host configuration for C++ builds. @@ -229,7 +229,7 @@ def create_host_config( friendly_name: Human-readable name libraries: PlatformIO library specification(s) includes: List of include folders for the build - platformio_options: Dict of platformio_options to set + build_flags: Compiler/linker flags for the build Returns: Configuration dict for ESPHome @@ -239,7 +239,7 @@ def create_host_config( "name": config_name, "friendly_name": friendly_name, "libraries": libraries, - "platformio_options": platformio_options, + "build_flags": build_flags, "includes": includes, }, HOST_KEY: {}, @@ -403,13 +403,12 @@ def compile_and_get_binary( return EXIT_COMPILE_ERROR, None # After a successful compilation, locate the executable: - idedata = get_idedata(config) - if idedata is None: + program_path = get_elf_path() + if not program_path.is_file(): print("Cannot find executable") return EXIT_NO_EXECUTABLE, None - program_path: str = idedata.raw["prog_path"] - return EXIT_OK, program_path + return EXIT_OK, str(program_path) def build_and_run( @@ -419,7 +418,7 @@ def build_and_run( config_prefix: str, friendly_name: str, libraries: str | list[str], - platformio_options: dict, + build_flags: list[str], main_entry: str, label: str = "build", build_only: bool = False, @@ -438,7 +437,7 @@ def build_and_run( config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench") friendly_name: Human-readable name for the config libraries: PlatformIO library specification(s) - platformio_options: PlatformIO options dict + build_flags: Compiler/linker flags for the build main_entry: Name of the main entry file (e.g. "main.cpp") label: Label for log messages build_only: If True, print binary path and return without running @@ -488,7 +487,7 @@ def build_and_run( config_name: str = f"{config_prefix}-" + hash_components(components) config = create_host_config( - config_name, friendly_name, libraries, includes, platformio_options + config_name, friendly_name, libraries, includes, build_flags ) exit_code, program_path = compile_and_get_binary( diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 5080a9fec74..678356496e4 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -25,19 +25,14 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" # allow benchmarks to compile on the host platform. STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" -PLATFORMIO_OPTIONS = { - "build_flags": [ - "-Os", # match firmware optimization level (detects inlining regressions) - "-g", # debug symbols for profiling - "-ffunction-sections", # required for dead-code stripping with -Os - "-fdata-sections", # required for dead-code stripping with -Os - "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() - f"-I{STUBS_DIR}", # stub headers for ESP32-only components - ], - # Use deep+ LDF mode to ensure PlatformIO detects the benchmark - # library dependency from nested includes. - "lib_ldf_mode": "deep+", -} +BUILD_FLAGS = [ + "-Os", # match firmware optimization level (detects inlining regressions) + "-g", # debug symbols for profiling + "-ffunction-sections", # required for dead-code stripping with -Os + "-fdata-sections", # required for dead-code stripping with -Os + "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() + f"-I{STUBS_DIR}", # stub headers for ESP32-only components +] def run_benchmarks(selected_components: list[str], build_only: bool = False) -> int: @@ -46,7 +41,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> # containing {"lib_path": "/path/to/google_benchmark"}. lib_config_json = os.environ.get("BENCHMARK_LIB_CONFIG") - pio_options = PLATFORMIO_OPTIONS + build_flags = BUILD_FLAGS if lib_config_json: lib_config = json.loads(lib_config_json) benchmark_lib = f"benchmark=symlink://{lib_config['lib_path']}" @@ -61,10 +56,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> "-DCODSPEED_ANALYSIS", f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"', ] - pio_options = { - **PLATFORMIO_OPTIONS, - "build_flags": PLATFORMIO_OPTIONS["build_flags"] + codspeed_flags, - } + build_flags = BUILD_FLAGS + codspeed_flags else: benchmark_lib = PLATFORMIO_GOOGLE_BENCHMARK_LIB @@ -77,7 +69,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> config_prefix="cppbench", friendly_name="CPP Benchmarks", libraries=benchmark_lib, - platformio_options=pio_options, + build_flags=build_flags, main_entry="main.cpp", label="benchmarks", build_only=build_only, diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index 8cb18d08757..c5179a1b1e9 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -15,24 +15,16 @@ from helpers import get_all_components, root_path # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" -PLATFORMIO_OPTIONS = { - "build_type": "debug", - "build_unflags": [ - "-Os", # remove size-opt flag - ], - "build_flags": [ - "-Og", # optimize for debug - "-DESPHOME_DEBUG", # enable debug assertions - # Enable the address and undefined behavior sanitizers - "-fsanitize=address", - "-fsanitize=undefined", - "-fno-omit-frame-pointer", - ], - "debug_build_flags": [ # only for debug builds - "-g3", # max debug info - "-ggdb3", - ], -} +BUILD_FLAGS = [ + "-Og", # optimize for debug + "-g3", # max debug info + "-ggdb3", + "-DESPHOME_DEBUG", # enable debug assertions + # Enable the address and undefined behavior sanitizers + "-fsanitize=address", + "-fsanitize=undefined", + "-fno-omit-frame-pointer", +] def run_tests(selected_components: list[str]) -> int: @@ -47,7 +39,7 @@ def run_tests(selected_components: list[str]) -> int: config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, - platformio_options=PLATFORMIO_OPTIONS, + build_flags=BUILD_FLAGS, main_entry="main.cpp", label="unit tests", ) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index aa930745814..3f0143076b2 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -111,14 +111,25 @@ INTEGRATION_TESTS_SPLIT_THRESHOLD = 10 INTEGRATION_TESTS_SPLIT_BUCKETS = 5 INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT = 360.0 -# platformio and aioesphomeapi (requirements.txt), the pytest stack -# (requirements_test.txt) and the fixture every session compiles; a change -# to any runs the full matrix +# aioesphomeapi (requirements.txt), the pytest stack (requirements_test.txt) +# and the native host build backend every test compiles with; a change to +# any runs the full matrix INTEGRATION_TESTS_TRIGGER_FILES = frozenset( { "requirements.txt", "requirements_test.txt", - "tests/integration/fixtures/cache_init.yaml", + "esphome/arduino/library.py", + "esphome/build_gen/build_tool.py", + "esphome/build_gen/host.py", + "esphome/build_helpers/ccache.py", + "esphome/build_helpers/idedata.py", + "esphome/build_helpers/native.py", + "esphome/build_helpers/ninja.py", + "esphome/build_helpers/ninja_gen.py", + "esphome/build_helpers/tools_cache.py", + "esphome/framework_helpers.py", + "esphome/host/toolchain.py", + "esphome/platformio/library.py", } ) @@ -240,7 +251,7 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s - conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc. 4. A file in INTEGRATION_TESTS_TRIGGER_FILES changed - - The dependency pins and the session init fixture affect every test + - The dependency pins and the host build backend affect every test Returns (run_all=False, [test_files...]) when: @@ -724,11 +735,17 @@ def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool: def esp8266_native_components_to_test(branch: str | None = None) -> list[str]: - """Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to - compile (same narrowing as ``esp32_platformio_components_to_test``).""" - return _toolchain_components_to_test( - branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger - ) + """The smoke set on a native-build change, nothing otherwise. + + Unlike the esp32 PlatformIO job, this one does not narrow to the changed + components: the component matrix already compiles every esp8266 fixture + with this toolchain, so the only gap left is a change to the native build + itself that brings no component along. + """ + files = changed_files(branch) + if core_changed(files) or _esp8266_native_path_or_file_trigger(files): + return sorted(ESP8266_NATIVE_TEST_COMPONENTS) + return [] def determine_cpp_unit_tests( @@ -1286,6 +1303,7 @@ def detect_memory_impact_config( "components": compatible_components, "platform": platform, "use_merged_config": "true", + "needs_arduino8266": platform.startswith("esp8266"), } @@ -1516,12 +1534,20 @@ def main() -> None: for batch in batches: platforms: set[str] = set() for component in batch: - platforms.update(get_component_test_platforms(component)) + # Variants included: the compile stage builds them, so a + # component tested only by test-..yaml + # still needs that platform's toolchain + platforms.update( + get_component_test_platforms(component, base_only=False) + ) component_test_batches.append( { "components": " ".join(batch), "needs_idf": any(p.startswith("esp32") for p in platforms), "needs_nrf": any(p.startswith("nrf52") for p in platforms), + "needs_arduino8266": any( + p.startswith("esp8266") for p in platforms + ), } ) diff --git a/tests/benchmarks/components/light/__init__.py b/tests/benchmarks/components/light/__init__.py index 233a3c246e5..bffaf0febf1 100644 --- a/tests/benchmarks/components/light/__init__.py +++ b/tests/benchmarks/components/light/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components.light import generate_gamma_table +from esphome.components.light import gamma_table_initializer from tests.testing_helpers import ComponentManifestOverride @@ -12,15 +12,13 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code(config): await original_to_code(config) cg.add_define("USE_LIGHT_GAMMA_LUT") - # Use the light component's own generate_gamma_table() so the + # Use the light component's own gamma_table_initializer() so the # benchmark stays in sync with any formula changes. - forward = generate_gamma_table(2.8) - values = ", ".join(f"0x{int(v):04X}" for v in forward) - # Use extern-visible (non-static) array so the benchmark .cpp - # can reference it via extern declaration. + # Extern-visible (non-static) so the benchmark .cpp can reference it. cg.add_global( cg.RawStatement( - f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};" + "extern const esphome::light::GammaTable bench_gamma_2_8 PROGMEM = " + f"{gamma_table_initializer(2.8)};" ) ) diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp index cc6871654de..e102d323714 100644 --- a/tests/benchmarks/components/light/bench_light_call.cpp +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -4,8 +4,8 @@ #include "esphome/components/light/light_state.h" // Gamma 2.8 forward LUT generated by the light component's Python codegen -// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table()) -extern const uint16_t bench_gamma_2_8_fwd[256]; +// (see tests/benchmarks/components/light/__init__.py which calls gamma_table_initializer()) +extern const esphome::light::GammaTable bench_gamma_2_8; namespace esphome::benchmarks { @@ -37,8 +37,7 @@ static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { output.traits_.set_max_mireds(500.0f); light.configure("test_light"); light.set_default_transition_length(0); - light.set_gamma_correct(2.8f); - light.set_gamma_table(bench_gamma_2_8_fwd); + light.set_gamma_table(&bench_gamma_2_8); } // --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- diff --git a/tests/component_tests/counter/__init__.py b/tests/component_tests/counter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/counter/config/counter_single.yaml b/tests/component_tests/counter/config/counter_single.yaml new file mode 100644 index 00000000000..41b7f55233d --- /dev/null +++ b/tests/component_tests/counter/config/counter_single.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + on_boot: + then: + - counter.set_value: 5 + - counter.set_value: + value: 7 + - counter.increment: + value: -3 + +esp32: + board: esp32dev + +sensor: + - platform: counter + id: only_counter + name: Only Counter diff --git a/tests/component_tests/counter/config/counter_test.yaml b/tests/component_tests/counter/config/counter_test.yaml new file mode 100644 index 00000000000..b149df42b72 --- /dev/null +++ b/tests/component_tests/counter/config/counter_test.yaml @@ -0,0 +1,42 @@ +esphome: + name: test + on_boot: + then: + - counter.set_value: + id: counter_a + value: 100 + - counter.set_value: + id: counter_b + value: -5000000000 + - counter.increment: counter_a + - counter.increment: + id: counter_a + value: -7 + - counter.increment: + id: counter_b + value: !lambda "return 3;" + +esp32: + board: esp32dev + +sensor: + - platform: template + id: source_sensor + lambda: return 1.0; + - platform: counter + id: counter_a + name: Counter A + sensor: source_sensor + - platform: counter + id: counter_b + name: Counter B + restore: false + initial_value: -5000000000 + - platform: counter + id: counter_c + name: Counter C + binary_sensor: source_binary_sensor + +binary_sensor: + - platform: template + id: source_binary_sensor diff --git a/tests/component_tests/counter/test_counter.py b/tests/component_tests/counter/test_counter.py new file mode 100644 index 00000000000..454f023af1d --- /dev/null +++ b/tests/component_tests/counter/test_counter.py @@ -0,0 +1,99 @@ +"""Tests for the counter sensor.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.counter.sensor import CONFIG_SCHEMA, COUNTER_VALUE + +INT64_MAX = 2**63 - 1 + + +def test_counter_constructor_arguments( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Restore (on by default) and initial value (zero by default) are constructor arguments.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "new(counter_a) counter::CounterSensor(true, 0);" in main_cpp + assert "new(counter_b) counter::CounterSensor(false, -5000000000LL);" in main_cpp + + +def test_counter_sensor_option_registers_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only the counter with a sensor option counts that sensor's updates.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "counter_a->count_updates_from(source_sensor);" in main_cpp + assert main_cpp.count("count_updates_from") == 1 + + +def test_counter_binary_sensor_option_registers_source( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only the counter with a binary_sensor option counts that sensor's changes to true.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "counter_c->count_true_from(source_binary_sensor);" in main_cpp + assert main_cpp.count("count_true_from") == 1 + + +def test_counter_actions( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Values reach the counter as 64-bit integers; increment defaults to one.""" + main_cpp = generate_main(component_config_path("counter_test.yaml")) + + assert "::counter_a->set_value(100);" in main_cpp + assert "::counter_b->set_value(-5000000000LL);" in main_cpp + assert "::counter_a->increment(1);" in main_cpp + assert "::counter_a->increment(-7);" in main_cpp + assert "::counter_b->increment(static_cast(3));" in main_cpp + + +def test_counter_actions_without_id_use_only_counter( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """With one counter configured, the id may be left out of an action.""" + main_cpp = generate_main(component_config_path("counter_single.yaml")) + + assert "::only_counter->set_value(5);" in main_cpp + assert "::only_counter->set_value(7);" in main_cpp + assert "::only_counter->increment(-3);" in main_cpp + + +@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5]) +def test_counter_initial_value_must_be_int64(value: float) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"id": "c1", "name": "C1", "initial_value": value}) + + +def test_counter_cannot_count_itself() -> None: + """A counter watching its own updates would recurse forever.""" + with pytest.raises(cv.Invalid, match="cannot count its own updates"): + CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "c1"}) + + +def test_counter_accepts_other_source() -> None: + config = CONFIG_SCHEMA({"id": "c1", "name": "C1", "sensor": "other"}) + + assert config["sensor"].id == "other" + + +@pytest.mark.parametrize("value", [0, 1, -1, INT64_MAX, -INT64_MAX]) +def test_counter_value_accepts_int64_range(value: int) -> None: + assert COUNTER_VALUE(value) == value + + +@pytest.mark.parametrize("value", [INT64_MAX + 1, -INT64_MAX - 1, 1.5]) +def test_counter_value_rejects_out_of_range(value: float) -> None: + with pytest.raises(cv.Invalid): + COUNTER_VALUE(value) diff --git a/tests/component_tests/light/test_light_state_layout.py b/tests/component_tests/light/test_light_state_layout.py new file mode 100644 index 00000000000..2d6c6e1523e --- /dev/null +++ b/tests/component_tests/light/test_light_state_layout.py @@ -0,0 +1,37 @@ +"""Limits and flash layout that let LightState stay small.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.light.effects import ( + MAX_EFFECTS, + MONOCHROMATIC_EFFECTS, + validate_effects, +) + + +def _effects(count: int) -> list[dict[str, dict[str, str]]]: + return [{"pulse": {"name": f"Pulse {i}"}} for i in range(count)] + + +def test_rejects_more_effects_than_the_index_holds() -> None: + with pytest.raises(cv.Invalid, match=f"at most {MAX_EFFECTS} effects"): + validate_effects(MONOCHROMATIC_EFFECTS)(_effects(MAX_EFFECTS + 1)) + + +def test_accepts_a_normal_effect_list() -> None: + assert len(validate_effects(MONOCHROMATIC_EFFECTS)(_effects(3))) == 3 + + +def test_gamma_table_initializer_holds_the_lut_then_gamma_times_100() -> None: + from esphome.components.light import gamma_table_initializer, generate_gamma_table + + init = gamma_table_initializer(2.8) + lut = ", ".join(f"0x{int(v):04X}" for v in generate_gamma_table(2.8)) + assert init == f"{{{{{lut}}}, 280}}" + + +def test_gamma_table_initializer_saturates_gamma_that_overflows_uint16() -> None: + from esphome.components.light import gamma_table_initializer + + assert gamma_table_initializer(1000.0).endswith(", 65535}") diff --git a/tests/component_tests/lvgl/test_lv_conf_build_flags.py b/tests/component_tests/lvgl/test_lv_conf_build_flags.py new file mode 100644 index 00000000000..eb576fa8fac --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_build_flags.py @@ -0,0 +1,59 @@ +"""``generate_lv_conf_h()`` writes ``#define LV_... 0`` for every LVGL option +the configuration does not use, so ``lv_conf.h`` must leave alone the options +the user defines in build flags. Otherwise the header, which is included after +the compiler ``-D`` flags, turns the option off again. + +Build flags can come from ``esphome: build_flags:`` or from the deprecated +``esphome: platformio_options: build_flags:``; both must be honoured. +""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.components.lvgl import defines as df, generate_lv_conf_h +from esphome.const import CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_PLATFORMIO_OPTIONS +from esphome.core import CORE + + +def _set_esphome_config( + build_flags: list[str] | None = None, + pio_build_flags: list[str] | str | None = None, +) -> None: + pio_options = {} if pio_build_flags is None else {"build_flags": pio_build_flags} + CORE.config = { + CONF_ESPHOME: { + CONF_PLATFORMIO_OPTIONS: pio_options, + CONF_BUILD_FLAGS: build_flags or [], + } + } + + +def test_unused_define_is_disabled_without_build_flag() -> None: + _set_esphome_config() + assert "#define LV_USE_OBSERVER 0" in generate_lv_conf_h().splitlines() + + +@pytest.mark.parametrize( + "flags", + [ + {"build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"build_flags": ["-D LV_USE_OBSERVER"]}, + {"pio_build_flags": ["-DLV_USE_OBSERVER=1"]}, + {"pio_build_flags": "-DLV_USE_OBSERVER=1"}, + ], + ids=["esphome", "esphome-spaced", "platformio_options", "platformio_options-str"], +) +def test_build_flag_define_is_not_disabled(flags: dict) -> None: + _set_esphome_config(**flags) + assert "#define LV_USE_OBSERVER 0" not in generate_lv_conf_h().splitlines() + + +def test_esphome_build_flag_clash_warns(caplog: pytest.LogCaptureFixture) -> None: + _set_esphome_config(build_flags=["-DLV_USE_ARC=1"]) + df.add_define("LV_USE_ARC") + with caplog.at_level(logging.WARNING): + generate_lv_conf_h() + assert "LV_USE_ARC" in caplog.text diff --git a/tests/component_tests/zephyr_ble_server/__init__.py b/tests/component_tests/zephyr_ble_server/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/zephyr_ble_server/config/no_request.yaml b/tests/component_tests/zephyr_ble_server/config/no_request.yaml new file mode 100644 index 00000000000..dcf79033eca --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/no_request.yaml @@ -0,0 +1,8 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +zephyr_ble_server: diff --git a/tests/component_tests/zephyr_ble_server/config/nus.yaml b/tests/component_tests/zephyr_ble_server/config/nus.yaml new file mode 100644 index 00000000000..63129069d26 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus.yaml @@ -0,0 +1,11 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +ble_nus: + type: logs diff --git a/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml new file mode 100644 index 00000000000..d62247dc546 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/config/nus_mcumgr.yaml @@ -0,0 +1,18 @@ +esphome: + name: mtutest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +logger: + +zephyr_ble_server: + +ble_nus: + type: logs + +ota: + - platform: zephyr_mcumgr + transport: + ble: true diff --git a/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py new file mode 100644 index 00000000000..af0c4112b03 --- /dev/null +++ b/tests/component_tests/zephyr_ble_server/test_l2cap_mtu.py @@ -0,0 +1,52 @@ +"""Tests for the BLE L2CAP MTU that zephyr_ble_server writes to prj.conf.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_PRJ_CONF +from esphome.components.zephyr_ble_server import _emit_ble_mtu, request_ble_l2cap_mtu + +MTU_KEYS = ( + "CONFIG_BT_L2CAP_TX_MTU", + "CONFIG_BT_BUF_ACL_TX_SIZE", + "CONFIG_BT_BUF_ACL_RX_SIZE", +) + + +def _mtu_values() -> tuple[int | None, ...]: + prj_conf = zephyr_data()[KEY_PRJ_CONF][""] + return tuple(prj_conf.get(key, (None,))[0] for key in MTU_KEYS) + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + ("no_request.yaml", (None, None, None)), + ("nus.yaml", (247, 251, 251)), + # The largest request wins; only the TX buffer is capped at 251 + ("nus_mcumgr.yaml", (498, 251, 502)), + ], +) +def test_l2cap_mtu_prj_conf( + fixture: str, + expected: tuple[int | None, ...], + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path(fixture)) + assert _mtu_values() == expected + + +@pytest.mark.asyncio +async def test_request_after_emit_raises( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("no_request.yaml")) + request_ble_l2cap_mtu(247) + await _emit_ble_mtu() + with pytest.raises(RuntimeError, match="after it was written"): + request_ble_l2cap_mtu(498) diff --git a/tests/components/counter/common.h b/tests/components/counter/common.h new file mode 100644 index 00000000000..ac9cf7f5a98 --- /dev/null +++ b/tests/components/counter/common.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/components/counter/counter_sensor.h" + +namespace esphome::counter::testing { + +constexpr int64_t INT64_MAX_VALUE = std::numeric_limits::max(); +constexpr int64_t INT64_MIN_VALUE = std::numeric_limits::min(); + +/// Stands in for a binary sensor: reports each state to its callbacks. +struct FakeBinarySource { + template void add_on_state_callback(F &&callback) { this->callbacks_.add(std::forward(callback)); } + void publish(bool state) { this->callbacks_.call(state); } + CallbackManager callbacks_; +}; + +// Restore is off so no preference storage is needed. +class CounterTest : public ::testing::Test { + protected: + CounterSensor counter_{false, 0}; +}; + +} // namespace esphome::counter::testing diff --git a/tests/components/counter/common.yaml b/tests/components/counter/common.yaml new file mode 100644 index 00000000000..6a0d8aecc8b --- /dev/null +++ b/tests/components/counter/common.yaml @@ -0,0 +1,37 @@ +esphome: + on_boot: + then: + - counter.set_value: + id: counter_sensor + value: 100 + - counter.increment: counter_sensor + - counter.increment: + id: counter_sensor + value: -5 + - counter.increment: + id: counter_sensor + value: !lambda "return 5000000000LL;" + +sensor: + - platform: counter + id: counter_sensor + name: Counter + - platform: counter + id: counter_no_restore + name: Counter No Restore + restore: false + initial_value: 10 + - platform: template + id: source_sensor + lambda: return 1.0; + update_interval: 1s + - platform: counter + name: Counter Of Sensor + sensor: source_sensor + - platform: counter + name: Counter Of Binary Sensor + binary_sensor: source_binary_sensor + +binary_sensor: + - platform: template + id: source_binary_sensor diff --git a/tests/components/counter/sensor/test_counter.cpp b/tests/components/counter/sensor/test_counter.cpp new file mode 100644 index 00000000000..a0431062e8d --- /dev/null +++ b/tests/components/counter/sensor/test_counter.cpp @@ -0,0 +1,84 @@ +#include "../common.h" + +namespace esphome::counter::testing { + +TEST_F(CounterTest, StartsAtZero) { + this->counter_.setup(); + EXPECT_EQ(this->counter_.state, 0.0f); +} + +TEST(CounterInitialValue, PublishedAtSetupWhenNotRestoring) { + CounterSensor counter(false, -12); + counter.setup(); + EXPECT_EQ(counter.state, -12.0f); + counter.increment(); + EXPECT_EQ(counter.state, -11.0f); +} + +TEST_F(CounterTest, SetValuePublishesState) { + this->counter_.set_value(42); + EXPECT_EQ(this->counter_.state, 42.0f); +} + +TEST_F(CounterTest, IncrementDefaultsToOne) { + this->counter_.increment(); + this->counter_.increment(); + EXPECT_EQ(this->counter_.state, 2.0f); +} + +TEST_F(CounterTest, IncrementAcceptsNegativeAmounts) { + this->counter_.set_value(10); + this->counter_.increment(-25); + EXPECT_EQ(this->counter_.state, -15.0f); +} + +TEST_F(CounterTest, ValueBeyondInt32) { + this->counter_.set_value(5000000000LL); + this->counter_.increment(5000000000LL); + EXPECT_EQ(this->counter_.state, 1.0e10f); +} + +TEST_F(CounterTest, IncrementWrapsAtInt64Limits) { + this->counter_.set_value(INT64_MAX_VALUE); + this->counter_.increment(1); + EXPECT_EQ(this->counter_.state, static_cast(INT64_MIN_VALUE)); + + this->counter_.set_value(INT64_MIN_VALUE); + this->counter_.increment(-1); + EXPECT_EQ(this->counter_.state, static_cast(INT64_MAX_VALUE)); +} + +TEST_F(CounterTest, CountsEachPublishFromSource) { + sensor::Sensor source; + this->counter_.count_updates_from(&source); + + // The counted value is unrelated to what the source publishes. + source.publish_state(10.0f); + source.publish_state(10.0f); + source.publish_state(-3.5f); + EXPECT_EQ(this->counter_.state, 3.0f); +} + +TEST_F(CounterTest, SourceUpdatesAddToCurrentValue) { + sensor::Sensor source; + this->counter_.count_updates_from(&source); + + this->counter_.set_value(100); + source.publish_state(1.0f); + EXPECT_EQ(this->counter_.state, 101.0f); +} + +TEST_F(CounterTest, CountsOnlyTrueFromBinarySource) { + FakeBinarySource source; + this->counter_.count_true_from(&source); + + source.publish(true); + source.publish(false); + EXPECT_EQ(this->counter_.state, 1.0f); + source.publish(true); + source.publish(false); + source.publish(false); + EXPECT_EQ(this->counter_.state, 2.0f); +} + +} // namespace esphome::counter::testing diff --git a/tests/components/counter/test.esp32-idf.yaml b/tests/components/counter/test.esp32-idf.yaml new file mode 100644 index 00000000000..cc0d0d4e7f1 --- /dev/null +++ b/tests/components/counter/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + counter: !include common.yaml diff --git a/tests/components/counter/validate.esp32-idf.yaml b/tests/components/counter/validate.esp32-idf.yaml new file mode 100644 index 00000000000..22751677af6 --- /dev/null +++ b/tests/components/counter/validate.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + counter: !include common.yaml + +esphome: + on_shutdown: + then: + - counter.set_value: + id: counter_no_restore + value: -9223372036854775807 + - counter.increment: + id: counter_no_restore + value: 9223372036854775807 diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h index a6151697f0f..bc9975f2e8b 100644 --- a/tests/components/hoermann_hcp/common.h +++ b/tests/components/hoermann_hcp/common.h @@ -35,11 +35,18 @@ inline void connect_controller(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); } -// Runs one command poll (write 2 / read 8) and returns both key-press registers. -inline std::pair poll_command(HoermannHcp &door) { - door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +// Runs one status poll (write 2 / read 8) and returns the whole answer. The bus controller writes its counter +// with command 0x03 here; most tests do not care and pass zero. +inline RegisterValues status_answer(HoermannHcp &door, uint16_t command_reg = 0x0000) { + door.on_write_registers(COMMAND_REG, make_registers({command_reg, 0x0000})); RegisterValues response; door.on_read_holding_registers(STATE_REG, 8, response); + return response; +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + const RegisterValues response = status_answer(door); EXPECT_EQ(response.size(), 8u); if (response.size() != 8u) return {0xFFFF, 0xFFFF}; @@ -59,7 +66,14 @@ class TestableHoermannHcp : public HoermannHcp { TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } using HoermannHcp::connection_timeout_ms_; +#ifdef USE_HOERMANN_HCP_IDENTITY + using HoermannHcp::identity_asked_at_; + using HoermannHcp::identity_request_; + using HoermannHcp::firmware_unreadable_; + using HoermannHcp::serial_unreadable_; +#endif using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::key_press_delay_ms_; using HoermannHcp::light_toggle_released_at_; using HoermannHcp::light_toggles_in_flight_; using HoermannHcp::set_valid_; diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 618a8181bfb..21d3d2b8485 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -22,3 +22,12 @@ button: light: - platform: hoermann_hcp name: Garage Light + +text_sensor: + - platform: hoermann_hcp + door_state: + name: Garage Door State + serial_number: + name: Garage Motor Serial Number + version: + name: Garage Motor Firmware Version diff --git a/tests/components/hoermann_hcp/text_sensor/__init__.py b/tests/components/hoermann_hcp/text_sensor/__init__.py new file mode 100644 index 00000000000..20e71245071 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_HOERMANN_HCP_IDENTITY") + + manifest.to_code = to_code_testing diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp new file mode 100644 index 00000000000..dfb3ba0e92d --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_door_state_test.cpp @@ -0,0 +1,125 @@ +#include + +#include "esphome/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// A status broadcast with the door state in the high byte of its third register. +void broadcast_state(HoermannHcp &door, uint16_t state_reg) { + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, state_reg})); + door.update(); +} + +struct DoorStateFixture { + DoorStateFixture() { + this->sensor.setup(); + this->sensor.add_on_state_callback([this](const std::string & /*state*/) { this->publishes++; }); + } + + TestableHoermannHcp door; + HoermannHcpDoorStateTextSensor sensor{&door}; + int publishes{0}; +}; + +} // namespace + +// Polls and bus scans make the connection valid before any broadcast has said where the door is, and an +// undecodable state says nothing either. None of these may show the default state. +TEST(HoermannHcpDoorStateTest, NothingBeforeTheDoorReportsAState) { + DoorStateFixture fixture; + auto &door = fixture.door; + door.update(); + EXPECT_FALSE(fixture.sensor.has_state()); + + status_answer(door, 0x0003); + door.update(); + ASSERT_TRUE(door.is_valid()); + EXPECT_FALSE(fixture.sensor.has_state()); + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + door.update(); + EXPECT_FALSE(fixture.sensor.has_state()); + + broadcast_state(door, 0x1000); + EXPECT_FALSE(fixture.sensor.has_state()); + + // The first real state is shown even when it equals the default. + broadcast_state(door, 0x4000); + EXPECT_EQ(fixture.sensor.get_state(), "Closed"); +} + +// Every state the door reports is shown, including the vent and half-open positions and the moves to them. +TEST(HoermannHcpDoorStateTest, FollowsTheDoorState) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + + const std::pair states[] = { + {0x2000, "Open"}, {0x0200, "Closing"}, {0x4000, "Closed"}, {0x0900, "Moving to vent"}, + {0x0A00, "Vent position"}, {0x0500, "Moving to half"}, {0x8000, "Half open"}, {0x0100, "Opening"}, + {0x0000, "Stopped"}, {0x0061, "Vent position"}, + }; + for (const auto &[reg, text] : states) { + broadcast_state(door, reg); + EXPECT_EQ(fixture.sensor.get_state(), text) << "state register 0x" << std::hex << reg; + } +} + +// Any hub change runs the publish path, so an unchanged door state is not published again, and a state the +// door is not known to report keeps the last one. +TEST(HoermannHcpDoorStateTest, EachStateIsPublishedOnce) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + broadcast_state(door, 0x2000); + ASSERT_EQ(fixture.publishes, 1); + + // The lamp changes, the door state does not. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x2000, 0x0000, 0x0000, 0x0000, 0x0010})); + door.update(); + EXPECT_EQ(fixture.publishes, 1); + + broadcast_state(door, 0x1000); + EXPECT_EQ(fixture.publishes, 1); + EXPECT_EQ(fixture.sensor.get_state(), "Open"); +} + +// While the bus controller is gone the last state stays. Once it is back, a poll alone shows nothing new; the +// next broadcast is published again even if it repeats the old state. +TEST(HoermannHcpDoorStateTest, LastStateStaysUntilTheNextBroadcast) { + DoorStateFixture fixture; + auto &door = fixture.door; + connect_controller(door); + broadcast_state(door, 0x8000); + ASSERT_EQ(fixture.publishes, 1); + + door.set_valid_(false); + door.update(); + EXPECT_EQ(fixture.sensor.get_state(), "Half open"); + + connect_controller(door); + door.update(); + EXPECT_EQ(fixture.publishes, 1); + + broadcast_state(door, 0x8000); + EXPECT_EQ(fixture.publishes, 2); + EXPECT_EQ(fixture.sensor.get_state(), "Half open"); +} + +// A sensor set up after the hub already decoded a state shows it right away. +TEST(HoermannHcpDoorStateTest, LateSetupShowsTheCurrentState) { + TestableHoermannHcp door; + connect_controller(door); + broadcast_state(door, 0x0A00); + + HoermannHcpDoorStateTextSensor sensor(&door); + sensor.setup(); + EXPECT_EQ(sensor.get_state(), "Vent position"); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp new file mode 100644 index 00000000000..8dff56e2a36 --- /dev/null +++ b/tests/components/hoermann_hcp/text_sensor/hoermann_hcp_text_sensor_test.cpp @@ -0,0 +1,510 @@ +#include +#include + +#include +#include + +#include "esphome/components/text_sensor/text_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Made up. 26 bytes on the wire: the first 14 arrive in one transfer, the other 12 in the next. +constexpr const char *SERIAL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +// Made up as well, padded with a space to the 12 bytes the motor sends. +constexpr const char *FIRMWARE = "FW-TEST 1.0 "; + +constexpr uint8_t SUB_SERIAL = 0x0C; +constexpr uint8_t SUB_FIRMWARE = 0x0D; +constexpr uint8_t FIRST_HALF = 0x80; + +// A status poll as the bus controller writes it: its counter in the high byte, command 0x03 in the low. +RegisterValues status_poll(HoermannHcp &door, uint8_t counter = 0x05) { + return status_answer(door, static_cast((counter << 8) | 0x03)); +} + +// The write half of a payload transfer: the motor writes the bytes into the command block. +void write_transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len) { + RegisterValues written; + written.push_back(static_cast((counter << 8) | 0x04)); + written.push_back(static_cast(sub_code << 8)); + for (size_t i = 0; i < len; i += 2) { + written.push_back( + static_cast((static_cast(bytes[i]) << 8) | static_cast(bytes[i + 1]))); + } + door.on_write_registers(COMMAND_REG, written); +} + +// A whole payload transfer, returning the answer the motor reads back. +RegisterValues transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len, + uint16_t read_registers = 8) { + write_transfer(door, counter, sub_code, bytes, len); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, read_registers, response); + return response; +} + +// The first status poll gets an ordinary answer, the next one carries the serial number request. +void request_serial(HoermannHcp &door) { + status_poll(door, 0x03); + status_poll(door, 0x04); +} + +void send_serial(HoermannHcp &door) { + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); +} + +// A hub with both sensors configured, which is what makes it ask. +struct IdentityFixture { + IdentityFixture() { + this->door.set_serial_number_text_sensor(&this->serial); + this->door.set_version_text_sensor(&this->version); + } + // What the sensors show once the loop has turned; empty while they have no state. + std::string serial_shown() { + this->door.update(); + return this->serial.has_state() ? this->serial.get_state() : ""; + } + std::string version_shown() { + this->door.update(); + return this->version.has_state() ? this->version.get_state() : ""; + } + + TestableHoermannHcp door; + text_sensor::TextSensor serial; + text_sensor::TextSensor version; +}; + +// The whole exchange as the motor runs it, with the loop turning in between as it would. +void run_identity_exchange(HoermannHcp &door) { + request_serial(door); + send_serial(door); + door.update(); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + door.update(); +} + +} // namespace + +// With no sensor configured, polls and transfers are answered exactly as before. +TEST(HoermannHcpTextSensorTest, NothingChangesWithoutASensor) { + HoermannHcp door; + for (int poll = 0; poll < 2; poll++) { + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0301); + EXPECT_EQ(response[2], 0x0000); + } + const RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + EXPECT_EQ(answer[1] & 0x00FF, 0x0001); +} + +// Like Hoermann's own bus accessory, the first status poll gets an ordinary answer and the next one carries the +// request, echoing the status command like any status answer. It is asked again only once 30 s have passed. +TEST(HoermannHcpTextSensorTest, SerialNumberIsAskedForAfterOneOrdinaryAnswer) { + IdentityFixture fixture; + auto &door = fixture.door; + EXPECT_EQ(status_poll(door)[1], 0x0301); + + const RegisterValues response = status_poll(door); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[0], 0x0500); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + + door.identity_asked_at_ -= 29000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + door.identity_asked_at_ -= 2000; + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Three attempts at the serial number, then the firmware version is asked for anyway, three times as well. +TEST(HoermannHcpTextSensorTest, GivesUpAfterThreeAttemptsEach) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0500); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + for (int attempt = 0; attempt < 3; attempt++) { + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + door.identity_asked_at_ -= 31000; + } + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A first half left behind by a serial number that never completed is not shown. +TEST(HoermannHcpTextSensorTest, HalfASerialNumberIsNeverShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + for (int attempt = 0; attempt < 4; attempt++) { + door.identity_asked_at_ -= 31000; + status_poll(door); + } + EXPECT_EQ(fixture.serial_shown(), ""); +} + +// Each half is acknowledged with the counter it came with, minus the half marker. The serial number is shown as +// soon as it is whole, and the firmware version is asked for right after. +TEST(HoermannHcpTextSensorTest, SerialNumberInTwoHalvesThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + + RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + ASSERT_EQ(answer.size(), 8u); + EXPECT_EQ(answer[0], 0x0500); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), ""); + + answer = transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(answer[0], 0x0600); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + EXPECT_EQ(fixture.version_shown(), ""); + + const RegisterValues response = status_poll(door); + EXPECT_EQ(response[1], 0x0322); + EXPECT_EQ(response[2], 0x0600); + + answer = transfer(door, 0x07, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// Once both values are in, nothing more is asked, however long the device keeps running. +TEST(HoermannHcpTextSensorTest, FinishedExchangeStaysFinished) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + + door.identity_asked_at_ -= 31000; + EXPECT_EQ(status_poll(door)[1], 0x0301); + EXPECT_EQ(door.identity_request_(), 0); +} + +// The text ends at the first byte that is not printable. 0xFF is below the printable range where char is signed, +// as on the host, and above it where char is unsigned, as on most targets. DEL is above it either way. +TEST(HoermannHcpTextSensorTest, PaddingEndsTheText) { + for (const char pad : {'\xFF', '\x7F'}) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char first[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', pad}; + const char second[] = {pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad, pad}; + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, first, sizeof(first)); + transfer(door, 0x06, SUB_SERIAL, second, sizeof(second)); + EXPECT_EQ(fixture.serial_shown(), "1234567890123"); + } +} + +// A half that cannot be used is still acknowledged but not kept, so the request stays open for the retry: a +// first half too short, a second half too short, a second half without a first. +TEST(HoermannHcpTextSensorTest, UnusableSerialHalvesAreNotKept) { + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12)[1], 0x04FD); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 10); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); + } +} + +// Older motors (index B1 seen) send the whole serial number in one frame, without the half marker. The bytes are +// the ones a B1 sent, with the serial number made up. +TEST(HoermannHcpTextSensorTest, SerialNumberInOneFrameThenTheFirmwareVersion) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x05, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + auto answer = status_poll(door, 0x06); + EXPECT_EQ(answer[1], 0x0322); + EXPECT_EQ(answer[2], 0x0600); +} + +// The frames as a B1 motor sends them on the bus, which reads a transfer answer back as 2 registers. The serial +// number is made up. +TEST(HoermannHcpTextSensorTest, ExchangeWithTheFrameSizesOfAB1) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_THAT(transfer(door, 0x05, SUB_SERIAL, one_frame, 12, 2), ::testing::ElementsAre(0x0500, 0x04FD)); + + EXPECT_THAT(status_poll(door, 0x06), ::testing::ElementsAre(0x0600, 0x0322, 0x0600, 0, 0, 0, 0, 0)); + const char zeros[12] = {}; + EXPECT_THAT(transfer(door, 0x07, SUB_FIRMWARE, zeros, 12, 2), ::testing::ElementsAre(0x0700, 0x04FD)); + + EXPECT_EQ(door.identity_request_(), 0); + EXPECT_EQ(fixture.serial_shown(), "123456789B1"); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_EQ(status_poll(door, 0x08)[1], 0x0301); +} + +// A transfer of the value not asked for is acknowledged but not kept, and the request stays open. +TEST(HoermannHcpTextSensorTest, TheValueNotAskedForIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + EXPECT_EQ(transfer(door, 0x05, SUB_FIRMWARE, FIRMWARE, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.version_shown(), ""); + + send_serial(door); + EXPECT_EQ(fixture.serial_shown(), SERIAL); + status_poll(door, 0x07); + const char other[14] = {'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z', 'Z'}; + EXPECT_EQ(transfer(door, FIRST_HALF | 0x08, SUB_SERIAL, other, 14)[1], 0x04FD); + EXPECT_EQ(transfer(door, 0x09, SUB_SERIAL, other, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), SERIAL); +} + +// A transfer before the request has gone out, as from a motor still finishing an exchange from before a restart, +// is acknowledged but not kept. +TEST(HoermannHcpTextSensorTest, ATransferBeforeTheRequestIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const char one_frame[12] = {'1', '2', '3', '4', '5', '6', '7', '8', '9', 'B', '1', 0}; + EXPECT_EQ(transfer(door, 0x04, SUB_SERIAL, one_frame, 12)[1], 0x04FD); + EXPECT_EQ(door.identity_request_(), 0x05); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(status_poll(door, 0x05)[1], 0x0322); +} + +// After a frame with the half marker, one without it can only be the second half, even if the first was unusable. +TEST(HoermannHcpTextSensorTest, ASecondHalfIsNeverTakenForTheWholeNumber) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 12); + transfer(door, 0x06, SUB_SERIAL, SERIAL + 14, 12); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_EQ(door.identity_request_(), 0x05); +} + +// A serial number without any text at its start is not shown but logged, in one frame or in two halves. The +// firmware version is still asked for. +TEST(HoermannHcpTextSensorTest, SerialNumberThatIsNotTextIsLoggedNotShown) { + const char zeros[14] = {}; + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, 0x05, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); // kept for the log + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); // logged once + } + { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, zeros, 14); + transfer(door, 0x06, SUB_SERIAL, zeros, 12); + EXPECT_TRUE(door.serial_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); + EXPECT_EQ(fixture.serial_shown(), ""); + EXPECT_FALSE(door.serial_unreadable_); + } +} + +// A firmware version too short is not kept, and is asked for again. +TEST(HoermannHcpTextSensorTest, ShortFirmwareVersionIsNotKept) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + EXPECT_TRUE(door.firmware_unreadable_); // kept for the log + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); // logged once + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A firmware version without any payload is logged, not shown. +TEST(HoermannHcpTextSensorTest, EmptyFirmwareVersionIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 0); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0x06); +} + +// A readable firmware version right after a short one, before the loop has turned, is still shown. +TEST(HoermannHcpTextSensorTest, ReadableFirmwareVersionAfterAShortOneIsShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 10); + transfer(door, 0x09, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), "FW-TEST 1.0"); +} + +// All zeros is how a motor that does not report its version says so: nothing shown, not asked for again. +TEST(HoermannHcpTextSensorTest, AllZeroFirmwareVersionMeansNoneIsReported) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char zeros[12] = {}; + transfer(door, 0x08, SUB_FIRMWARE, zeros, 12); + EXPECT_EQ(fixture.version_shown(), ""); + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A firmware version that is not text is not shown, is logged, and is not asked for again: it would come back +// the same. +TEST(HoermannHcpTextSensorTest, FirmwareVersionThatIsNotTextIsLoggedNotShown) { + IdentityFixture fixture; + auto &door = fixture.door; + request_serial(door); + send_serial(door); + status_poll(door, 0x07); + const char binary[12] = {0x01, 0x12, 0x34, 0x00, 0, 0, 0, 0, 0, 0, 0, 0}; + transfer(door, 0x08, SUB_FIRMWARE, binary, 12); + EXPECT_TRUE(door.firmware_unreadable_); + EXPECT_EQ(fixture.version_shown(), ""); // the raw bytes are not published + EXPECT_FALSE(door.firmware_unreadable_); + EXPECT_EQ(door.identity_request_(), 0); +} + +// A repeat of a transfer already taken, as after a lost acknowledgement, is acknowledged again. Answered as a +// status poll instead, it would carry the key press waiting in the slot. +TEST(HoermannHcpTextSensorTest, RepeatedTransferIsAcknowledgedNotAnsweredWithAKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + run_identity_exchange(door); + connect_controller(door); + door.open_door(); + + const RegisterValues answer = transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12); + EXPECT_EQ(answer[1], 0x04FD); + EXPECT_EQ(answer[2], 0x0000); + EXPECT_EQ(status_poll(door)[2], 0x0210); +} + +// An answer belongs to the frame whose write half took the transfer. A frame whose read went elsewhere leaves +// nothing behind for the next poll. +TEST(HoermannHcpTextSensorTest, AnAnswerBelongsToItsFrame) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + write_transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14); + RegisterValues ignored; + door.on_read_holding_registers(COMMAND_REG, 8, ignored); + + EXPECT_EQ(status_poll(door)[1] & 0x00FF, 0x0022); +} + +// Only the answer to a status poll carries a request, not the answer to another frame of the same length, and +// other transfers are not this exchange's to answer. +TEST(HoermannHcpTextSensorTest, RequestRidesOnlyOnAStatusPoll) { + IdentityFixture fixture; + auto &door = fixture.door; + status_poll(door); + const RegisterValues other = transfer(door, 0x06, 0x19, "\x00\x0F", 2); + EXPECT_EQ(other[1] & 0x00FF, 0x0001); + EXPECT_EQ(status_poll(door, 0x07)[1], 0x0322); +} + +// The request travels in the registers a key press would, so it waits for the press, the hold and the release. +TEST(HoermannHcpTextSensorTest, RequestWaitsForTheKeyPress) { + IdentityFixture fixture; + auto &door = fixture.door; + door.key_press_delay_ms_ = 100; + connect_controller(door); + status_poll(door); + door.open_door(); + + EXPECT_EQ(status_poll(door)[2], 0x0210); + const RegisterValues held = status_poll(door); + EXPECT_EQ(held[1], 0x0301); + EXPECT_EQ(held[2], 0x0000); + door.key_press_delay_ms_ = 0; + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + const RegisterValues release = status_poll(door); + EXPECT_EQ(release[1], 0x0301); + EXPECT_EQ(release[2], 0x0110); + EXPECT_EQ(status_poll(door)[1], 0x0322); +} + +// Each value is published once it is in, and only once. +TEST(HoermannHcpTextSensorTest, EachValueIsPublishedOnce) { + IdentityFixture fixture; + int serial_publishes = 0; + int version_publishes = 0; + fixture.serial.add_on_state_callback([&serial_publishes](const std::string & /*state*/) { serial_publishes++; }); + fixture.version.add_on_state_callback([&version_publishes](const std::string & /*state*/) { version_publishes++; }); + + fixture.door.update(); + EXPECT_EQ(serial_publishes, 0); + EXPECT_EQ(version_publishes, 0); + + run_identity_exchange(fixture.door); + EXPECT_EQ(fixture.serial.get_state(), SERIAL); + EXPECT_EQ(fixture.version.get_state(), "FW-TEST 1.0"); + + fixture.door.update(); + fixture.door.update(); + EXPECT_EQ(serial_publishes, 1); + EXPECT_EQ(version_publishes, 1); +} + +// Configuring only one of the two is enough to ask. +TEST(HoermannHcpTextSensorTest, OneSensorIsEnough) { + TestableHoermannHcp version_only; + text_sensor::TextSensor version; + version_only.set_version_text_sensor(&version); + run_identity_exchange(version_only); + EXPECT_EQ(version.get_state(), "FW-TEST 1.0"); + + TestableHoermannHcp serial_only; + text_sensor::TextSensor serial; + serial_only.set_serial_number_text_sensor(&serial); + run_identity_exchange(serial_only); + EXPECT_EQ(serial.get_state(), SERIAL); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/http_request/http_request.yaml b/tests/components/http_request/http_request.yaml index 172e0c235ad..eb8a6d33e7a 100644 --- a/tests/components/http_request/http_request.yaml +++ b/tests/components/http_request/http_request.yaml @@ -53,8 +53,8 @@ esphome: # Regression test for esphome/esphome#16224: a light.turn_on action # nested inside on_response with capture_response: true puts # `std::string &` into the trigger's Ts..., which exposed a codegen - # bug where the apply lambda's parameter list did not match the - # ApplyFn signature. + # bug where the generated function's parameter list did not match + # the automation's arguments. - light.turn_on: id: test_regression_light brightness: 100% diff --git a/tests/components/light/__init__.py b/tests/components/light/__init__.py index d5cdb70c9c8..2d5eb9223dc 100644 --- a/tests/components/light/__init__.py +++ b/tests/components/light/__init__.py @@ -8,6 +8,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_WEBSERVER") # api_connection.cpp reports the port whenever USE_WEBSERVER is set cg.add_define("USE_WEBSERVER_PORT", 80) + # The gamma table and its stored gamma are behind this define + cg.add_define("USE_LIGHT_GAMMA_LUT") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + ["json"] diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 9e3242f9a19..c40cfd5e3a3 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -217,6 +217,7 @@ light: name: Binary Light output: light_test_binary restore_mode: RESTORE_AND_ON + resume_effect: true effects: - strobe: on_state: @@ -254,6 +255,7 @@ light: state: INITIAL brightness: INITIAL - platform: monochromatic + flash_transition_length: 100ms id: test_monochromatic_light name: Monochromatic Light output: test_ledc_1 diff --git a/tests/components/light/test_light_state.cpp b/tests/components/light/test_light_state.cpp new file mode 100644 index 00000000000..85bfbd09ef5 --- /dev/null +++ b/tests/components/light/test_light_state.cpp @@ -0,0 +1,68 @@ +#include + +#include "esphome/components/light/light_effect.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +namespace esphome::light::testing { + +namespace { + +class BrightnessOutput : public LightOutput { + public: + LightTraits get_traits() override { + LightTraits traits; + traits.set_supported_color_modes({ColorMode::BRIGHTNESS}); + return traits; + } + void write_state(LightState *state) override {} +}; + +class NoopEffect : public LightEffect { + public: + using LightEffect::LightEffect; + void apply() override {} +}; + +// start_effect_() is where the uint32_t index is narrowed to the stored uint16_t. +class TestableLightState : public LightState { + public: + using LightState::LightState; + using LightState::start_effect_; +}; + +} // namespace + +// add_effects() is public, so an external component can exceed the codegen cap on effect count; +// an index the uint16_t can't hold must be ignored rather than wrap onto another effect. +TEST(LightStateEffect, IndexAboveUint16IsIgnoredAndKeepsTheActiveEffect) { + BrightnessOutput output; + TestableLightState state(&output); + NoopEffect effect("Noop"); + state.add_effects({&effect}); + + state.start_effect_(1); + ASSERT_EQ(state.get_current_effect_index(), 1u); + + state.start_effect_(0x10000u); // unchecked narrowing wraps this to 0, which stops the effect + EXPECT_EQ(state.get_current_effect_index(), 1u); +} + +// get_gamma_correct() reads the gamma codegen stores after the lookup table, rounded to two decimals. +TEST(LightStateGamma, ReadsTheGammaStoredWithTheTable) { + static constexpr GammaTable TABLE{{}, 280}; + BrightnessOutput output; + LightState state(&output); + state.set_gamma_table(&TABLE); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 2.8f); + EXPECT_EQ(state.get_gamma_table(), TABLE.lut); +} + +TEST(LightStateGamma, IsZeroWithoutATable) { + BrightnessOutput output; + LightState state(&output); + EXPECT_FLOAT_EQ(state.get_gamma_correct(), 0.0f); + EXPECT_EQ(state.get_gamma_table(), nullptr); +} + +} // namespace esphome::light::testing diff --git a/tests/components/lvgl/__init__.py b/tests/components/lvgl/__init__.py new file mode 100644 index 00000000000..4c8e202cf91 --- /dev/null +++ b/tests/components/lvgl/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components.lvgl import LVGL_VERSION +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The real to_code needs a display and widgets. The unit tests only need the LVGL + # headers and the animation code. + async def to_code_testing(config: ConfigType) -> None: + cg.add_library("lvgl/lvgl", LVGL_VERSION) + cg.add_build_flag("-DLV_CONF_SKIP=1") + cg.add_define("USE_LVGL_ANIMATION") + + manifest.to_code = to_code_testing diff --git a/tests/components/lvgl/animation_test.cpp b/tests/components/lvgl/animation_test.cpp new file mode 100644 index 00000000000..0ccb412f043 --- /dev/null +++ b/tests/components/lvgl/animation_test.cpp @@ -0,0 +1,184 @@ +#include +#include +#include +#include +#include +#include "esphome/components/lvgl/animation.h" + +namespace esphome::lvgl::testing { + +namespace { + +// The update callback is a plain function pointer, so it cannot capture; results go to a global. +std::vector updates; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +void record_update(const lv_coord_t *data) { updates.push_back(data[0]); } + +// Exposes the running state so tests can wait for the animation to finish. +class TestAnimation : public LvAnimation<1> { + public: + TestAnimation(lv_coord_t from, lv_coord_t to, uint32_t duration_ms, uint32_t start_delay_ms = 0) + : LvAnimation<1>(record_update, {TemplatableValue(from)}, {TemplatableValue(to)}) { + this->set_duration(duration_ms); + this->set_start_delay(start_delay_ms); + this->add_on_start_callback([this]() { this->start_count++; }); + this->add_on_stop_callback([this]() { this->stop_count++; }); + } + + bool is_running() const { return this->state_ != AnimationState::STOPPED; } + + // Returns true if the animation stopped before the timeout. + bool run_until_stopped(uint32_t timeout_ms = 1000) { + const uint32_t begin = millis(); + while (this->is_running() && millis() - begin < timeout_ms) { + this->loop(); + delay(1); + } + return !this->is_running(); + } + + void run_for(uint32_t duration_ms) { + const uint32_t begin = millis(); + while (millis() - begin < duration_ms) { + this->loop(); + delay(1); + } + } + + int start_count{0}; + int stop_count{0}; +}; + +lv_coord_t max_update() { + lv_coord_t result = updates.front(); + for (auto value : updates) + result = std::max(result, value); + return result; +} + +} // namespace + +class LvAnimationTest : public ::testing::Test { + protected: + void SetUp() override { updates.clear(); } +}; + +TEST_F(LvAnimationTest, LinearRunsToEndAndStopsOnce) { + TestAnimation anim(0, 100, 20); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.front(), 0); + EXPECT_EQ(updates.back(), 100); + EXPECT_EQ(anim.start_count, 1); + EXPECT_EQ(anim.stop_count, 1); + + // Nothing more happens once stopped. + const size_t update_count = updates.size(); + anim.run_for(10); + EXPECT_EQ(updates.size(), update_count); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, ZeroDurationDoesNotStart) { + TestAnimation anim(0, 100, 0); + anim.start(); + EXPECT_FALSE(anim.is_running()); + EXPECT_EQ(anim.start_count, 0); + EXPECT_TRUE(updates.empty()); +} + +TEST_F(LvAnimationTest, StartDelayHoldsBackUpdates) { + TestAnimation anim(0, 100, 20, 30); + anim.start(); + EXPECT_TRUE(anim.is_running()); + EXPECT_TRUE(updates.empty()); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 100); +} + +// Round trip maps the end of the duration back to the start value, so completion must not depend on the +// mapped value reaching 1.0. +TEST_F(LvAnimationTest, RoundTripStopsAtStartValue) { + LvAnimationTimingRoundTrip timing(0.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 0); + EXPECT_EQ(anim.stop_count, 1); +} + +// The pause maps to 1.0 in the middle of the duration; the animation must still play the return leg. +TEST_F(LvAnimationTest, RoundTripWithPausePlaysReturnLeg) { + LvAnimationTimingRoundTrip timing(0.5f); + TestAnimation anim(0, 100, 40); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(max_update(), 100); + EXPECT_EQ(updates.back(), 0); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, GravityStops) { + LvAnimationTimingGravity timing(0.5f, 0.5f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(anim.stop_count, 1); +} + +TEST_F(LvAnimationTest, EaseInOutEndsAtTarget) { + LvAnimationTimingEaseInOut timing(1.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.start(); + ASSERT_TRUE(anim.run_until_stopped()); + EXPECT_EQ(updates.back(), 100); +} + +TEST_F(LvAnimationTest, LoopRestartsOnlyAfterReachingEnd) { + TestAnimation anim(0, 100, 20); + anim.set_loop(true); + anim.start(); + anim.run_for(100); + anim.stop(); + + EXPECT_GE(anim.start_count, 2); + // Each restart is a drop in value, and must follow a completed cycle that reached the end value. + for (size_t i = 1; i < updates.size(); i++) { + if (updates[i] < updates[i - 1]) + EXPECT_EQ(updates[i - 1], 100) << "update " << i << " restarted before the end was reached"; + } +} + +// The first frame of the next cycle must not be drawn in the same pass as the final frame of the last one. +TEST_F(LvAnimationTest, LoopRestartKeepsFinalFrame) { + TestAnimation anim(0, 100, 20); + anim.set_loop(true); + anim.start(); + const uint32_t begin = millis(); + while (millis() - begin < 100) { + const size_t before = updates.size(); + anim.loop(); + ASSERT_LE(updates.size() - before, 1u); + delay(1); + } + anim.stop(); + EXPECT_GE(anim.start_count, 2); +} + +TEST_F(LvAnimationTest, LoopWithRoundTripKeepsCycling) { + LvAnimationTimingRoundTrip timing(0.0f); + TestAnimation anim(0, 100, 20); + anim.add_timing(&timing); + anim.set_loop(true); + anim.start(); + anim.run_for(100); + EXPECT_GE(anim.start_count, 2); + EXPECT_GE(anim.stop_count, 1); + anim.stop(); +} + +} // namespace esphome::lvgl::testing diff --git a/tests/components/media_player/common.yaml b/tests/components/media_player/common.yaml index dd6773864d6..dcd04e017ad 100644 --- a/tests/components/media_player/common.yaml +++ b/tests/components/media_player/common.yaml @@ -13,60 +13,75 @@ media_player: speaker: test_speaker format: NONE on_state: - - media_player.play: - - media_player.play_media: http://localhost/media.mp3 - - media_player.play_media: !lambda |- - return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; + - media_player.play: test_media_player + - media_player.play_media: + id: test_media_player + media_url: http://localhost/media.mp3 + - media_player.play_media: + id: test_media_player + media_url: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; on_idle: - - media_player.pause: + - media_player.pause: test_media_player on_play: + - media_player.stop: test_media_player - media_player.stop: - - media_player.stop: + id: test_media_player announcement: true - media_player.stop: + id: test_media_player announcement: !lambda 'return id(test_media_player).is_muted();' on_announcement: - - media_player.play: + - media_player.play: test_media_player on_turn_on: - - media_player.play: + - media_player.play: test_media_player on_turn_off: - - media_player.stop: + - media_player.stop: test_media_player on_pause: - - media_player.toggle: - - media_player.turn_on: - - media_player.turn_off: - - media_player.next: - - media_player.previous: - - media_player.mute: - - media_player.unmute: - - media_player.repeat_off: - - media_player.repeat_one: - - media_player.repeat_all: - - media_player.shuffle: - - media_player.unshuffle: - - media_player.group_join: - - media_player.clear_playlist: + - media_player.toggle: test_media_player + - media_player.turn_on: test_media_player + - media_player.turn_off: test_media_player + - media_player.next: test_media_player + - media_player.previous: test_media_player + - media_player.mute: test_media_player + - media_player.unmute: test_media_player + - media_player.repeat_off: test_media_player + - media_player.repeat_one: test_media_player + - media_player.repeat_all: test_media_player + - media_player.shuffle: test_media_player + - media_player.unshuffle: test_media_player + - media_player.group_join: test_media_player + - media_player.clear_playlist: test_media_player - wait_until: - media_player.is_idle: + media_player.is_idle: test_media_player - wait_until: - media_player.is_playing: + media_player.is_playing: test_media_player - wait_until: - media_player.is_announcing: + media_player.is_announcing: test_media_player - wait_until: - media_player.is_paused: + media_player.is_paused: test_media_player - wait_until: - media_player.is_on: + media_player.is_on: test_media_player - wait_until: - media_player.is_off: + media_player.is_off: test_media_player - wait_until: - media_player.is_muted: - - media_player.volume_up: - - media_player.volume_down: - - media_player.volume_set: 50% - - media_player.volume_set: !lambda 'return id(test_media_player).volume / 2;' - - media_player.enqueue: http://localhost/media.mp3 - - media_player.enqueue: !lambda |- - return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; + media_player.is_muted: test_media_player + - media_player.volume_up: test_media_player + - media_player.volume_down: test_media_player + - media_player.volume_set: + id: test_media_player + volume: 50% + - media_player.volume_set: + id: test_media_player + volume: !lambda 'return id(test_media_player).volume / 2;' - media_player.enqueue: + id: test_media_player + media_url: http://localhost/media.mp3 + - media_player.enqueue: + id: test_media_player + media_url: !lambda |- + return id(test_media_player).is_muted() ? "http://localhost/muted.mp3" : "http://localhost/media.mp3"; + - media_player.enqueue: + id: test_media_player media_url: http://localhost/media.mp3 announcement: true diff --git a/tests/components/pn532/pn532_test.cpp b/tests/components/pn532/pn532_test.cpp index 38c369acbc0..75f9cf6d088 100644 --- a/tests/components/pn532/pn532_test.cpp +++ b/tests/components/pn532/pn532_test.cpp @@ -1,6 +1,8 @@ #include +#include #include +#include #include "esphome/components/pn532/pn532.h" @@ -14,6 +16,7 @@ class FakePN532 : public PN532 { using PN532::auth_mifare_classic_block_; using PN532::read_mifare_ultralight_bytes_; using PN532::read_mifare_classic_block_; + using PN532::write_command_; using PN532::write_mifare_classic_block_; using PN532::write_mifare_ultralight_page_; @@ -22,24 +25,26 @@ class FakePN532 : public PN532 { protected: bool is_read_ready() override { return true; } - bool write_data(const std::vector &data) override { - this->written.push_back(data); + bool write_data(std::span data) override { + this->written.emplace_back(data.begin(), data.end()); return true; } // only used for ACK frames; index 0 is the I2C status byte - bool read_data(std::vector &data, uint8_t len) override { + bool read_data(PN532Frame &data, size_t len) override { data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; return true; } - bool read_response(uint8_t command, std::vector &data) override { + bool read_response(uint8_t command, PN532Frame &data) override { if (this->responses.empty()) return false; - data = this->responses.front(); + data.assign(this->responses.front().begin(), this->responses.front().end()); this->responses.pop_front(); return true; } }; +std::vector bytes_of(std::span bytes) { return {bytes.begin(), bytes.end()}; } + // Extracts the command bytes (after TFI) from a normal information frame std::vector frame_data(const std::vector &frame) { // preamble, start code (2), LEN, LCS, TFI, data..., DCS, postamble @@ -58,40 +63,79 @@ TEST(PN532TagType, FromSelRes) { EXPECT_EQ(tag_type_from_sel_res(0x40), nfc::TAG_TYPE_UNKNOWN); } +// The frame wraps the command in preamble, start code, LEN, LCS, TFI, DCS and postamble (UM0701-02, 6.2.1.1). +TEST(PN532Frame, WrapsCommand) { + FakePN532 pn532; + ASSERT_TRUE(pn532.write_command_({0x4A, 0x01, 0x00})); + ASSERT_EQ(pn532.written.size(), 1u); + EXPECT_EQ(pn532.written[0], (std::vector{0x00, 0x00, 0xFF, 0x04, 0xFC, 0xD4, 0x4A, 0x01, 0x00, 0xE1, 0x00})); +} + +// A command that cannot fit a normal information frame is refused rather than truncated. +TEST(PN532Frame, RejectsOversizedCommand) { + FakePN532 pn532; + std::array too_long{}; + EXPECT_FALSE(pn532.write_command_(too_long)); + EXPECT_TRUE(pn532.written.empty()); + EXPECT_TRUE(pn532.write_command_(std::span(too_long).first(PN532_FRAME_MAX_DATA_SIZE))); + ASSERT_EQ(pn532.written.size(), 1u); + EXPECT_EQ(pn532.written[0].size(), PN532_FRAME_MAX_DATA_SIZE + 8); +} + // A failed write (status byte other than 0x00) must be reported as a failure. TEST(PN532Mifare, ClassicWriteChecksStatus) { FakePN532 pn532; const uint8_t block[16] = {}; pn532.responses.push_back({0x14}); // authentication error - EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block, sizeof(block))); + EXPECT_FALSE(pn532.write_mifare_classic_block_(4, block)); pn532.responses.push_back({0x00}); - EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block, sizeof(block))); + EXPECT_TRUE(pn532.write_mifare_classic_block_(4, block)); } TEST(PN532Mifare, UltralightWriteChecksStatus) { FakePN532 pn532; const uint8_t page[4] = {}; pn532.responses.push_back({0x01}); // timeout - EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page))); + EXPECT_FALSE(pn532.write_mifare_ultralight_page_(4, page)); pn532.responses.push_back({0x00}); - EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page, sizeof(page))); + EXPECT_TRUE(pn532.write_mifare_ultralight_page_(4, page)); } TEST(PN532Mifare, ClassicReadRejectsBadResponses) { FakePN532 pn532; - std::vector data; + MifareReadData data{}; pn532.responses.emplace_back(); // empty response EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); - data.clear(); pn532.responses.push_back({0x00, 0x01, 0x02}); // short block EXPECT_FALSE(pn532.read_mifare_classic_block_(4, data)); std::vector good(17, 0xAB); good[0] = 0x00; pn532.responses.push_back(good); - data.clear(); EXPECT_TRUE(pn532.read_mifare_classic_block_(4, data)); - EXPECT_EQ(data, std::vector(16, 0xAB)); + EXPECT_EQ(bytes_of(data), std::vector(16, 0xAB)); +} + +// The NDEF TLV is type 0x03, a one-byte length below 255 (three bytes otherwise), the message, terminator 0xFE, +// then zero padding out to the requested length. +TEST(PN532Ndef, FillsTlv) { + FixedVector buffer; + const std::array message = {0xD1, 0x01, 0x02}; + nfc::fill_ndef_tlv(message, 8, buffer); + EXPECT_EQ(bytes_of(std::span(buffer)), + (std::vector{0x03, 0x03, 0xD1, 0x01, 0x02, 0xFE, 0x00, 0x00})); + + std::vector long_message(300, 0xAA); + nfc::fill_ndef_tlv(long_message, 320, buffer); + ASSERT_EQ(buffer.size(), 320u); + EXPECT_EQ(buffer[0], 0x03); + EXPECT_EQ(buffer[1], 0xFF); + EXPECT_EQ(buffer[2], 0x01); // 300 = 0x012C + EXPECT_EQ(buffer[3], 0x2C); + EXPECT_EQ(buffer[4], 0xAA); + EXPECT_EQ(buffer[303], 0xAA); + EXPECT_EQ(buffer[304], 0xFE); + EXPECT_EQ(buffer[319], 0x00); } // Authentication carries exactly 4 UID bytes: the last 4 of a 7-byte UID. @@ -119,7 +163,7 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { pn532.responses.push_back(first); pn532.responses.push_back(second); - std::vector data; + UltralightReadBuffer data; ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data)); ASSERT_EQ(data.size(), 20u); EXPECT_EQ(data[15], 15); @@ -133,7 +177,7 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { TEST(PN532Mifare, UltralightReadRejectsBadResponses) { FakePN532 pn532; - std::vector data; + UltralightReadBuffer data; pn532.responses.push_back({0x00, 0x01, 0x02}); // short response EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); diff --git a/tests/components/pn532_i2c/common-actions.yaml b/tests/components/pn532_i2c/common-actions.yaml new file mode 100644 index 00000000000..2f5bd4aec42 --- /dev/null +++ b/tests/components/pn532_i2c/common-actions.yaml @@ -0,0 +1,10 @@ +button: + - platform: template + name: PN532 write state + on_press: + - if: + condition: + pn532.is_writing: + id: pn532_nfcc_i2c + then: + - logger.log: Writing diff --git a/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml b/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml new file mode 100644 index 00000000000..9a413421266 --- /dev/null +++ b/tests/components/pn532_i2c/test-actions.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pn532_i2c: !include common.yaml + actions: !include common-actions.yaml diff --git a/tests/components/pn71xx/pn71xx_test.cpp b/tests/components/pn71xx/pn71xx_test.cpp index ceb2d7305c5..a856400dcdd 100644 --- a/tests/components/pn71xx/pn71xx_test.cpp +++ b/tests/components/pn71xx/pn71xx_test.cpp @@ -12,6 +12,9 @@ namespace { class FakePN71xx : public PN71xx { public: using PN71xx::card_emu_t4t_get_response_; + using PN71xx::discovered_endpoint_; + using PN71xx::erase_tag_; + using PN71xx::find_or_add_tag_; using PN71xx::transceive_; std::deque> to_read; @@ -36,7 +39,8 @@ class FakePN71xx : public PN71xx { this->write_failures--; return nfc::STATUS_FAILED; } - this->written.push_back(tx.encode()); + const auto encoded = tx.encode(); + this->written.emplace_back(encoded.begin(), encoded.end()); return nfc::STATUS_OK; } }; @@ -48,9 +52,9 @@ std::vector apdu(std::initializer_list bytes) { } std::vector respond(FakePN71xx &nfcc, std::initializer_list bytes) { - std::vector response; + CardEmuResponse response; nfcc.card_emu_t4t_get_response_(apdu(bytes), response); - return response; + return {response.begin(), response.end()}; } void select_ndef_file(FakePN71xx &nfcc) { @@ -58,6 +62,10 @@ void select_ndef_file(FakePN71xx &nfcc) { respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}); } +std::vector bytes_of(const nfc::NciMessage &msg) { + return {msg.get_message().begin(), msg.get_message().end()}; +} + const std::vector SW_OK = {0x90, 0x00}; const std::vector SW_NOT_FOUND = {0x6A, 0x82}; @@ -80,7 +88,7 @@ TEST(PN71xxTransceive, SkipsNotificationAheadOfResponse) { nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); nfc::NciMessage rx; EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); - EXPECT_EQ(rx.get_message(), (std::vector{0x41, 0x06, 0x01, 0x00})); + EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x06, 0x01, 0x00})); EXPECT_EQ(nfcc.written.size(), 1u); } @@ -100,10 +108,65 @@ TEST(PN71xxTransceive, SkipsStaleResponseFromEarlierCommand) { nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, {0x00}); nfc::NciMessage rx; EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); - EXPECT_EQ(rx.get_message(), (std::vector{0x41, 0x03, 0x01, 0x00})); + EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x03, 0x01, 0x00})); EXPECT_EQ(nfcc.written.size(), 1u); } +nfc::NfcTagUid uid_of(uint8_t last) { return {0x04, 0x00, 0x00, last}; } + +// Erasing an entry keeps the others in order and frees the tag of the slot that is dropped. +TEST(PN71xxTagCache, EraseKeepsOrderAndFreesTail) { + FakePN71xx nfcc; + for (uint8_t i = 0; i < 3; i++) { + nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); + } + nfcc.erase_tag_(1); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), 2u); + EXPECT_EQ(nfcc.discovered_endpoint_[0].tag->get_uid()[3], 0); + EXPECT_EQ(nfcc.discovered_endpoint_[1].tag->get_uid()[3], 2); + EXPECT_EQ(nfcc.discovered_endpoint_.data()[2].tag, nullptr); + nfcc.erase_tag_(1); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), 1u); + EXPECT_EQ(nfcc.discovered_endpoint_.data()[1].tag, nullptr); +} + +// A full cache evicts the entry seen longest ago instead of refusing the new tag. +TEST(PN71xxTagCache, FullCacheEvictsOldest) { + FakePN71xx nfcc; + for (uint8_t i = 0; i < MAX_DISCOVERED_ENDPOINTS; i++) { + const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); + nfcc.discovered_endpoint_[loc].last_seen = 100 + i; + } + nfcc.discovered_endpoint_[3].last_seen = 1; // seen longest ago + const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)); + ASSERT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); + EXPECT_EQ(nfcc.discovered_endpoint_[loc].tag->get_uid()[3], 0x99); + for (const auto &endpoint : nfcc.discovered_endpoint_) { + EXPECT_NE(endpoint.tag->get_uid()[3], 3); + } + // a known UID is found, not added again + EXPECT_EQ(nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)), loc); + EXPECT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); +} + +// Bytes that do not fit the packet are dropped and the length byte stays consistent. +TEST(PN71xxNciMessage, AppendStopsAtPacketSize) { + nfc::NciMessage msg(nfc::NCI_PKT_MT_DATA, {0x01}); + std::vector big(300, 0xAA); + msg.append(big); + const auto encoded = msg.encode(); + EXPECT_EQ(encoded.size(), nfc::NCI_PKT_MAX_SIZE); + EXPECT_EQ(msg.get_payload_size(), nfc::NCI_PKT_MAX_PAYLOAD_SIZE); +} + +// A read that could not fit the status bytes into one packet is refused. +TEST(PN71xxCardEmulation, OversizedReadIsRejected) { + FakePN71xx nfcc; + nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/pulse_ce"); + select_ndef_file(nfcc); + EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0xFE}), SW_NOT_FOUND); +} + // A refused write (e.g. NFCC in standby) is sent again. TEST(PN71xxTransceive, RefusedWriteIsRetried) { FakePN71xx nfcc; diff --git a/tests/components/template/common.yaml b/tests/components/template/common.yaml index d06f3ce1317..a93fc0f5b6b 100644 --- a/tests/components/template/common.yaml +++ b/tests/components/template/common.yaml @@ -3,6 +3,10 @@ time: - platform: sntp # Required for datetime id: sntp_time + - platform: template + id: template_offset_time + lambda: |- + return id(sntp_time).timestamp_now() + 3600; wifi: # Required for sntp time ap: diff --git a/tests/components/tfluna/common.yaml b/tests/components/tfluna/common.yaml index 5804ae6981b..57ecf892861 100644 --- a/tests/components/tfluna/common.yaml +++ b/tests/components/tfluna/common.yaml @@ -1,4 +1,5 @@ tfluna: + - i2c_id: i2c_bus sensor: - platform: tfluna diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 78e0b1a36c9..ce2e7f5bbc0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,6 @@ import re import shutil import signal import socket -import subprocess import sys import tempfile import time @@ -35,7 +34,7 @@ from esphome.helpers import ( write_file, write_file_if_changed, ) -from esphome.platformio.toolchain import get_idedata +from esphome.host.toolchain import get_elf_path from .const import ( API_CONNECTION_TIMEOUT, @@ -82,16 +81,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests" -def _get_platformio_env(cache_dir: Path) -> dict[str, str]: - """Get environment variables for PlatformIO with shared cache.""" +def _get_build_env() -> dict[str, str]: + """Environment for an ``esphome compile`` subprocess.""" env = os.environ.copy() - env["PLATFORMIO_CORE_DIR"] = str(cache_dir) - env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache") - # libdeps is keyed only by env name (the device name), and fixtures share - # names; two xdist workers first-compiling the same name race pio pkg - # install in the same directory. Keep libdeps per worker. - worker = os.environ.get("PYTEST_XDIST_WORKER", "master") - env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker) # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" # Cap each compile's -j so several xdist workers do not each spawn a @@ -110,53 +102,41 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: return env +# Registry libraries (noise-c, lvgl, ...) download into /pio_components, +# and every integration test gets its own data dir. Share one download cache +# per xdist worker instead: the converter has no cross-process lock, so +# workers must never write the same dir, while a worker's later tests (and +# later runs) reuse what it fetched. +LIBRARY_CACHE_ROOT = INTEGRATION_TESTS_ROOT / "pio_components" + + @pytest.fixture(scope="session") -def shared_platformio_cache() -> Generator[Path]: - """Initialize a shared PlatformIO cache for all integration tests.""" - # Use a dedicated directory for integration tests to avoid conflicts. - test_cache_dir = INTEGRATION_TESTS_ROOT - cache_dir = test_cache_dir / "platformio" +def shared_library_cache() -> Path: + """This worker's shared registry-library download cache.""" + worker = os.environ.get("PYTEST_XDIST_WORKER", "master") + # Keyed by checkout too: two sessions on one machine share worker ids + cache_dir = LIBRARY_CACHE_ROOT / _REPO_KEY / worker + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir - # Use a lock file in the home directory to ensure only one process initializes the cache - # This is needed when running with pytest-xdist - # The lock file must be in a directory that already exists to avoid race conditions - lock_file = Path.home() / ".esphome-integration-tests-init.lock" - # Always acquire the lock to ensure cache is ready before proceeding - with lock_file.open("w") as lock_fd: - fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) - - # Check if the native platform is installed (the actual indicator of a populated cache) - native_platform = cache_dir / "platforms" / "native" - if not native_platform.exists(): - # Create the test cache directory if it doesn't exist - test_cache_dir.mkdir(exist_ok=True) - - with tempfile.TemporaryDirectory() as tmpdir: - # Use the cache_init fixture for initialization - init_dir = Path(tmpdir) - fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml" - config_path = init_dir / "cache_init.yaml" - config_path.write_text( - fixture_path.read_text(encoding="utf-8"), encoding="utf-8" - ) - - # Run compilation to populate the cache - # We must succeed here to avoid race conditions where multiple - # tests try to populate the same cache directory simultaneously - env = _get_platformio_env(cache_dir) - - subprocess.run( - [sys.executable, "-m", "esphome", "compile", str(config_path)], - check=True, - cwd=init_dir, - env=env, - close_fds=False, - ) - - # Lock is held until here, ensuring cache is fully populated before any test proceeds - - yield cache_dir +def _link_library_cache(config_dir: Path, cache_dir: Path) -> None: + """Point a config dir's library download cache at the shared one + (blocking, run in executor).""" + data_dir = config_dir / ".esphome" + data_dir.mkdir(exist_ok=True) + link = data_dir / "pio_components" + if link.is_symlink(): + if link.resolve() == cache_dir.resolve(): + return + # A shared build dir was linked by another worker; writing through + # it would put two workers in one cache + link.unlink() + elif link.exists(): + # A real dir from a run predating the shared cache; nothing in it is + # worth more than a re-download + rmtree(link) + link.symlink_to(cache_dir, target_is_directory=True) @pytest.fixture(scope="module", autouse=True) @@ -238,18 +218,17 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s content = content.replace("api:", f"api:\n port: {unused_tcp_port}") # Add debug build flags for integration tests to enable assertions - if "esphome:" in content and "platformio_options:" not in content: - # Add platformio_options with debug flags after esphome: + if "esphome:" in content and "build_flags:" not in content: + # Add build_flags with debug flags after esphome: content = content.replace( "esphome:", "esphome:\n" " # Enable assertions for integration tests\n" - " platformio_options:\n" - " build_flags:\n" - ' - "-DDEBUG" # Enable assert() statements\n' - ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' - ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' - ' - "-g" # Add debug symbols', + " build_flags:\n" + ' - "-DDEBUG" # Enable assert() statements\n' + ' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n' + ' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n' + ' - "-g" # Add debug symbols', ) # Replace external component path placeholder if present @@ -283,8 +262,8 @@ async def write_yaml_config( yield _write_config -# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale -# dirs for a fixture are pruned when its content hash changes. +# Deliberately not CI-cached; stale dirs for a fixture are pruned when its +# content hash changes. SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds" # In the dir name (not just the hash) so pruning stays inside this checkout @@ -470,8 +449,7 @@ def _resolve_compiled_binary(config_path: Path) -> Path: ) if config is None: raise RuntimeError(f"Failed to read config from {config_path}") - idedata = get_idedata(config) - binary_path = Path(idedata.firmware_elf_path) + binary_path = get_elf_path() if not binary_path.exists(): raise RuntimeError(f"Compiled binary not found at {binary_path}") return binary_path @@ -480,19 +458,22 @@ def _resolve_compiled_binary(config_path: Path) -> Path: @pytest_asyncio.fixture async def compile_esphome( integration_test_dir: Path, - shared_platformio_cache: Path, + shared_library_cache: Path, request: pytest.FixtureRequest, ) -> AsyncGenerator[CompileFunction]: """Compile an ESPHome configuration and return the binary path.""" async def _compile(config_path: Path) -> Path: - # Use the shared PlatformIO cache for faster compilation - # This avoids re-downloading dependencies for each test - env = _get_platformio_env(shared_platformio_cache) + env = _get_build_env() loop = asyncio.get_running_loop() name = _shared_yaml_name(request) if name is None: + # Share the library download cache so a test never re-fetches + # what an earlier one already pulled from the registry + await loop.run_in_executor( + None, _link_library_cache, integration_test_dir, shared_library_cache + ) await _run_esphome_compile(config_path, integration_test_dir, env) return await loop.run_in_executor( None, _resolve_compiled_binary, config_path @@ -568,6 +549,9 @@ async def compile_esphome( await loop.run_in_executor( None, write_file_if_changed, shared_config, content ) + await loop.run_in_executor( + None, _link_library_cache, shared_dir, shared_library_cache + ) await _run_esphome_compile(shared_config, shared_dir, env) if built is None or not built.exists(): built = await loop.run_in_executor( diff --git a/tests/integration/fixtures/api_action_timeout.yaml b/tests/integration/fixtures/api_action_timeout.yaml index 405d9d0e2b3..5dc6e1db050 100644 --- a/tests/integration/fixtures/api_action_timeout.yaml +++ b/tests/integration/fixtures/api_action_timeout.yaml @@ -1,9 +1,8 @@ esphome: name: api-action-timeout-test # Use a short timeout for testing (500ms instead of 30s) - platformio_options: - build_flags: - - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" + build_flags: + - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" host: diff --git a/tests/integration/fixtures/cache_init.yaml b/tests/integration/fixtures/cache_init.yaml deleted file mode 100644 index de208196cdf..00000000000 --- a/tests/integration/fixtures/cache_init.yaml +++ /dev/null @@ -1,10 +0,0 @@ -esphome: - name: cache-init - -host: - -api: - encryption: - key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w=" - -logger: diff --git a/tests/integration/fixtures/fnv1_hash_object_id.yaml b/tests/integration/fixtures/fnv1_hash_object_id.yaml index 2097b2fbf9c..05449afe86c 100644 --- a/tests/integration/fixtures/fnv1_hash_object_id.yaml +++ b/tests/integration/fixtures/fnv1_hash_object_id.yaml @@ -1,8 +1,7 @@ esphome: name: fnv1-hash-object-id-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::fnv1_hash_object_id; diff --git a/tests/integration/fixtures/fnv1a_hash.yaml b/tests/integration/fixtures/fnv1a_hash.yaml index d9c80601b88..3da0042e50e 100644 --- a/tests/integration/fixtures/fnv1a_hash.yaml +++ b/tests/integration/fixtures/fnv1a_hash.yaml @@ -1,8 +1,7 @@ esphome: name: fnv1a-hash-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::fnv1a_hash; diff --git a/tests/integration/fixtures/light_resume_effect.yaml b/tests/integration/fixtures/light_resume_effect.yaml new file mode 100644 index 00000000000..e52dc1ea677 --- /dev/null +++ b/tests/integration/fixtures/light_resume_effect.yaml @@ -0,0 +1,47 @@ +esphome: + name: light-resume-effect + +host: + +api: + +logger: + level: DEBUG + +output: + - platform: template + id: light_output + type: float + write_action: + - logger.log: + format: "LIGHT_OUTPUT:%.2f" + args: [state] + +light: + - platform: monochromatic + name: "Test Light Resume" + id: test_light_resume + output: light_output + resume_effect: true + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms + - strobe: + name: "Strobe Effect" + colors: + - state: true + duration: 50ms + - state: false + duration: 50ms + - platform: monochromatic + name: "Test Light No Resume" + id: test_light_no_resume + output: light_output + resume_effect: false + effects: + - pulse: + name: "Pulse Effect" + transition_length: 20ms + update_interval: 50ms diff --git a/tests/integration/fixtures/micros_to_millis.yaml b/tests/integration/fixtures/micros_to_millis.yaml index d11808c43a2..a7ba1c719a0 100644 --- a/tests/integration/fixtures/micros_to_millis.yaml +++ b/tests/integration/fixtures/micros_to_millis.yaml @@ -1,8 +1,7 @@ esphome: name: micros-to-millis-test - platformio_options: - build_flags: - - "-DDEBUG" + build_flags: + - "-DDEBUG" on_boot: - lambda: |- using esphome::micros_to_millis; diff --git a/tests/integration/fixtures/template_time.yaml b/tests/integration/fixtures/template_time.yaml new file mode 100644 index 00000000000..d55e8fe6562 --- /dev/null +++ b/tests/integration/fixtures/template_time.yaml @@ -0,0 +1,47 @@ +esphome: + name: template-time-test +host: +api: +logger: + +time: + - platform: template + id: fixed_time + lambda: |- + return 1700000000; + + # Derived from another time source, as when offsetting a real clock. + - platform: template + id: offset_time + lambda: |- + return id(fixed_time).timestamp_now() + 3600; + + # A lambda that gives no value reports an invalid time. + - platform: template + id: empty_time + lambda: |- + return {}; + +text_sensor: + # Timestamps are exposed as text (rather than a 32-bit float sensor state, + # which cannot represent a UNIX epoch exactly) so the exact value can be checked. + - platform: template + name: "Fixed Timestamp" + id: fixed_timestamp + update_interval: 100ms + lambda: |- + return std::to_string(id(fixed_time).now().timestamp); + + - platform: template + name: "Offset Timestamp" + id: offset_timestamp + update_interval: 100ms + lambda: |- + return std::to_string(id(offset_time).now().timestamp); + + - platform: template + name: "Empty Time Valid" + id: empty_time_valid + update_interval: 100ms + lambda: |- + return std::string(id(empty_time).now().is_valid() ? "valid" : "invalid"); diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index ea9885872d5..f9f1a43373c 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,156 +1,164 @@ { - "tests/integration/test_action_concurrent_reentry.py": 33.88, - "tests/integration/test_addressable_light_transition.py": 25.37, - "tests/integration/test_alarm_control_panel_state_transitions.py": 29.76, - "tests/integration/test_api_action_metadata.py": 25.54, - "tests/integration/test_api_action_responses.py": 21.15, - "tests/integration/test_api_action_timeout.py": 37.89, - "tests/integration/test_api_conditional_memory.py": 13.21, - "tests/integration/test_api_custom_services.py": 18.72, - "tests/integration/test_api_get_time_response_timezone.py": 19.55, - "tests/integration/test_api_homeassistant.py": 32.09, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 17.67, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 14.04, - "tests/integration/test_api_list_entities_backpressure.py": 14.96, - "tests/integration/test_api_message_size_batching.py": 22.77, - "tests/integration/test_api_reboot_timeout.py": 20.76, - "tests/integration/test_api_string_lambda.py": 35.07, - "tests/integration/test_api_vv_logging.py": 17.17, - "tests/integration/test_api_zero_psk_provisioning.py": 27.91, - "tests/integration/test_areas_and_devices.py": 23.45, - "tests/integration/test_automation_wait_actions.py": 21.21, - "tests/integration/test_automations.py": 30.73, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 25.92, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 24.97, - "tests/integration/test_binary_sensor_invalidate_state.py": 17.7, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 15.43, - "tests/integration/test_build_info.py": 23.12, - "tests/integration/test_camera_mock.py": 21.23, - "tests/integration/test_climate_control_action.py": 26.55, - "tests/integration/test_climate_custom_modes.py": 24.85, - "tests/integration/test_continuation_actions.py": 14.94, - "tests/integration/test_cover_control_action.py": 25.35, - "tests/integration/test_crc8_helper.py": 19.94, - "tests/integration/test_device_id_in_state.py": 57.04, - "tests/integration/test_duplicate_entities.py": 17.91, - "tests/integration/test_entity_icon.py": 23.81, - "tests/integration/test_fan_turn_on_action.py": 14.88, - "tests/integration/test_fnv1_hash_object_id.py": 21.14, - "tests/integration/test_fnv1a_hash.py": 16.96, - "tests/integration/test_gpio_expander_cache.py": 16.1, - "tests/integration/test_host_logger_thread_safety.py": 17.96, - "tests/integration/test_host_mode_basic.py": 8.17, - "tests/integration/test_host_mode_batch_delay.py": 18.78, - "tests/integration/test_host_mode_climate_basic_state.py": 28.88, - "tests/integration/test_host_mode_climate_control.py": 20.11, - "tests/integration/test_host_mode_empty_string_options.py": 21.1, - "tests/integration/test_host_mode_entity_fields.py": 22.3, - "tests/integration/test_host_mode_fan_preset.py": 18.12, - "tests/integration/test_host_mode_many_entities.py": 47.88, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 30.94, - "tests/integration/test_host_mode_noise_encryption.py": 30.68, - "tests/integration/test_host_mode_reconnect.py": 4.8, - "tests/integration/test_host_mode_sensor.py": 17.36, - "tests/integration/test_host_ota.py": 94.88, - "tests/integration/test_host_preferences.py": 20.49, - "tests/integration/test_host_preferences_suspend_resume.py": 24.17, - "tests/integration/test_improv_serial_uart.py": 22.53, - "tests/integration/test_large_message_batching.py": 17.54, - "tests/integration/test_legacy_area.py": 23.03, - "tests/integration/test_legacy_climate_compat.py": 19.3, - "tests/integration/test_legacy_fan_compat.py": 14.69, - "tests/integration/test_light_automations.py": 21.44, - "tests/integration/test_light_binary_effect_off_phase.py": 29.84, - "tests/integration/test_light_calls.py": 26.72, - "tests/integration/test_light_constant_brightness.py": 29.08, - "tests/integration/test_light_control_action.py": 22.44, - "tests/integration/test_light_dim_relative_action.py": 16.57, - "tests/integration/test_light_effect_zero_brightness.py": 32.2, - "tests/integration/test_light_initial_state.py": 18.42, - "tests/integration/test_light_toggle_action.py": 16.23, - "tests/integration/test_lock_automations.py": 28.17, - "tests/integration/test_logger_buffered_recursion_guard.py": 23.52, - "tests/integration/test_loop_disable_enable.py": 19.4, - "tests/integration/test_loop_interval_decoupling.py": 27.32, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.47, - "tests/integration/test_lvgl_headless_render.py": 58.56, - "tests/integration/test_micros_to_millis.py": 21.91, - "tests/integration/test_multi_click_trigger.py": 25.74, - "tests/integration/test_multi_device_preferences.py": 24.1, - "tests/integration/test_noise_encryption_key_protection.py": 22.54, - "tests/integration/test_object_id_api_verification.py": 14.59, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 12.88, - "tests/integration/test_object_id_no_friendly_name.py": 40.23, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 48.98, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 44.62, - "tests/integration/test_online_image_bmp.py": 8.46, - "tests/integration/test_oversized_payloads.py": 41.29, - "tests/integration/test_preference_key_stability.py": 33.97, - "tests/integration/test_runtime_stats.py": 23.77, - "tests/integration/test_safe_mode_loop_runs.py": 7.96, - "tests/integration/test_scheduler_blocking_warning.py": 45.2, - "tests/integration/test_scheduler_bulk_cleanup.py": 16.2, - "tests/integration/test_scheduler_defer_cancel.py": 19.87, - "tests/integration/test_scheduler_defer_cancel_regular.py": 15.93, - "tests/integration/test_scheduler_defer_fifo_simple.py": 14.12, - "tests/integration/test_scheduler_defer_stress.py": 24.55, - "tests/integration/test_scheduler_heap_stress.py": 19.84, - "tests/integration/test_scheduler_internal_id_no_collision.py": 18.42, - "tests/integration/test_scheduler_interval_reschedule.py": 22.37, - "tests/integration/test_scheduler_interval_zero_coerced.py": 3.69, - "tests/integration/test_scheduler_null_name.py": 22.08, - "tests/integration/test_scheduler_numeric_id_test.py": 18.45, - "tests/integration/test_scheduler_pool.py": 16.04, - "tests/integration/test_scheduler_rapid_cancellation.py": 4.32, - "tests/integration/test_scheduler_recursive_timeout.py": 24.06, - "tests/integration/test_scheduler_removed_item_race.py": 24.2, - "tests/integration/test_scheduler_self_keyed.py": 19.48, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 22.9, - "tests/integration/test_scheduler_string_test.py": 18.13, - "tests/integration/test_script_array_params.py": 8.06, - "tests/integration/test_script_delay_params.py": 13.13, - "tests/integration/test_script_queued.py": 17.67, - "tests/integration/test_script_queued_idle_loop.py": 16.33, - "tests/integration/test_script_wait_on_boot.py": 14.01, - "tests/integration/test_sdl_headless_screenshot.py": 12.91, - "tests/integration/test_select_stringref_trigger.py": 17.66, - "tests/integration/test_sensor_filters_delta.py": 25.94, - "tests/integration/test_sensor_filters_ring_buffer.py": 17.25, - "tests/integration/test_sensor_filters_sliding_window.py": 44.9, - "tests/integration/test_sensor_filters_value_list.py": 16.0, - "tests/integration/test_sensor_raw_state.py": 42.47, - "tests/integration/test_sensor_timeout_filter.py": 23.58, - "tests/integration/test_set_internal_at_boot.py": 21.55, - "tests/integration/test_snapshot_display.py": 7.58, - "tests/integration/test_socket_wake_gate_tcp.py": 15.76, - "tests/integration/test_status_flags.py": 37.76, - "tests/integration/test_strftime_to.py": 19.69, - "tests/integration/test_syslog.py": 21.44, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 16.98, - "tests/integration/test_template_climate_basic.py": 20.51, - "tests/integration/test_template_climate_custom_modes.py": 16.94, - "tests/integration/test_template_climate_nonoptimistic.py": 19.28, - "tests/integration/test_template_climate_on_control_ordering.py": 15.47, - "tests/integration/test_template_climate_publish_all_fields.py": 18.9, - "tests/integration/test_template_climate_sensor_push.py": 14.96, - "tests/integration/test_template_climate_set_actions.py": 16.04, - "tests/integration/test_template_climate_two_point_temperature.py": 23.72, - "tests/integration/test_template_text_save.py": 15.49, - "tests/integration/test_text_command.py": 14.21, - "tests/integration/test_text_sensor_raw_state.py": 16.32, - "tests/integration/test_uart_mock_ld2410.py": 44.46, - "tests/integration/test_uart_mock_ld2412.py": 85.51, - "tests/integration/test_uart_mock_ld2420.py": 33.99, - "tests/integration/test_uart_mock_ld2450.py": 25.3, - "tests/integration/test_uart_mock_modbus.py": 522.13, - "tests/integration/test_udp.py": 13.7, - "tests/integration/test_use_address_runtime.py": 33.8, - "tests/integration/test_valve_control_action.py": 13.0, - "tests/integration/test_varint_five_byte_device_id.py": 25.11, - "tests/integration/test_wait_until_mid_loop_timing.py": 22.73, - "tests/integration/test_wait_until_on_boot.py": 10.45, - "tests/integration/test_wait_until_ordering.py": 3.64, - "tests/integration/test_wait_until_reentrant_restart.py": 18.73, - "tests/integration/test_wake_loop_forces_phase_b.py": 22.85, - "tests/integration/test_water_heater_template.py": 31.06 + "tests/integration/test_action_concurrent_reentry.py": 31.66, + "tests/integration/test_addressable_light_transition.py": 40.49, + "tests/integration/test_alarm_control_panel_state_transitions.py": 28.84, + "tests/integration/test_api_action_metadata.py": 20.54, + "tests/integration/test_api_action_responses.py": 35.05, + "tests/integration/test_api_action_timeout.py": 25.85, + "tests/integration/test_api_conditional_memory.py": 33.44, + "tests/integration/test_api_custom_services.py": 21.85, + "tests/integration/test_api_decode_wire_types.py": 38.03, + "tests/integration/test_api_empty_message_roundtrip.py": 20.53, + "tests/integration/test_api_encode_boundaries.py": 28.74, + "tests/integration/test_api_get_time_response_timezone.py": 26.53, + "tests/integration/test_api_homeassistant.py": 38.17, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 21.45, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 20.86, + "tests/integration/test_api_list_entities_backpressure.py": 24.14, + "tests/integration/test_api_message_size_batching.py": 32.91, + "tests/integration/test_api_reboot_timeout.py": 21.49, + "tests/integration/test_api_string_lambda.py": 22.48, + "tests/integration/test_api_vv_logging.py": 25.08, + "tests/integration/test_api_zero_psk_provisioning.py": 40.67, + "tests/integration/test_areas_and_devices.py": 26.02, + "tests/integration/test_automation_wait_actions.py": 23.49, + "tests/integration/test_automations.py": 49.82, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 27.22, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 23.85, + "tests/integration/test_binary_sensor_invalidate_state.py": 23.49, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 14.73, + "tests/integration/test_build_info.py": 23.7, + "tests/integration/test_camera_mock.py": 16.57, + "tests/integration/test_climate_control_action.py": 25.09, + "tests/integration/test_climate_custom_modes.py": 26.78, + "tests/integration/test_continuation_actions.py": 20.51, + "tests/integration/test_cover_control_action.py": 24.95, + "tests/integration/test_crc8_helper.py": 17.19, + "tests/integration/test_device_id_in_state.py": 58.05, + "tests/integration/test_duplicate_entities.py": 30.23, + "tests/integration/test_entity_icon.py": 30.68, + "tests/integration/test_entity_missing_state.py": 27.81, + "tests/integration/test_fan_turn_on_action.py": 23.22, + "tests/integration/test_fnv1_hash_object_id.py": 25.75, + "tests/integration/test_fnv1a_hash.py": 3.64, + "tests/integration/test_gpio_expander_cache.py": 22.24, + "tests/integration/test_host_logger_thread_safety.py": 20.82, + "tests/integration/test_host_mode_basic.py": 4.96, + "tests/integration/test_host_mode_batch_delay.py": 24.14, + "tests/integration/test_host_mode_climate_basic_state.py": 27.28, + "tests/integration/test_host_mode_climate_control.py": 28.31, + "tests/integration/test_host_mode_empty_string_options.py": 27.5, + "tests/integration/test_host_mode_entity_fields.py": 30.67, + "tests/integration/test_host_mode_fan_preset.py": 24.14, + "tests/integration/test_host_mode_many_entities.py": 51.6, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 31.89, + "tests/integration/test_host_mode_noise_encryption.py": 40.77, + "tests/integration/test_host_mode_reconnect.py": 12.62, + "tests/integration/test_host_mode_sensor.py": 22.41, + "tests/integration/test_host_ota.py": 110.14, + "tests/integration/test_host_preferences.py": 24.78, + "tests/integration/test_host_preferences_suspend_resume.py": 22.95, + "tests/integration/test_improv_serial_uart.py": 18.2, + "tests/integration/test_large_message_batching.py": 39.36, + "tests/integration/test_legacy_area.py": 21.25, + "tests/integration/test_legacy_climate_compat.py": 25.65, + "tests/integration/test_legacy_fan_compat.py": 23.25, + "tests/integration/test_light_automations.py": 27.09, + "tests/integration/test_light_binary_effect_off_phase.py": 26.9, + "tests/integration/test_light_calls.py": 31.49, + "tests/integration/test_light_constant_brightness.py": 31.64, + "tests/integration/test_light_control_action.py": 24.78, + "tests/integration/test_light_dim_relative_action.py": 23.69, + "tests/integration/test_light_effect_zero_brightness.py": 25.32, + "tests/integration/test_light_initial_state.py": 24.22, + "tests/integration/test_light_restore_state.py": 23.08, + "tests/integration/test_light_resume_effect.py": 30.38, + "tests/integration/test_light_toggle_action.py": 24.42, + "tests/integration/test_light_transition_state_publish_interval.py": 71.09, + "tests/integration/test_lock_automations.py": 21.54, + "tests/integration/test_logger_buffered_recursion_guard.py": 12.66, + "tests/integration/test_loop_disable_enable.py": 22.87, + "tests/integration/test_loop_interval_decoupling.py": 36.3, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 25.88, + "tests/integration/test_lvgl_headless_render.py": 95.64, + "tests/integration/test_micros_to_millis.py": 27.1, + "tests/integration/test_multi_click_trigger.py": 23.09, + "tests/integration/test_multi_device_preferences.py": 28.62, + "tests/integration/test_noise_encryption_key_protection.py": 32.04, + "tests/integration/test_object_id_api_verification.py": 36.37, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 22.4, + "tests/integration/test_object_id_no_friendly_name.py": 42.47, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 50.34, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 5.45, + "tests/integration/test_online_image_bmp.py": 43.63, + "tests/integration/test_oversized_payloads.py": 52.45, + "tests/integration/test_preference_key_stability.py": 26.26, + "tests/integration/test_runtime_stats.py": 28.11, + "tests/integration/test_safe_mode_loop_runs.py": 10.73, + "tests/integration/test_scheduler_blocking_warning.py": 53.72, + "tests/integration/test_scheduler_bulk_cleanup.py": 21.74, + "tests/integration/test_scheduler_defer_cancel.py": 24.06, + "tests/integration/test_scheduler_defer_cancel_regular.py": 21.48, + "tests/integration/test_scheduler_defer_fifo_simple.py": 23.68, + "tests/integration/test_scheduler_defer_stress.py": 23.57, + "tests/integration/test_scheduler_heap_stress.py": 23.12, + "tests/integration/test_scheduler_internal_id_no_collision.py": 22.64, + "tests/integration/test_scheduler_interval_reschedule.py": 20.29, + "tests/integration/test_scheduler_interval_zero_coerced.py": 20.9, + "tests/integration/test_scheduler_null_name.py": 20.45, + "tests/integration/test_scheduler_numeric_id_test.py": 23.77, + "tests/integration/test_scheduler_pool.py": 24.07, + "tests/integration/test_scheduler_rapid_cancellation.py": 25.39, + "tests/integration/test_scheduler_recursive_timeout.py": 5.31, + "tests/integration/test_scheduler_removed_item_race.py": 21.95, + "tests/integration/test_scheduler_self_keyed.py": 5.81, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 21.44, + "tests/integration/test_scheduler_string_test.py": 24.38, + "tests/integration/test_script_array_params.py": 16.92, + "tests/integration/test_script_delay_params.py": 14.32, + "tests/integration/test_script_queued.py": 25.32, + "tests/integration/test_script_queued_idle_loop.py": 19.93, + "tests/integration/test_script_wait_on_boot.py": 23.34, + "tests/integration/test_sdl_headless_screenshot.py": 26.0, + "tests/integration/test_select_stringref_trigger.py": 22.38, + "tests/integration/test_sensor_filters_delta.py": 25.72, + "tests/integration/test_sensor_filters_ring_buffer.py": 24.23, + "tests/integration/test_sensor_filters_sliding_window.py": 70.81, + "tests/integration/test_sensor_filters_value_list.py": 22.96, + "tests/integration/test_sensor_raw_state.py": 43.36, + "tests/integration/test_sensor_timeout_filter.py": 22.93, + "tests/integration/test_set_internal_at_boot.py": 23.86, + "tests/integration/test_snapshot_display.py": 26.76, + "tests/integration/test_socket_wake_gate_tcp.py": 21.23, + "tests/integration/test_status_flags.py": 35.38, + "tests/integration/test_strftime_to.py": 22.15, + "tests/integration/test_syslog.py": 28.45, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 24.28, + "tests/integration/test_template_climate_basic.py": 20.56, + "tests/integration/test_template_climate_custom_modes.py": 25.65, + "tests/integration/test_template_climate_nonoptimistic.py": 19.89, + "tests/integration/test_template_climate_on_control_ordering.py": 21.98, + "tests/integration/test_template_climate_publish_all_fields.py": 25.12, + "tests/integration/test_template_climate_sensor_push.py": 25.02, + "tests/integration/test_template_climate_set_actions.py": 21.45, + "tests/integration/test_template_climate_two_point_temperature.py": 24.62, + "tests/integration/test_template_text_save.py": 24.66, + "tests/integration/test_template_time.py": 18.48, + "tests/integration/test_text_command.py": 23.93, + "tests/integration/test_text_sensor_raw_state.py": 22.68, + "tests/integration/test_uart_mock_ld2410.py": 60.58, + "tests/integration/test_uart_mock_ld2412.py": 79.12, + "tests/integration/test_uart_mock_ld2420.py": 40.95, + "tests/integration/test_uart_mock_ld2450.py": 30.8, + "tests/integration/test_uart_mock_modbus.py": 554.44, + "tests/integration/test_udp.py": 9.61, + "tests/integration/test_use_address_runtime.py": 29.41, + "tests/integration/test_valve_control_action.py": 21.9, + "tests/integration/test_varint_five_byte_device_id.py": 23.6, + "tests/integration/test_wait_until_mid_loop_timing.py": 21.89, + "tests/integration/test_wait_until_on_boot.py": 22.57, + "tests/integration/test_wait_until_ordering.py": 21.27, + "tests/integration/test_wait_until_reentrant_restart.py": 18.84, + "tests/integration/test_wake_loop_forces_phase_b.py": 22.68, + "tests/integration/test_water_heater_template.py": 41.77 } diff --git a/tests/integration/test_light_resume_effect.py b/tests/integration/test_light_resume_effect.py new file mode 100644 index 00000000000..dce5feb9ab5 --- /dev/null +++ b/tests/integration/test_light_resume_effect.py @@ -0,0 +1,147 @@ +"""Integration test verifying light effect restore on/off cycles. + +Tests that when resume_effect is enabled, the previously active effect +is restored when the light is turned back on without explicit effect/color +parameters. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import ColorMode, EntityState, LightInfo, LightState +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_resume_effect( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """When resume_effect is enabled, effect persists after off/on cycle.""" + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + light_resume = next(e for e in entities if e.object_id == "test_light_resume") + light_no_resume = next( + e for e in entities if e.object_id == "test_light_no_resume" + ) + + state_futures: dict[int, asyncio.Future[LightState]] = {} + + def on_state(state: EntityState) -> None: + if isinstance(state, LightState) and state.key in state_futures: + future = state_futures[state.key] + if not future.done(): + future.set_result(state) + + # Drain initial state burst + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + async def send_and_wait( + light: LightInfo, timeout: float = 5.0, **kwargs: Any + ) -> LightState: + """Send a light command and wait for the matching state response.""" + state_futures[light.key] = asyncio.get_running_loop().create_future() + client.light_command(key=light.key, **kwargs) + return await asyncio.wait_for(state_futures[light.key], timeout=timeout) + + # Test 1: Start with Pulse effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + # Test 2: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 3: Turn on without specifying effect — should restore Pulse Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Pulse Effect", ( + "Effect should be restored when turning on without explicit effect parameter" + ) + + # Test 4: Switch to Strobe effect + state = await send_and_wait(light_resume, effect="Strobe Effect") + assert state.effect == "Strobe Effect" + + # Test 5: Turn off + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + # Test 6: Turn on without effect — should restore Strobe Effect + state = await send_and_wait(light_resume, state=True) + assert state.state is True + assert state.effect == "Strobe Effect", ( + "Effect should be restored to Strobe after turning on" + ) + + # Test 7: Explicitly set effect to None while turning on — should not restore + state = await send_and_wait(light_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_resume, state=True, effect="None") + assert state.state is True + assert state.effect == "None", ( + "Explicit effect=None should override restoration" + ) + + # Test 8: Turn off, then on with explicit effect — should use explicit effect + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + assert state.effect == "Pulse Effect" + + # Test 9: a turn-on that asks for something specific does not restore, and the + # effect it replaced must not come back on a later plain off/on + state = await send_and_wait(light_resume, state=False) + assert state.state is False + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None", "A turn-on with brightness should not restore" + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "An effect dropped on an earlier cycle must not return" + ) + + # Test 10: a plain turn-on sent to a light that is already on never starts the + # remembered effect + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait(light_resume, state=True, brightness=0.5) + assert state.effect == "None" + state = await send_and_wait(light_resume, state=True) + assert state.effect == "None", ( + "A lit light must not pick up the remembered effect" + ) + + # Test 11: a turn-on that names a color mode is not plain, so it does not restore + state = await send_and_wait(light_resume, state=True, effect="Pulse Effect") + state = await send_and_wait(light_resume, state=False) + state = await send_and_wait( + light_resume, state=True, color_mode=ColorMode.BRIGHTNESS + ) + assert state.effect == "None", "A turn-on with a color mode should not restore" + + # Test 12: Turn on effect, then off, then on without effect — should not restore Pulse Effect + state = await send_and_wait(light_no_resume, state=True, effect="Pulse Effect") + assert state.state is True + assert state.effect == "Pulse Effect" + + state = await send_and_wait(light_no_resume, state=False) + assert state.state is False + + state = await send_and_wait(light_no_resume, state=True) + assert state.state is True + assert state.effect == "None", ( + "Effect should NOT be restored when resume_effect=false" + ) diff --git a/tests/integration/test_template_time.py b/tests/integration/test_template_time.py new file mode 100644 index 00000000000..c0a03fe2352 --- /dev/null +++ b/tests/integration/test_template_time.py @@ -0,0 +1,49 @@ +"""Integration test for the template time platform.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState, TextSensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Timestamps are exposed as text sensors (rather than 32-bit float sensor states, +# which cannot represent a UNIX epoch exactly) so the exact value can be checked. +EXPECTED_STATES = { + "fixed_timestamp": "1700000000", + "offset_timestamp": "1700003600", + "empty_time_valid": "invalid", +} + + +@pytest.mark.asyncio +async def test_template_time( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Verify the template time platform evaluates its lambda on demand.""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + + key_to_name = build_key_to_entity_mapping(entities, list(EXPECTED_STATES)) + events = {name: asyncio.Event() for name in EXPECTED_STATES} + + def on_state(state: EntityState) -> None: + if isinstance(state, TextSensorState) and not state.missing_state: + name = key_to_name.get(state.key) + if name is not None and state.state == EXPECTED_STATES[name]: + events[name].set() + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + for name, event in events.items(): + try: + await asyncio.wait_for(event.wait(), timeout=3.0) + except TimeoutError: + pytest.fail(f"Timeout waiting for {name} to report its expected value") diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 4903f1a88ad..c3f30b8ed17 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -250,6 +250,82 @@ def test_main_all_tests_should_run( assert len(batch["components"]) > 0 assert isinstance(batch["needs_idf"], bool) assert isinstance(batch["needs_nrf"], bool) + assert isinstance(batch["needs_arduino8266"], bool) + + +def test_main_batch_flags_count_variant_tests( + mock_determine_integration_tests: Mock, + mock_should_run_clang_tidy: Mock, + mock_should_run_clang_format: Mock, + mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, + mock_should_run_device_builder: Mock, + mock_esp32_platformio_components_to_test: Mock, + mock_esp8266_native_components_to_test: Mock, + mock_changed_files: Mock, + mock_determine_cpp_unit_tests: Mock, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The compile stage builds test-..yaml too, so a + component tested on esp8266 only by a variant still needs the toolchain.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + mock_determine_integration_tests.return_value = (False, []) + mock_should_run_clang_tidy.return_value = False + mock_should_run_clang_format.return_value = False + mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False + mock_should_run_device_builder.return_value = False + mock_esp32_platformio_components_to_test.return_value = [] + mock_esp8266_native_components_to_test.return_value = [] + mock_determine_cpp_unit_tests.return_value = (False, []) + mock_changed_files.return_value = ["esphome/components/safe_mode/__init__.py"] + + def platforms(component: str, *, base_only: bool = True) -> set[str]: + return set() if base_only else {"esp8266-ard"} + + with ( + patch("sys.argv", ["determine-jobs.py"]), + patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False), + patch.object( + determine_jobs, "get_changed_components", return_value=["safe_mode"] + ), + patch.object( + determine_jobs, + "filter_component_and_test_files", + side_effect=lambda f: f.startswith("esphome/components/"), + ), + patch.object( + determine_jobs, + "get_components_with_dependencies", + return_value=["safe_mode"], + ), + patch.object(determine_jobs, "_component_has_tests", return_value=True), + patch.object( + determine_jobs, + "detect_memory_impact_config", + return_value={"should_run": "false"}, + ), + patch.object( + determine_jobs, + "create_intelligent_batches", + return_value=([["safe_mode"]], {}), + ), + patch.object( + determine_jobs, "get_component_test_platforms", side_effect=platforms + ), + ): + determine_jobs.main() + + output = json.loads(capsys.readouterr().out) + assert output["component_test_batches"] == [ + { + "components": "safe_mode", + "needs_idf": False, + "needs_nrf": False, + "needs_arduino8266": True, + } + ] def test_main_no_tests_should_run( @@ -1530,6 +1606,7 @@ def test_detect_memory_impact_config_with_common_platform(tmp_path: Path) -> Non assert set(result["components"]) == {"wifi", "api"} assert result["platform"] == "esp32-idf" # Common platform assert result["use_merged_config"] == "true" + assert result["needs_arduino8266"] is False @pytest.mark.usefixtures("mock_target_branch_dev") @@ -1634,6 +1711,8 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None: assert result["platform"] == "esp8266-ard" assert result["components"] == ["logger"] assert result["use_merged_config"] == "true" + # The esp8266 build is native, so the job restores that toolchain + assert result["needs_arduino8266"] is True @pytest.mark.usefixtures("mock_target_branch_dev") @@ -3212,13 +3291,13 @@ def test_esp8266_native_components_full_list_on_infra_change(changed: str) -> No @pytest.mark.parametrize( ("changed_files", "dependency_closure", "expected"), [ - # Tested component changed -- narrow to the intersection. + # A tested component alone does not schedule this job: the component + # matrix already compiles its esp8266 fixtures with this toolchain. ( ["esphome/components/mqtt/mqtt_client.cpp"], ["mqtt", "json"], - ["mqtt"], + [], ), - # Components outside the test set return an empty list (job skipped). ( ["esphome/components/wifi/wifi_component.cpp"], ["wifi", "network"], @@ -3236,7 +3315,7 @@ def test_esp8266_native_components_to_test_narrowing( dependency_closure: list[str], expected: list[str], ) -> None: - """Component changes narrow the native-ESP8266 test list.""" + """Only a native-build change schedules the native-ESP8266 job.""" with ( patch.object(determine_jobs, "changed_files", return_value=changed_files), patch.object( diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 8f82a121c61..ded7ef0a722 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -2143,10 +2143,7 @@ def test_no_orphan_integration_fixtures() -> None: fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve() fixtures = list(fixtures_dir.glob("*.yaml")) assert fixtures, f"no fixtures found under {fixtures_dir}" - # cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead - orphans = [ - f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping - ] + orphans = [f.stem for f in fixtures if f.stem not in mapping] assert not orphans, f"fixtures invisible to CI test selection: {orphans}" diff --git a/tests/test_build_components/build_components_base.esp8266-ard.yaml b/tests/test_build_components/build_components_base.esp8266-ard.yaml index 1e2d6143921..a20aa573aec 100644 --- a/tests/test_build_components/build_components_base.esp8266-ard.yaml +++ b/tests/test_build_components/build_components_base.esp8266-ard.yaml @@ -4,6 +4,7 @@ esphome: esp8266: board: d1_mini_pro + toolchain: arduino logger: level: VERY_VERBOSE diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index c0c5c3e28c6..9a8a2e4448d 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -715,7 +715,7 @@ def test_write_project_libraries_and_variant( ) _set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS") - with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"): + with caplog.at_level(logging.DEBUG, logger="esphome.build_helpers.ninja_gen"): content = _write_ninja( paths, libraries=[library, headers_only], ccache="/cc/ccache" ) @@ -727,6 +727,9 @@ def test_write_project_libraries_and_variant( assert "libHeadersOnly.a" not in content assert "Library HeadersOnly has no source files" in caplog.text assert " flags = -DMYLIB=1" in content + # A library's own include dirs lead its compile lines + assert " own_includes = -I" in content + assert "$own_includes $cxxflags $flags" in content assert "-lalgobsec" in content # Library link flags reach the firmware link line; .cc compiles as C++ assert "-Wl,--wrap=malloc" in content diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py new file mode 100644 index 00000000000..d7fa73e897d --- /dev/null +++ b/tests/unit_tests/build_gen/test_host.py @@ -0,0 +1,368 @@ +"""Tests for esphome.build_gen.host (the host ninja generator).""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.arduino.library import ArduinoLibrary +from esphome.build_gen import host as build_gen +from esphome.build_helpers.ninja import escape as _e, quote_path as _q +from esphome.build_helpers.ninja_gen import Flag +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST +from esphome.core import CORE, EsphomeError, Library +from esphome.host.toolchain import PROGRAM_NAME, HostCompilers + +COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",)) + + +@pytest.fixture(autouse=True) +def _core(tmp_path: Path) -> None: + CORE.build_path = tmp_path + CORE.name = "dev" + CORE.cpp_standard = "gnu++20" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST} + + +def _make_src(tmp_path: Path, *names: str) -> Path: + src = tmp_path / "src" + for name in names: + path = src / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + return src + + +def _render(ccache: str | None = None) -> tuple[bool, str]: + changed = build_gen.write_project(COMPILERS, ccache) + ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja" + return changed, ninja.read_text() + + +@pytest.mark.parametrize( + ("entries", "compile_flags", "link_flags"), + [ + ( + ["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"], + [("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)], + [], + ), + (["-std=gnu++20"], [("-std=gnu++20",)], []), + ( + ["-lssl", "-L/opt/lib", "-Wl,--gc-sections"], + [], + [("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)], + ), + # A link-only flag travels with its argument + ( + ["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"], + [], + [ + ("-Xlinker", "--wrap=malloc"), + ("-z", "noexecstack"), + ("-framework", "Cocoa"), + ], + ), + # Both lines, as SCons routes unclassified flags + ( + ["-g -O2", "-fsanitize=address", "-pthread", "--coverage"], + [ + ("-g",), + ("-O2",), + ("-fsanitize=address",), + ("-pthread",), + ("--coverage",), + ], + [ + ("-g",), + ("-O2",), + ("-fsanitize=address",), + ("-pthread",), + ("--coverage",), + ], + ), + # A flag with a path argument travels with it, glued on or not + ( + ["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"], + [("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)], + [], + ), + ], +) +def test_split_flags( + entries: list[str], compile_flags: list[Flag], link_flags: list[Flag] +) -> None: + flags = build_gen.parse_flags(entries, "esphome") + assert build_gen.split_flags(flags) == (compile_flags, link_flags) + + +@pytest.mark.parametrize( + "entries", + [ + # The flag is the last token of its entry + ["-g -include"], + # Entries are a set: the next entry is not this flag's argument + ["-include", "pre.h"], + ["-framework", "-pthread"], + # A path never starts with "-" + ["-include -pthread"], + ], +) +def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None: + with pytest.raises(EsphomeError, match="with no argument; write the flag"): + build_gen.parse_flags(entries, "esphome") + + +def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None: + assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [ + ("-Xlinker", "--wrap=malloc") + ] + + +def test_flag_lists_route_the_standard() -> None: + """cpp_standard wins for C++; C never sees a C++ standard.""" + CORE.build_flags = { + "-DUSE_HOST", + "-Iinc", + "-Wl,-x", + "-g", + "-lssl", + "-std=c++17", + "-std=gnu17", + } + CORE.cxx_build_flags = {"-Wno-volatile"} + cflags, cxxflags, link_flags = build_gen._flag_lists() + # The relative include is anchored at the build path + inc = f"-I{CORE.build_path / 'inc'}" + assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"] + assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"] + assert link_flags == ["-Wl,-x", "-g", "-lssl"] + + +def test_flag_lists_without_cpp_standard_keeps_user_std() -> None: + CORE.cpp_standard = None + CORE.build_flags = {"-std=c++17"} + cflags, cxxflags, _link = build_gen._flag_lists() + assert cflags == [] + assert cxxflags == ["-std=c++17"] + + +def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None: + CORE.build_flags = {"-g", "-DUSE_HOST"} + CORE.build_unflags = {"-g", "-Onope"} + with caplog.at_level(logging.WARNING): + cflags, cxxflags, link_flags = build_gen._flag_lists() + assert "-g" not in cflags + cxxflags + link_flags + assert "-DUSE_HOST" in cflags + assert "build_unflags entries matched no build flag: -Onope" in caplog.text + + +def test_resolve_host_libraries_without_libraries() -> None: + with patch("esphome.arduino.library.resolve_libraries") as resolve: + assert build_gen._resolve_host_libraries() == [] + resolve.assert_not_called() + + +def test_resolve_host_libraries_is_framework_less() -> None: + CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0")) + lib = ArduinoLibrary(name="lvgl") + with patch( + "esphome.arduino.library.resolve_libraries", return_value=[lib] + ) as resolve: + assert build_gen._resolve_host_libraries() == [lib] + resolve.assert_called_once_with( + None, + pio_platform="native", + board_mcu="host", + cache_key="host", + framework=None, + manifest_optional=True, + ) + + +def test_write_project_requires_generated_sources(tmp_path: Path) -> None: + with pytest.raises(EsphomeError, match="Generated source directory .* is missing"): + build_gen.write_project(COMPILERS, None) + _make_src(tmp_path, "esphome.h") + with pytest.raises(EsphomeError, match="No source files found"): + build_gen.write_project(COMPILERS, None) + + +def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: + src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h") + CORE.build_flags = {"-DUSE_HOST", "-g"} + changed, ninja = _render(ccache="/usr/bin/ccache") + assert changed is True + # A compiler override's arguments follow the program + assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja + assert f"cxx = {_q('/usr/bin/g++')}" in ninja + assert f"ccache = {_q('/usr/bin/ccache')}" in ninja + assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja + assert ( + f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja + ) + assert "build obj/src/x.S.o: aspp " in ninja + assert "build obj/src/y.s.o: asm " in ninja + assert "h.h" not in ninja + # __FILE__ stays relative to the build path, as it was under PlatformIO, + # whether the source arrives absolute or rewritten by ccache + sep = os.sep + maps = ( + f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} " + f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}" + ) + assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja + assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja + # Assembly gets the defines and includes only + assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja + assert "linkflags = -g\n" in ninja + assert "libdirflags = \n" in ninja + assert "libflags = \n" in ninja + assert "rule ar" not in ninja + assert ( + f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o " + f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n" + ) in ninja + # Unchanged content reports no change so the compile DB can be reused + changed, _ = _render(ccache="/usr/bin/ccache") + assert changed is False + + +def test_write_project_without_ccache(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + _changed, ninja = _render() + assert "ccache = \n" in ninja + + +def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + lib_dir = tmp_path / "opt" / "lib" + # Forward slashes: build flags are lexed like a POSIX shell line, which + # reads a backslash as an escape + CORE.build_flags = { + f"-L{lib_dir.as_posix()}", + "-lcrypto", + "-Wl,-framework,Security", + } + _changed, ninja = _render() + assert "linkflags = -Wl,-framework,Security\n" in ninja + assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja + assert "libflags = -lcrypto\n" in ninja + + +def _libraries(tmp_path: Path) -> list[ArduinoLibrary]: + lib_dir = tmp_path / "libs" + for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"): + path = lib_dir / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + archived = ArduinoLibrary( + name="foo", + sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"], + include_dirs=[lib_dir / "foo/src"], + flags=["-DFOO=1"], + link_dirs=[lib_dir / "foo/lib"], + link_libs=["bar"], + link_flags=["-Wl,--gc-sections"], + ) + direct = ArduinoLibrary( + name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False + ) + header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"]) + return [archived, direct, header_only] + + +def test_write_project_with_libraries( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + src = _make_src(tmp_path, "main.cpp") + libs = _libraries(tmp_path) + with ( + patch.object(build_gen, "_resolve_host_libraries", return_value=libs), + patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool, + patch.object(build_gen.sys, "platform", "linux"), + caplog.at_level(logging.DEBUG), + ): + _changed, ninja = _render() + find_tool.assert_called_once_with("AR", ("ar",)) + assert ( + f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp" + in ninja + ) + lib_dir = tmp_path / "libs" + # Every library's include dir joins the global include path + assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja + assert "linkflags = -Wl,--gc-sections\n" in ninja + assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja + assert "libflags = -lbar\n" in ninja + # Library sources compile with the library's own flags, rooted at their + # common parent; its own include dirs lead the line so another library's + # header of the same name cannot shadow them + own = f"-I{_q(lib_dir / 'foo/src')}" + assert "$own_includes $cxxflags $flags" in ninja + assert ( + f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n" + f" own_includes = {own}\n flags = -DFOO=1\n" in ninja + ) + assert ( + f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n" + f" own_includes = {own}\n flags = -DFOO=1\n" in ninja + ) + assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja + # libArchive: false objects link directly; the archive is an order-only + # input wrapped in a group for GNU ld + assert ( + f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o " + "| libfoo.a\n" + " archives = -Wl,--start-group libfoo.a -Wl,--end-group\n" + ) in ninja + assert "Library hdr has no source files" in caplog.text + + +def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None: + _make_src(tmp_path, "main.cpp") + libs = _libraries(tmp_path) + with ( + patch.object(build_gen, "_resolve_host_libraries", return_value=libs), + patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"), + patch.object(build_gen.sys, "platform", "darwin"), + ): + _changed, ninja = _render() + assert " archives = libfoo.a\n" in ninja + assert "--start-group" not in ninja + + +def test_build_unflags_remove_a_flag_with_its_argument( + caplog: pytest.LogCaptureFixture, +) -> None: + """Only the named pair goes; the same path under another flag stays.""" + CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"} + CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"} + with caplog.at_level(logging.WARNING): + cflags, _cxxflags, link_flags = build_gen._flag_lists() + assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")] + assert link_flags == ["-g"] + assert "matched no build flag" not in caplog.text + + +def test_build_unflags_warn_when_nothing_matches( + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.build_flags = {"-g"} + CORE.build_unflags = {"-isystem inc", "-DNOPE"} + with caplog.at_level(logging.WARNING): + build_gen._flag_lists() + assert "matched no build flag: -DNOPE, -isystem " in caplog.text + + +def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None: + CORE.build_flags = {"-Iinc", "-g"} + CORE.build_unflags = {"-Iinc"} + with caplog.at_level(logging.WARNING): + cflags, _cxxflags, _link = build_gen._flag_lists() + assert cflags == ["-g"] + assert "matched no build flag" not in caplog.text diff --git a/tests/unit_tests/build_helpers/test_ccache.py b/tests/unit_tests/build_helpers/test_ccache.py index d50f80cc287..c6690915889 100644 --- a/tests/unit_tests/build_helpers/test_ccache.py +++ b/tests/unit_tests/build_helpers/test_ccache.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest from esphome.build_helpers import ccache +from esphome.build_helpers.pch import mark_pch_emitted from esphome.core import CORE @@ -135,3 +136,78 @@ def test_effective_ccache_basedir_prefers_user_value(tmp_path: Path) -> None: for bad in ("", "/", "a/b"): with patch.dict(os.environ, {"CCACHE_BASEDIR": bad}, clear=True): assert ccache.effective_ccache_basedir() == str(tmp_path.resolve()) + + +def test_resolve_absolute_ccache_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + with patch.object(ccache, "resolve_ccache_path", return_value="bin/ccache"): + resolved = ccache.resolve_absolute_ccache_path() + assert Path(resolved) == tmp_path / "bin" / "ccache" + with patch.object(ccache, "resolve_ccache_path", return_value=None): + assert ccache.resolve_absolute_ccache_path() is None + + +def test_ccache_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from esphome.core import CORE + + CORE.build_path = tmp_path / "build" + spec = ("ESPHOME_TEST_PREFIX", "test") + monkeypatch.setenv("ESPHOME_TEST_PREFIX", str(tmp_path / "cache")) + monkeypatch.setenv("CCACHE_NOHASHDIR", "false") + for key in ("CCACHE_DIR", "CCACHE_BASEDIR", "CCACHE_DEPEND"): + monkeypatch.delenv(key, raising=False) + # None means resolved and disabled + assert ccache.ccache_env(None, spec) == {} + env = ccache.ccache_env("/usr/bin/ccache", spec) + # User-set values are respected; the rest get defaults + assert "CCACHE_NOHASHDIR" not in env + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + + +def test_ccache_defaults_env_escapes_a_dollar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A literal $ is doubled, since ccache expands $VAR in its settings.""" + from esphome.core import CORE + + CORE.build_path = tmp_path / "a$b" + for key in ("CCACHE_DIR", "CCACHE_BASEDIR"): + monkeypatch.delenv(key, raising=False) + env = ccache.ccache_defaults_env(tmp_path / "c$d") + assert env["CCACHE_DIR"].endswith("c$$d") + assert env["CCACHE_BASEDIR"].endswith("a$$b") + + +def _pch_ccache_env(tmp_path: Path, environ: dict[str, str]) -> dict[str, str]: + CORE.build_path = tmp_path / "build" + spec = ("ESPHOME_TEST_PREFIX", "test") + environ = {"ESPHOME_TEST_PREFIX": str(tmp_path / "cache"), **environ} + with patch.dict(os.environ, environ, clear=True): + return ccache.ccache_env("/usr/bin/ccache", spec) + + +def test_ccache_env_includes_pch_settings(tmp_path: Path) -> None: + """A native build exports the ccache settings the pch needs.""" + mark_pch_emitted() + env = _pch_ccache_env(tmp_path, {}) + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" + + +def test_ccache_env_pch_disabled(tmp_path: Path) -> None: + env = _pch_ccache_env(tmp_path, {"ESPHOME_PCH_ENABLE": "0"}) + assert "CCACHE_SLOPPINESS" not in env + assert "CCACHE_PCH_EXTSUM" not in env + + +def test_ccache_env_respects_user_sloppiness(tmp_path: Path) -> None: + mark_pch_emitted() + env = _pch_ccache_env(tmp_path, {"CCACHE_SLOPPINESS": "locale"}) + # The user's tokens survive; the ones the pch needs are unioned on + assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros" + assert env["CCACHE_PCH_EXTSUM"] == "true" diff --git a/tests/unit_tests/build_helpers/test_idedata.py b/tests/unit_tests/build_helpers/test_idedata.py index 9a294b60221..1b396501e3d 100644 --- a/tests/unit_tests/build_helpers/test_idedata.py +++ b/tests/unit_tests/build_helpers/test_idedata.py @@ -757,3 +757,36 @@ def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None: ) mock_build.assert_not_called() assert data["cached"] is True + + +def test_warn_if_idedata_missing_is_silent_on_success( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + idedata.warn_if_idedata_missing(lambda: {"cc_path": "gcc"}) + assert "idedata" not in caplog.text + + +def test_warn_if_idedata_missing_warns_on_none( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + idedata.warn_if_idedata_missing(lambda: None) + assert "No idedata was generated for this build" in caplog.text + + +@pytest.mark.parametrize( + "error", [EsphomeError("db unusable"), ValueError("bad"), OSError("gone")] +) +def test_warn_if_idedata_missing_downgrades_failures( + caplog: pytest.LogCaptureFixture, error: Exception +) -> None: + """The firmware already built; a broken idedata must not fail the build.""" + + def broken() -> dict | None: + raise error + + with caplog.at_level(logging.DEBUG): + idedata.warn_if_idedata_missing(broken) + assert f"Could not generate idedata: {error}" in caplog.text + assert "Idedata failure detail" in caplog.text diff --git a/tests/unit_tests/build_helpers/test_native.py b/tests/unit_tests/build_helpers/test_native.py new file mode 100644 index 00000000000..57be9db26ac --- /dev/null +++ b/tests/unit_tests/build_helpers/test_native.py @@ -0,0 +1,34 @@ +"""Tests for esphome.build_helpers.native.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome.build_helpers import native +from esphome.const import Toolchain +from esphome.core import CORE + + +def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None: + """Options a native build drops are warned by name; consumed ones stay quiet.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.platformio_options = { + "lib_ignore": ["x"], + "board_build.filesystem": "littlefs", + } + with caplog.at_level(logging.WARNING): + native.warn_ignored_platformio_options({"lib_ignore"}) + assert "platformio_options->board_build.filesystem is ignored" in caplog.text + assert "native 'arduino' toolchain" in caplog.text + assert "lib_ignore" not in caplog.text + + +def test_warn_ignored_platformio_options_without_options( + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.platformio_options = None + with caplog.at_level(logging.WARNING): + native.warn_ignored_platformio_options(()) + assert caplog.text == "" diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py index 6f0bbda0b99..42782a1f058 100644 --- a/tests/unit_tests/build_helpers/test_ninja.py +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -141,3 +141,126 @@ def test_shell_token_windows_branch_uses_argv_rule() -> None: with patch.object(os, "name", "nt"): assert ninja_helper.shell_token("a b") == '"a b"' assert ninja_helper.shell_token("", force=True) == '""' + + +@pytest.mark.parametrize("char", ["|", "\n", "\r"]) +def test_escape_rejects_what_ninja_cannot_express(char: str) -> None: + with pytest.raises(EsphomeError, match="a ninja build file cannot express"): + ninja_helper.escape(f"src/a{char}b.cpp") + + +def test_escape_keeps_a_hash() -> None: + """A # is only a comment at the start of a ninja line.""" + assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp" + + +def test_absolute_tool_anchors_a_relative_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja" + + +def test_find_ninja_returns_an_absolute_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + with ( + patch("shutil.which", return_value="bin/ninja"), + patch.object(ninja_helper, "_ninja_runs", return_value=True), + ): + assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja" + + +@pytest.fixture +def compdb_dir(tmp_path: Path) -> Path: + build_dir = tmp_path / "build" + build_dir.mkdir() + (build_dir / "build.ninja").write_text("rule x\n") + return build_dir + + +def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None: + ninja = Path("ninja") + compdb = compdb_dir / "compile_commands.json" + stamp = compdb_dir / ".compile_commands.stamp" + with patch.object(ninja_helper, "write_compile_commands") as write: + # A rewritten manifest always regenerates + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True) + assert write.call_count == 1 + assert stamp.is_file() + # No compile DB yet + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 2 + compdb.write_text("[]") + # Fresh stamp: nothing to do + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 2 + # A manifest newer than the stamp (interrupted previous run) + os.utime(stamp, (1, 1)) + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 3 + # A missing stamp regenerates too + stamp.unlink() + ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False) + assert write.call_count == 4 + + +def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock: + return MagicMock(returncode=rc, stdout=stdout, stderr=stderr) + + +def test_write_compile_commands_success(compdb_dir: Path) -> None: + entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' + with patch("subprocess.run", return_value=_completed(stdout=entries)) as run: + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"}) + assert (compdb_dir / "compile_commands.json").read_text() == entries + # Every compile rule, so preprocessed assembly reaches the database too + assert run.call_args.args[0] == [ + "ninja", + "-C", + str(compdb_dir), + "-t", + "compdb", + "c", + "cxx", + "aspp", + "asm", + ] + assert run.call_args.kwargs["env"] == {"A": "1"} + + +@pytest.mark.parametrize( + ("result", "message"), + [ + (_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"), + # A parse failure names its cause, not the rule-name story + (_completed(stdout="not json"), "unparsable compile database.*not json"), + (_completed(stdout="[]"), "empty compile database"), + ], +) +def test_write_compile_commands_failures_drop_stale_db( + compdb_dir: Path, result: MagicMock, message: str +) -> None: + compdb = compdb_dir / "compile_commands.json" + compdb.write_text("[stale]") + with ( + patch("subprocess.run", return_value=result), + pytest.raises(EsphomeError, match=message), + ): + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) + assert not compdb.exists() + + +def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db( + compdb_dir: Path, +) -> None: + """The idedata cache is keyed on the DB's mtime.""" + entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]' + compdb = compdb_dir / "compile_commands.json" + compdb.write_text(entries) + os.utime(compdb, (1000, 1000)) + with patch("subprocess.run", return_value=_completed(stdout=entries)): + ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {}) + assert compdb.stat().st_mtime == 1000 diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py new file mode 100644 index 00000000000..e7f9b96af2e --- /dev/null +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -0,0 +1,57 @@ +"""Tests for esphome.build_helpers.ninja_gen.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from esphome.build_helpers import ninja_gen +from esphome.build_helpers.ninja_gen import Flag + + +@pytest.mark.parametrize( + ("flag", "anchored"), + [ + (("-Iinc",), ("-I{base}/inc",)), + (("-Llib",), ("-L{base}/lib",)), + (("-include", "pre.h"), ("-include", "{base}/pre.h")), + (("-isystem", "sys"), ("-isystem", "{base}/sys")), + # The glued spelling of a flag that takes a path + (("-isystemsys",), ("-isystem{base}/sys",)), + (("-includepre.h",), ("-include{base}/pre.h",)), + # An absolute operand is never changed + (("-I{base}/abs",), ("-I{base}/abs",)), + (("-include", "{base}/abs.h"), ("-include", "{base}/abs.h")), + # Not a path + (("-DUSE_HOST",), ("-DUSE_HOST",)), + (("-lssl",), ("-lssl",)), + (("-framework", "Cocoa"), ("-framework", "Cocoa")), + (("-I",), ("-I",)), + ], +) +def test_anchor_path_flag(tmp_path: Path, flag: Flag, anchored: Flag) -> None: + """Relative operands resolve from the build path, as under PlatformIO.""" + + def fill(tokens: Flag) -> Flag: + return tuple( + str(Path(tok.replace("{base}", str(tmp_path)))) + if "{base}" in tok and not tok.startswith("-") + else tok.replace("{base}/", f"{tmp_path}{os.sep}") + for tok in tokens + ) + + assert ninja_gen.anchor_path_flag(fill(flag), tmp_path) == fill(anchored) + + +def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: + for name in ("b.cpp", "a.c", "skip.cpp", "notes.txt", "sub/c.S"): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("") + assert ninja_gen.collect_sources(tmp_path, {"skip.cpp"}) == [ + tmp_path / "a.c", + tmp_path / "b.cpp", + tmp_path / "sub" / "c.S", + ] diff --git a/tests/unit_tests/components/host/__init__.py b/tests/unit_tests/components/host/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/host/test_init.py b/tests/unit_tests/components/host/test_init.py new file mode 100644 index 00000000000..d7662e0b0a2 --- /dev/null +++ b/tests/unit_tests/components/host/test_init.py @@ -0,0 +1,86 @@ +"""Tests for the host platform's native-toolchain wiring.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +import pytest + +from esphome.components import host +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + Toolchain, +) +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _core_data() -> None: + CORE.data[KEY_CORE] = {} + + +def test_schema_resolves_the_host_toolchain() -> None: + """The host serves exactly one toolchain; it is picked without config.""" + config = host.CONFIG_SCHEMA({}) + assert CORE.toolchain is Toolchain.HOST + assert CORE.using_native_toolchain + assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == PLATFORM_HOST + assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "host" + assert "mac_address" in config + + +def test_schema_rejects_the_platformio_toolchain() -> None: + """There is no PlatformIO backend left to honor --toolchain platformio.""" + CORE.toolchain = Toolchain.PLATFORMIO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'platformio' for host"): + host.CONFIG_SCHEMA({}) + + +def test_to_code_configures_the_native_build() -> None: + config = host.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF"}) + asyncio.run(host.to_code(config)) + assert "-DUSE_HOST" in CORE.build_flags + # The standard travels through cpp_standard, not a -std= build flag, + # so the generator can keep it off the C compile line + assert CORE.cpp_standard == "gnu++20" + assert not any(flag.startswith("-std=") for flag in CORE.build_flags) + assert not CORE.platformio_options + define_names = {define.name for define in CORE.defines} + assert {"ESPHOME_BOARD", "ESPHOME_VARIANT", "USE_ESPHOME_HOST_MAC_ADDRESS"} <= ( + define_names + ) + + +def test_run_compile_hook_claims_the_build() -> None: + config = {"esphome": {}} + with ( + patch.object(host.sys, "platform", "linux"), + patch("esphome.host.toolchain.run_compile", return_value=0) as run, + ): + assert host.run_compile(object(), config) is True + run.assert_called_once_with(config, CORE.verbose) + + +def test_run_compile_hook_raises_on_failure() -> None: + with ( + patch.object(host.sys, "platform", "linux"), + patch("esphome.host.toolchain.run_compile", return_value=2), + pytest.raises(EsphomeError, match="Host build failed"), + ): + host.run_compile(object(), {"esphome": {}}) + + +def test_run_compile_hook_refuses_windows() -> None: + """The host sources use POSIX interfaces; fail before building, by name.""" + with ( + patch.object(host.sys, "platform", "win32"), + patch("esphome.host.toolchain.run_compile") as run, + pytest.raises(EsphomeError, match="does not build on Windows"), + ): + host.run_compile(object(), {"esphome": {}}) + run.assert_not_called() diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 53035ad7136..cd6b3315178 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -1236,3 +1236,61 @@ async def test_finalize_comment_sanitization( # Newline must be replaced to prevent breaking out of comment assert "\n" not in comment_line assert "INJECTED_CODE" in comment_line # still visible but safe in comment + + +@pytest.mark.asyncio +async def test_sub_entities_creates_and_sets( + monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str] +) -> None: + """A configured key creates the entity, sets the parent before the setter and returns it.""" + calls: list[tuple[Any, ...]] = [] + created = MockObj("child") + + async def new_entity(conf: dict[str, Any], *args: Any, **kwargs: Any) -> MockObj: + calls.append((conf, args, kwargs)) + return created + + async def fake_register_parented(var: MockObj, parent: MockObj) -> None: + setup_test_environment.append(f"parent {var} {parent}") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fake_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {}}, parent=hub) + + assert await sub("child", hub.set_child, 1, step=2) is created + assert calls == [({}, (1,), {"step": 2})] + assert setup_test_environment == ["parent child hub", "hub->set_child(child)"] + + +@pytest.mark.asyncio +async def test_sub_entities_missing_key(setup_test_environment: list[str]) -> None: + """A missing key creates nothing and emits no code.""" + + async def new_entity(conf: dict[str, Any]) -> MockObj: + raise AssertionError("must not be called") + + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {}) + + assert await sub("child", hub.set_child) is None + assert setup_test_environment == [] + + +@pytest.mark.asyncio +async def test_sub_entities_without_parent( + monkeypatch: pytest.MonkeyPatch, setup_test_environment: list[str] +) -> None: + """Without a bound parent, register_parented is not called.""" + + async def fail_register_parented(var: MockObj, parent: MockObj) -> None: + raise AssertionError("must not be called") + + async def new_entity(conf: dict[str, Any]) -> MockObj: + return MockObj("child") + + monkeypatch.setattr(entity_helpers.cg, "register_parented", fail_register_parented) + hub = MockObj("hub", "->") + sub = entity_helpers.SubEntities(new_entity, {"child": {CONF_ID: "child"}}) + + assert await sub("child", hub.set_child) is not None + assert setup_test_environment == ["hub->set_child(child)"] diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py index 9f415344aea..ab11c41afb4 100644 --- a/tests/unit_tests/test_arduino8266_framework.py +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -112,17 +112,6 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None: assert env["CCACHE_DIR"] == "x" -def test_ccache_env(tmp_path: Path) -> None: - assert framework.ccache_env(None) == {} - with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True): - env = framework.ccache_env("/usr/bin/ccache") - # User-set values are respected; the rest get defaults - assert "CCACHE_NOHASHDIR" not in env - assert env["CCACHE_DEPEND"] == "1" - assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve()) - assert env["CCACHE_DIR"].endswith("ccache") - - def test_check_and_install_rejects_old_core(tmp_path: Path) -> None: """Calling the installer below the floor fails before any download.""" with pytest.raises(EsphomeError, match=">= 3.1.1"): @@ -148,18 +137,20 @@ def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None: assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"] -def test_ccache_env_accepts_a_preresolved_path() -> None: - """The caller resolves ccache once and threads it through; None means - resolved-and-disabled.""" - with patch.dict(os.environ, {}, clear=True): - assert framework.ccache_env(None) == {} - env = framework.ccache_env("/usr/bin/ccache") - assert env["CCACHE_DIR"].endswith("ccache") - - def test_toolchain_tool_layout(tmp_path: Path) -> None: """One owner for the bin/xtensa-lx106-elf- layout.""" tool = framework.toolchain_tool(tmp_path, "addr2line") assert tool.parent == tmp_path / "bin" assert tool.name.startswith("xtensa-lx106-elf-addr2line") assert (tool.suffix == ".exe") is (os.name == "nt") + + +def test_get_build_env_uses_the_arduino8266_ccache_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(tmp_path / "cache")) + monkeypatch.delenv("CCACHE_DIR", raising=False) + env = framework.get_build_env(tmp_path / "toolchain", "/usr/bin/ccache") + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + # None means resolved and disabled + assert "CCACHE_DIR" not in framework.get_build_env(tmp_path / "toolchain", None) diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index d552416adee..62863744181 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -10,13 +10,13 @@ from unittest.mock import MagicMock, patch import pytest from esphome.arduino8266 import framework, toolchain -from esphome.build_helpers.pch import mark_pch_emitted import esphome.config_validation as cv from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE, KEY_FRAMEWORK_VERSION, + Toolchain, ) from esphome.core import CORE, EsphomeError @@ -81,7 +81,7 @@ def test_run_compile_build_failure(tmp_path: Path) -> None: patch.object( toolchain.subprocess, "run", return_value=MagicMock(returncode=2) ) as mock_run, - patch.object(toolchain, "_write_compile_commands") as mock_compdb, + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, ): assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2 cmd = mock_run.call_args[0][0] @@ -101,7 +101,7 @@ def test_run_compile_success(tmp_path: Path) -> None: "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ) as mock_run, - patch.object(toolchain, "_write_compile_commands") as mock_compdb, + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, patch.object(toolchain, "_print_size_summary") as mock_size, patch.object(toolchain, "get_idedata") as mock_idedata, ): @@ -117,39 +117,12 @@ def test_run_compile_success(tmp_path: Path) -> None: # cwd, not -C, so ninja prints no "Entering directory" banner assert "-C" not in cmd assert call[1]["cwd"] is not None - mock_compdb.assert_called_once() + # An unchanged manifest is passed on, so the shared refresh can skip + assert mock_compdb.call_args.args[3] is False mock_size.assert_called_once() mock_idedata.assert_called_once() -def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None: - """An interrupted run can leave build.ninja newer than the compile DB; - mere existence must not skip regeneration.""" - build_dir = toolchain.get_build_dir() - build_dir.mkdir(parents=True, exist_ok=True) - (build_dir / "build.ninja").write_text("") - compdb = build_dir / "compile_commands.json" - compdb.write_text("[]") - os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2) - with ( - patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), - patch.object(framework, "get_build_env", return_value={}), - patch("esphome.build_gen.arduino8266.write_project", return_value=False), - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock( - returncode=0, stdout="ninja: no work to do.\n", stderr="" - ), - ), - patch.object(toolchain, "_write_compile_commands") as mock_compdb, - patch.object(toolchain, "_print_size_summary"), - patch.object(toolchain, "get_idedata"), - ): - assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 - mock_compdb.assert_called_once() - - def test_run_compile_missing_artifact_fails( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -165,7 +138,7 @@ def test_run_compile_missing_artifact_fails( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary") as mock_size, patch.object(toolchain, "get_idedata"), ): @@ -189,7 +162,7 @@ def test_run_compile_warns_when_idedata_fails( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary"), patch.object(toolchain, "get_idedata", return_value=None), ): @@ -197,63 +170,6 @@ def test_run_compile_warns_when_idedata_fails( assert "No idedata was generated for this build" in caplog.text -def test_write_compile_commands(tmp_path: Path) -> None: - build_dir = tmp_path / "build" - build_dir.mkdir() - entries = '[{"file": "a.cpp", "command": "cc"}]\n' - with patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=0, stdout=entries), - ): - toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) - assert (build_dir / "compile_commands.json").read_text() == entries - - -@pytest.mark.parametrize( - ("stdout", "match"), - [ - ("[]\n", "empty compile database"), - # A parse failure names its cause, not the rule-name story - ("not json", "unparsable compile database.*not json"), - ], -) -def test_write_compile_commands_bad_db_raises( - tmp_path: Path, stdout: str, match: str -) -> None: - """An empty or unparsable compile database fails the build with its - actual cause and drops any stale database.""" - build_dir = tmp_path / "build" - build_dir.mkdir() - (build_dir / "compile_commands.json").write_text("[stale]") - with ( - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=0, stdout=stdout), - ), - pytest.raises(EsphomeError, match=match), - ): - toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {}) - assert not (build_dir / "compile_commands.json").exists() - - -def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None: - """A failed compdb run must not leave a stale database behind.""" - stale = tmp_path / "compile_commands.json" - stale.write_text("[]") - with ( - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=1, stderr="boom"), - ), - pytest.raises(EsphomeError, match="compile_commands"), - ): - toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {}) - assert not stale.exists() - - def test_parse_app_size(tmp_path: Path) -> None: ld = tmp_path / "eagle.flash.4m.ld" ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n") @@ -343,7 +259,9 @@ def test_get_idedata_delegates(tmp_path: Path) -> None: "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"cc_path": "x"}, ) as mock_load, - patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"), + patch.object( + toolchain, "resolve_absolute_ccache_path", return_value="/cc/ccache" + ), ): assert toolchain.get_idedata() == {"cc_path": "x"} compile_commands, elf, cache = mock_load.call_args[0] @@ -360,43 +278,12 @@ def test_get_idedata_no_ccache(tmp_path: Path) -> None: patch( "esphome.build_helpers.idedata.load_or_build_idedata", return_value={} ) as mock_load, - patch.object(toolchain, "resolve_ccache_path", return_value=None), + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None), ): toolchain.get_idedata() assert mock_load.call_args.kwargs["launcher"] is None -def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None: - """An unchanged build.ninja means the compile DB is already current.""" - build_dir = toolchain.get_build_dir() - build_dir.mkdir(parents=True, exist_ok=True) - # write_project (stubbed below) always leaves a build.ninja behind - (build_dir / "build.ninja").write_text("# manifest") - - def run(regenerate_expected: bool) -> None: - with ( - patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), - patch.object(framework, "get_build_env", return_value={}), - patch("esphome.build_gen.arduino8266.write_project", return_value=False), - patch.object( - toolchain.subprocess, - "run", - return_value=MagicMock(returncode=0, stdout="", stderr=""), - ), - patch.object(toolchain, "_write_compile_commands") as mock_compdb, - patch.object(toolchain, "_print_size_summary"), - patch.object(toolchain, "get_idedata"), - ): - assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 - assert mock_compdb.called == regenerate_expected - - # Missing compile DB: regenerated even though build.ninja is unchanged - run(regenerate_expected=True) - # Present compile DB + unchanged build.ninja: skipped - (build_dir / "compile_commands.json").write_text("[]") - run(regenerate_expected=False) - - def test_print_size_summary_unparsable_section( tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -448,32 +335,6 @@ def test_print_size_summary_missing_section_skips_summary( assert "missing section(s) .bss" in caplog.text -def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None: - """Component-added options the native build drops are warned by name; - the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, - flash_mode) stay quiet.""" - CORE.platformio_options = { - "board_build.ldscript": "eagle.flash.4m2m.ld", - "board_build.f_cpu": "160000000L", - "board_build.filesystem": "littlefs", - "board_build.flash_mode": "dio", - "build_src_flags": "-include throw_stubs.h", - "lib_ignore": ["Updater"], - "upload_speed": "460800", - } - toolchain._warn_ignored_platformio_options() - assert "platformio_options->board_build.filesystem is ignored" in caplog.text - assert "native 'arduino' toolchain" in caplog.text - assert "board_build.ldscript is ignored" not in caplog.text - assert "board_build.f_cpu is ignored" not in caplog.text - assert "lib_ignore" not in caplog.text - assert "build_src_flags" not in caplog.text - assert "flash_mode" not in caplog.text - # Component-added upload_speed never gets read under the native - # toolchain, so it must warn - assert "upload_speed" in caplog.text - - def test_run_compile_idedata_error_does_not_fail_build( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -487,7 +348,7 @@ def test_run_compile_idedata_error_does_not_fail_build( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary"), patch.object( toolchain, @@ -513,7 +374,7 @@ def test_run_compile_skipped_size_summary_names_consequence( "run", return_value=MagicMock(returncode=0, stdout="", stderr=""), ), - patch.object(toolchain, "_write_compile_commands"), + patch.object(toolchain, "refresh_compile_commands"), patch.object(toolchain, "_print_size_summary", return_value=False), patch.object(toolchain, "get_idedata", return_value=None), ): @@ -542,33 +403,50 @@ def test_get_idedata_accepts_preresolved_ccache() -> None: "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"ok": True}, ) as mock_build, - patch.object(toolchain, "resolve_ccache_path") as mock_resolve, + patch.object(toolchain, "resolve_absolute_ccache_path") as mock_resolve, ): assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True} mock_resolve.assert_not_called() assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache" -def test_ccache_env_includes_pch_settings() -> None: - """The native build exports the ccache settings the pch needs.""" - mark_pch_emitted() - with patch.dict(os.environ, {}, clear=True): - env = framework.ccache_env("/usr/bin/ccache") - assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" - assert env["CCACHE_PCH_EXTSUM"] == "true" - - -def test_ccache_env_pch_disabled() -> None: - with patch.dict(os.environ, {"ESPHOME_PCH_ENABLE": "0"}, clear=True): - env = framework.ccache_env("/usr/bin/ccache") - assert "CCACHE_SLOPPINESS" not in env - assert "CCACHE_PCH_EXTSUM" not in env - - -def test_ccache_env_respects_user_sloppiness() -> None: - mark_pch_emitted() - with patch.dict(os.environ, {"CCACHE_SLOPPINESS": "locale"}, clear=True): - env = framework.ccache_env("/usr/bin/ccache") - # The user's tokens survive; the ones the pch needs are unioned on - assert env["CCACHE_SLOPPINESS"] == "locale,pch_defines,time_macros" - assert env["CCACHE_PCH_EXTSUM"] == "true" +def test_run_compile_warns_about_dropped_platformio_options( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Component-added options the native build drops are warned by name; + the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags, + flash_mode) stay quiet.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.platformio_options = { + "board_build.ldscript": "eagle.flash.4m2m.ld", + "board_build.f_cpu": "160000000L", + "board_build.filesystem": "littlefs", + "board_build.flash_mode": "dio", + "build_src_flags": "-include throw_stubs.h", + "lib_ignore": ["Updater"], + "upload_speed": "460800", + } + with ( + patch.object(framework, "check_and_install", return_value=_paths(tmp_path)), + patch.object(framework, "get_build_env", return_value={}), + patch("esphome.build_gen.arduino8266.write_project", return_value=True), + patch.object( + toolchain.subprocess, + "run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + patch.object(toolchain, "refresh_compile_commands") as mock_compdb, + patch.object(toolchain, "_print_size_summary"), + patch.object(toolchain, "get_idedata"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "platformio_options->board_build.filesystem is ignored" in caplog.text + assert "platformio_options->upload_speed is ignored" in caplog.text + assert "native 'arduino' toolchain" in caplog.text + assert "board_build.ldscript is ignored" not in caplog.text + assert "board_build.f_cpu is ignored" not in caplog.text + assert "lib_ignore" not in caplog.text + assert "build_src_flags" not in caplog.text + assert "flash_mode" not in caplog.text + # A rewritten manifest is passed on, so the compile DB is regenerated + assert mock_compdb.call_args.args[3] is True diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 87de28cf327..77b30363ecc 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -1160,3 +1160,29 @@ def test_versionless_dependency_with_provider_stays_quiet( libs = _resolve(framework) assert "Wire" in [lib.name for lib in libs] assert "has no version to resolve" not in caplog.text + + +def test_resolve_libraries_without_framework_is_all_external() -> None: + """The host build has no core tree: nothing is bundled, the framework + check is skipped, and every name reaches the converter.""" + _add_library("Wire", None) + _add_library("lvgl/lvgl", "9.5.0") + + def fake_convert(libraries: list, backend: LibraryBackend) -> list: + assert backend.platform == "native" + assert backend.framework is None + assert backend.cache_key == "host" + assert backend.provides("Wire") is False + return [] + + with patch.object(component, "convert_libraries", side_effect=fake_convert) as mock: + libs = component.resolve_libraries( + None, + pio_platform="native", + board_mcu="host", + cache_key="host", + framework=None, + ) + assert libs == [] + (libraries, _backend), _ = mock.call_args + assert [lib.name for lib in libraries] == ["Wire", "lvgl/lvgl"] diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 001756d72fc..3a902a429f6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -509,6 +509,8 @@ class MockCodegen(NamedTuple): get_variable: AsyncMock new_pvariable: MagicMock register_parented: AsyncMock + add_global: MagicMock + calls: MagicMock # new_pvariable and add_global attached, to check their order @pytest.fixture @@ -520,10 +522,16 @@ def mock_cg() -> Generator[MockCodegen]: patch( "esphome.codegen.register_parented", new_callable=AsyncMock ) as register_parented, + patch("esphome.cpp_generator.add_global") as add_global, ): get_variable.return_value = PARENT_OBJ new_pvariable.return_value = NEW_OBJ - yield MockCodegen(get_variable, new_pvariable, register_parented) + calls = MagicMock() + calls.attach_mock(new_pvariable, "new_pvariable") + calls.attach_mock(add_global, "add_global") + yield MockCodegen( + get_variable, new_pvariable, register_parented, add_global, calls + ) @pytest.fixture @@ -649,8 +657,8 @@ async def _run_apply_condition( return await _run_entry(conditions["my.check"], config, args, platform, id_key) -def _apply_lambda(mock_cg: MockCodegen) -> str: - return str(mock_cg.new_pvariable.call_args.args[2]) +def _apply_definition(mock_cg: MockCodegen) -> str: + return str(mock_cg.add_global.call_args.args[0]) @pytest.mark.asyncio @@ -661,9 +669,14 @@ async def test_register_apply_action_entry( assert entry.type_id is ApplyAction assert entry.synchronous is True mock_cg.get_variable.assert_awaited_once_with(PARENT_ID) - action_id, template_arg, _ = mock_cg.new_pvariable.call_args.args + action_id, template_arg = mock_cg.new_pvariable.call_args.args assert action_id == ID("obj_1") - assert str(template_arg) == "" + assert str(template_arg) == "" + # The definition must precede the storage line that names the function. + assert [c[0] for c in mock_cg.calls.mock_calls] == ["add_global", "new_pvariable"] + assert _apply_definition(mock_cg).startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) @pytest.mark.asyncio @@ -692,7 +705,7 @@ async def test_apply_constants( ) config = {"kp": 0.0, "on": False, "song": "a:b", "position": 0.5} await _run_apply_action(registries, fields, config) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) lines = [ f"::{PARENT_OBJ}->set_kp(0.0f);", f"::{PARENT_OBJ}->set_on(false);", @@ -715,7 +728,9 @@ async def test_apply_id_constant_is_the_named_object( fields = (ApplyField("target", "switch_to_output", cg.RawExpression("Speaker *")),) await _run_apply_action(registries, fields, {"target": ID("speaker_b")}) mock_cg.get_variable.assert_any_await(ID("speaker_b")) - assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in _apply_lambda(mock_cg) + assert f"::{PARENT_OBJ}->switch_to_output(::speaker_b);" in str( + _apply_definition(mock_cg) + ) @pytest.mark.asyncio @@ -728,7 +743,9 @@ async def test_apply_condition_id_constant_is_the_named_object( check = ApplyCall("is_output({})", (("target", cg.RawExpression("Speaker *")),)) await _run_apply_condition(registries, check, {"target": ID("speaker_b")}) mock_cg.get_variable.assert_any_await(ID("speaker_b")) - assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in _apply_lambda(mock_cg) + assert f"return ::{PARENT_OBJ}->is_output(::speaker_b);" in str( + _apply_definition(mock_cg) + ) @pytest.mark.asyncio @@ -745,8 +762,10 @@ async def test_apply_lambdas( "ki": Lambda("if (x) return 1.0f;\nreturn 2.0f;"), } await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) - text = _apply_lambda(mock_cg) - assert text.startswith("[](const std::remove_cvref_t & x) -> void {") + text = _apply_definition(mock_cg) + assert text.startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) # The parent is global-scope qualified, so an arg named like the id cannot shadow it. assert f"::{PARENT_OBJ}->set_kp(" in text assert f"::{PARENT_OBJ}->set_kp(static_cast(x * 2));" in text @@ -768,11 +787,11 @@ async def test_apply_call_keys( ApplyCall("set_range({}, {})", (("low", cg.float_), ("high", cg.float_))), ) await _run_apply_action(registries, fields, {"low": 1.0, "high": 2.0}) - assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_lambda(mock_cg) + assert f"::{PARENT_OBJ}->set_range(1.0f, 2.0f);" in _apply_definition(mock_cg) mock_cg.new_pvariable.reset_mock() await _run_apply_action(registries, fields, {}) - assert "set_range" not in _apply_lambda(mock_cg) + assert "set_range" not in _apply_definition(mock_cg) with pytest.raises(EsphomeError, match="needs all of"): await _run_apply_action(registries, fields, {"low": 1.0}) @@ -784,7 +803,7 @@ async def test_apply_action_call_shape( ) -> None: fields = (ApplyField("brightness", "set_brightness", cg.float_),) await _run_apply_action(registries, fields, {"brightness": 0.5}, call="make_call") - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) lines = [ f"auto apply_call = ::{PARENT_OBJ}->make_call();", "apply_call.set_brightness(0.5f);", @@ -814,7 +833,7 @@ async def test_apply_field_nested_key_const_fn_and_type_string( "value": Lambda("return 42;"), } await _run_apply_action(registries, fields, config) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) assert f"::{PARENT_OBJ}->set_direction(3);" in text assert f'::{PARENT_OBJ}->set_name("abc", 3);' in text assert ( @@ -824,7 +843,7 @@ async def test_apply_field_nested_key_const_fn_and_type_string( mock_cg.new_pvariable.reset_mock() await _run_apply_action(registries, fields[:1], {}) - assert "set_direction" not in _apply_lambda(mock_cg) + assert "set_direction" not in _apply_definition(mock_cg) def test_apply_registration_checks(registries: tuple[Registry, Registry]) -> None: @@ -883,8 +902,9 @@ async def test_apply_string_constant_stays_in_flash_on_esp8266( ) -> None: fields = (ApplyField("song", "play", cg.std_string),) await _run_apply_action(registries, fields, {"song": "a:b"}, platform="esp8266") - assert f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' in _apply_lambda( - mock_cg + assert ( + f'::{PARENT_OBJ}->play(progmem_string(ESPHOME_F("a:b")));' + in _apply_definition(mock_cg) ) @@ -900,7 +920,7 @@ async def test_apply_literal_with_length_is_plain_on_every_platform( await _run_apply_action( registries, fields, {"option": "h\u00e9llo"}, platform=platform ) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) assert f'::{PARENT_OBJ}->set_option("h\\303\\251llo", 6);' in text assert "progmem_string" not in text @@ -913,11 +933,13 @@ async def test_register_apply_condition_predicate( registries, "is_playing()", {}, args=[(cg.int32, "x")] ) assert entry.type_id is ApplyCondition - condition_id, template_arg, check = mock_cg.new_pvariable.call_args.args + condition_id, template_arg = mock_cg.new_pvariable.call_args.args assert condition_id == ID("obj_1") - assert str(template_arg) == "" - text = str(check) - assert text.startswith("[](const std::remove_cvref_t & x) -> bool {") + assert str(template_arg) == "" + text = _apply_definition(mock_cg) + assert text.startswith( + "static bool esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) assert f"return ::{PARENT_OBJ}->is_playing();" in text @@ -927,7 +949,7 @@ async def test_apply_condition_compares_config_value( ) -> None: check = ApplyCall("state == {}", (("state", cg.bool_),)) await _run_apply_condition(registries, check, {"state": True}) - assert f"return ::{PARENT_OBJ}->state == true;" in _apply_lambda(mock_cg) + assert f"return ::{PARENT_OBJ}->state == true;" in _apply_definition(mock_cg) with pytest.raises(EsphomeError, match="needs all of"): await _run_apply_condition(registries, check, {}) @@ -940,7 +962,7 @@ async def test_apply_condition_string_constant_is_a_plain_literal( ) -> None: check = ApplyCall("state == {}", (("state", cg.std_string),)) await _run_apply_condition(registries, check, {"state": "two"}, platform=platform) - assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_lambda(mock_cg) + assert f'return ::{PARENT_OBJ}->state == "two";' in _apply_definition(mock_cg) @pytest.mark.asyncio @@ -964,6 +986,6 @@ async def test_apply_condition_string_lambda_paths( await _run_apply_condition( registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")] ) - text = _apply_lambda(mock_cg) + text = _apply_definition(mock_cg) assert expected in text assert ("-> std::string {" in text) is called diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 0c96f8c8c99..94458109753 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -976,6 +976,14 @@ class TestEsphomeCore: target.toolchain = const.Toolchain.SDK_NRF assert target.using_native_toolchain is False + def test_using_toolchain_host(self, target): + """The host's only toolchain: the system compiler driven by ninja.""" + target.toolchain = const.Toolchain.HOST + assert target.using_toolchain_host is True + assert target.using_native_toolchain is True + target.toolchain = const.Toolchain.PLATFORMIO + assert target.using_toolchain_host is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index 2ccf53f7e3b..0d664b8db66 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -50,3 +50,37 @@ def test_recorded_firmware_path_yields_downloads(platform: str, tmp_path: Path) assert all( {"title", "description", "file", "download"} <= entry.keys() for entry in types ) + + +def _nrf52_files(tmp_path: Path, *built: str) -> list[str]: + """The files nrf52 offers for a build directory holding *built*.""" + (tmp_path / "zephyr").mkdir() + for name in built: + (tmp_path / name).touch() + storage = _wizard_storage() + storage.firmware_bin_path = tmp_path / "firmware.bin" + return [entry["file"] for entry in _download_types("nrf52", storage)] + + +@pytest.mark.parametrize( + ("built", "expected"), + [ + # Adafruit bootloader with the mcumgr OTA: MCUboot is chained behind it. + ( + ["zephyr/zephyr.uf2", "zephyr/app_update.bin"], + ["zephyr/zephyr.uf2", "firmware.zip", "zephyr/app_update.bin"], + ), + (["zephyr/zephyr.uf2"], ["zephyr/zephyr.uf2", "firmware.zip"]), + ( + ["zephyr/merged.hex", "zephyr/app_update.bin"], + ["zephyr/merged.hex", "zephyr/app_update.bin"], + ), + (["zephyr/merged.hex"], ["zephyr/merged.hex"]), + ([], ["zephyr/zephyr.hex"]), + ], +) +def test_nrf52_offers_the_mcumgr_image_whenever_it_was_built( + tmp_path: Path, built: list[str], expected: list[str] +) -> None: + """The mcumgr update image is offered beside a UF2 as well as beside a HEX.""" + assert _nrf52_files(tmp_path, *built) == expected diff --git a/tests/unit_tests/test_host_toolchain.py b/tests/unit_tests/test_host_toolchain.py new file mode 100644 index 00000000000..4e4e4448016 --- /dev/null +++ b/tests/unit_tests/test_host_toolchain.py @@ -0,0 +1,329 @@ +"""Tests for esphome.host.toolchain (the native host build driver).""" + +from __future__ import annotations + +from collections.abc import Generator +import logging +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.const import ( + CONF_COMPILE_PROCESS_LIMIT, + CONF_ESPHOME, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + Toolchain, +) +from esphome.core import CORE, EsphomeError +from esphome.host import toolchain +from esphome.host.toolchain import PROGRAM_NAME + + +@pytest.fixture(autouse=True) +def _core(tmp_path: Path) -> None: + CORE.config_path = tmp_path / "dev.yaml" + CORE.build_path = tmp_path + CORE.name = "dev" + CORE.toolchain = Toolchain.HOST + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST} + + +def _abs(path: str) -> str: + """What the toolchain makes of a tool path (a drive is added on Windows).""" + return str(Path(path).absolute()) + + +def test_find_command_prefers_env_override(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CXX", "/opt/clang++") + with patch("shutil.which", side_effect={"/opt/clang++": "/opt/clang++"}.get): + assert toolchain.find_command("CXX", ("g++",)) == (_abs("/opt/clang++"),) + + +def test_find_command_keeps_the_override_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``CC="gcc -m32"`` is a command, as make and CMake read it.""" + monkeypatch.setenv("CC", "gcc -m32 '--sysroot=/opt/my sdk'") + with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get): + assert toolchain.find_command("CC", ("cc",)) == ( + _abs("/usr/bin/gcc"), + "-m32", + "--sysroot=/opt/my sdk", + ) + + +@pytest.mark.parametrize( + ("override", "expected_args"), + [("ccache gcc", ()), ("/opt/bin/ccache gcc -m32", ("-m32",))], +) +def test_find_command_drops_a_ccache_prefix( + monkeypatch: pytest.MonkeyPatch, override: str, expected_args: tuple[str, ...] +) -> None: + """The build adds ccache itself; the compiler is the word after it.""" + monkeypatch.setenv("CC", override) + with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get): + assert toolchain.find_command("CC", ("cc",)) == ( + _abs("/usr/bin/gcc"), + *expected_args, + ) + + +def test_find_command_accepts_a_compiler_named_ccache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Alone, the word is the program: ccache's compiler links work that way.""" + monkeypatch.setenv("CC", "ccache") + with patch("shutil.which", side_effect={"ccache": "/usr/bin/ccache"}.get): + assert toolchain.find_command("CC", ("cc",)) == (_abs("/usr/bin/ccache"),) + + +def test_find_command_env_override_must_run(monkeypatch: pytest.MonkeyPatch) -> None: + """A broken override fails by name instead of silently using another compiler.""" + monkeypatch.setenv("CXX", "nope++") + with ( + patch("shutil.which", return_value=None), + pytest.raises(EsphomeError, match="CXX='nope\\+\\+' does not name"), + ): + toolchain.find_command("CXX", ("g++",)) + + +def test_find_command_blank_override_is_unset(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CC", " ") + with patch("shutil.which", side_effect={"clang": "/usr/bin/clang"}.get): + assert toolchain.find_command("CC", ("gcc", "clang")) == ( + _abs("/usr/bin/clang"), + ) + + +def test_find_command_first_candidate_wins(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CC", raising=False) + table = {"gcc": "/usr/bin/gcc", "clang": "/usr/bin/clang"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.find_command("CC", ("gcc", "clang")) == (_abs("/usr/bin/gcc"),) + + +def test_find_command_none_found(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CC", raising=False) + with ( + patch("shutil.which", return_value=None), + pytest.raises( + EsphomeError, + match=r"gcc not found on PATH \(tried gcc, clang\); install it or set CC", + ), + ): + toolchain.find_command("CC", ("gcc", "clang")) + + +def test_find_command_makes_a_relative_hit_absolute( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Anchor the path: ninja runs from the build directory.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CC", "./toolchain/gcc") + with patch("shutil.which", return_value="./toolchain/gcc"): + (found,) = toolchain.find_command("CC", ("gcc",)) + assert Path(found) == Path("toolchain/gcc").absolute() + + +def test_find_tool_refuses_arguments(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("AR", "ar --plugin x") + with ( + patch("shutil.which", return_value="/usr/bin/ar"), + pytest.raises(EsphomeError, match="AR must name a program without arguments"), + ): + toolchain.find_tool("AR", ("ar",)) + + +def test_find_compilers(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ("CC", "CXX"): + monkeypatch.delenv(var, raising=False) + table = {"gcc": "/usr/bin/gcc", "g++": "/usr/bin/g++"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.find_compilers() == toolchain.HostCompilers( + cc=(_abs("/usr/bin/gcc"),), cxx=(_abs("/usr/bin/g++"),) + ) + + +def test_build_paths(tmp_path: Path) -> None: + """The PlatformIO layout is kept: CORE.firmware_bin resolves the same file.""" + assert toolchain.get_build_dir() == tmp_path / ".pioenvs" / "dev" + assert toolchain.get_elf_path() == CORE.firmware_bin + + +def test_binutils_paths(monkeypatch: pytest.MonkeyPatch) -> None: + for var in ("OBJDUMP", "READELF"): + monkeypatch.delenv(var, raising=False) + table = {"objdump": "/usr/bin/objdump", "readelf": "/usr/bin/readelf"} + with patch("shutil.which", side_effect=table.get): + assert toolchain.get_objdump_path() == Path(_abs("/usr/bin/objdump")) + assert toolchain.get_readelf_path() == Path(_abs("/usr/bin/readelf")) + + +def test_get_build_env_merges_without_leaking( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_HOST_PREFIX", str(tmp_path / "cache")) + monkeypatch.delenv("CCACHE_DIR", raising=False) + monkeypatch.setenv("KEEP_ME", "1") + env = toolchain.get_build_env("/usr/bin/ccache") + assert env["KEEP_ME"] == "1" + assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache") + assert "CCACHE_DIR" not in os.environ + + +def test_get_build_env_without_ccache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("CCACHE_DIR", raising=False) + assert "CCACHE_DIR" not in toolchain.get_build_env(None) + + +@pytest.fixture +def compile_env(tmp_path: Path) -> Generator[SimpleNamespace]: + """Stub everything run_compile resolves; the build dir holds a manifest.""" + build_dir = tmp_path / ".pioenvs" / "dev" + build_dir.mkdir(parents=True) + (build_dir / "build.ninja").write_text("rule x\n") + compilers = toolchain.HostCompilers(("gcc",), ("g++",)) + with ( + patch.object(toolchain, "find_ninja", return_value=Path("/usr/bin/ninja")), + patch.object(toolchain, "find_compilers", return_value=compilers), + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None), + patch("esphome.build_gen.host.write_project", return_value=True) as project, + patch.object(toolchain, "refresh_compile_commands") as refresh, + patch.object( + toolchain, "_load_idedata", return_value={"cc_path": "gcc"} + ) as ide, + patch("subprocess.run") as run, + ): + yield SimpleNamespace( + build_dir=build_dir, + compilers=compilers, + write_project=project, + refresh=refresh, + idedata=ide, + run=run, + ) + + +def _completed(rc: int = 0) -> SimpleNamespace: + return SimpleNamespace(returncode=rc, stdout="", stderr="") + + +def test_run_compile_builds_and_reports_success(compile_env: SimpleNamespace) -> None: + elf = compile_env.build_dir / PROGRAM_NAME + + def build(cmd: list[str], **kwargs: object) -> SimpleNamespace: + elf.write_text("") + return _completed() + + compile_env.run.side_effect = build + config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}} + assert toolchain.run_compile(config, verbose=True) == 0 + + compile_env.write_project.assert_called_once_with(compile_env.compilers, None) + compile_env.refresh.assert_called_once() + assert compile_env.refresh.call_args.args[3] is True + compile_env.run.assert_called_once() + assert compile_env.run.call_args.args[0] == [ + str(Path("/usr/bin/ninja")), + "-v", + "-j", + "4", + PROGRAM_NAME, + ] + assert compile_env.run.call_args.kwargs["cwd"] == compile_env.build_dir + compile_env.idedata.assert_called_once_with(None) + + +def test_run_compile_defaults(compile_env: SimpleNamespace) -> None: + """No verbosity and no process limit: just the ninja target.""" + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert compile_env.run.call_args.args[0] == [ + str(Path("/usr/bin/ninja")), + PROGRAM_NAME, + ] + # The build inherits stdio and never captures: progress must stream + kwargs = compile_env.run.call_args.kwargs + assert kwargs["check"] is False + assert kwargs["close_fds"] is False + assert "capture_output" not in kwargs + + +def test_run_compile_warns_about_dropped_platformio_options( + compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + CORE.platformio_options = {"lib_ignore": ["x"], "board_build.f_cpu": "1"} + with caplog.at_level(logging.WARNING): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + assert "platformio_options->board_build.f_cpu is ignored" in caplog.text + assert "native 'host' toolchain" in caplog.text + assert "lib_ignore" not in caplog.text + + +def test_run_compile_passes_the_resolved_ccache(compile_env: SimpleNamespace) -> None: + (compile_env.build_dir / PROGRAM_NAME).write_text("") + compile_env.run.return_value = _completed() + with patch.object( + toolchain, "resolve_absolute_ccache_path", return_value="/usr/bin/ccache" + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + compile_env.write_project.assert_called_once_with( + compile_env.compilers, "/usr/bin/ccache" + ) + compile_env.idedata.assert_called_once_with("/usr/bin/ccache") + + +def test_run_compile_build_failure_returns_code(compile_env: SimpleNamespace) -> None: + compile_env.run.return_value = _completed(rc=3) + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 3 + compile_env.idedata.assert_not_called() + + +def test_run_compile_missing_program_fails( + compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture +) -> None: + """A green ninja run that produced no program fails by name.""" + compile_env.run.return_value = _completed() + with caplog.at_level(logging.ERROR): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1 + assert "Build produced no" in caplog.text + compile_env.idedata.assert_not_called() + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_get_idedata_resolves_ccache(tmp_path: Path, ccache: str | None) -> None: + with ( + patch.object(toolchain, "resolve_absolute_ccache_path", return_value=ccache), + patch( + "esphome.build_helpers.idedata.load_or_build_idedata", return_value={"x": 1} + ) as load, + ): + assert toolchain.get_idedata() == {"x": 1} + load.assert_called_once_with( + tmp_path / ".pioenvs" / "dev" / "compile_commands.json", + tmp_path / ".pioenvs" / "dev" / PROGRAM_NAME, + CORE.relative_internal_path("idedata", "dev.json"), + launcher=ccache, + ) + + +@pytest.mark.parametrize("platform", ["darwin", "win32"]) +def test_check_analysis_supported_refuses_non_elf(platform: str) -> None: + with ( + patch.object(toolchain.sys, "platform", platform), + pytest.raises(EsphomeError, match=f"the host build on {platform}"), + ): + toolchain.check_analysis_supported() + + +def test_check_analysis_supported_accepts_linux() -> None: + with patch.object(toolchain.sys, "platform", "linux"): + toolchain.check_analysis_supported() diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index b5e7181269b..2ed61e5d26e 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7489,6 +7489,8 @@ def test_command_idedata_arduino_no_build_errors(tmp_path: Path) -> None: [ (PLATFORM_ESP8266, Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), (PLATFORM_ESP32, Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + # No native build backend, but its binutils and ELF are known + (PLATFORM_NRF52, Toolchain.SDK_NRF, "esphome.components.nrf52.toolchain"), ], ) def test_command_analyze_memory_native_toolchains( @@ -7708,7 +7710,12 @@ def test_command_analyze_memory_unsupported_toolchain( mock_write_cpp.return_value = 0 mock_compile_program.return_value = 0 - result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) + # Every toolchain has analysis hooks today; drop sdk-nrf's to stand in for + # one that does not + with patch.dict( + "esphome.build_helpers.native.ANALYSIS_TOOLCHAIN_MODULES", clear=True + ): + result = command_analyze_memory(MockArgs(), {CONF_ESPHOME: {CONF_NAME: "t"}}) assert result == 1 assert "analyze-memory is not supported" in caplog.text @@ -7798,52 +7805,206 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: assert second.index("a: 2") < second.index("z: 1") -def test_host_program_path_platformio_toolchain() -> None: - """Host + PlatformIO toolchain reads the memoized idedata path.""" - setup_core(platform=PLATFORM_HOST) - idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program") - with patch( - "esphome.platformio.toolchain.get_idedata", return_value=idedata - ) as mock_get: - assert main._host_program_path({}) == "/build/x/.pioenvs/x/program" - mock_get.assert_called_once_with({}) - - -def test_host_program_path_esp_idf_toolchain() -> None: - """Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF.""" - setup_core(platform=PLATFORM_HOST) - CORE.toolchain = Toolchain.ESP_IDF - with patch( - "esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf") - ): - assert main._host_program_path({}) == str(Path("/b/app.elf")) - - def test_command_compile_host_logs_program_path( + tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """command_compile on host logs the compiled program path.""" - setup_core(platform=PLATFORM_HOST) + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") with ( patch.object(main, "write_cpp", return_value=0), patch.object(main, "compile_program", return_value=0), - patch.object(main, "_host_program_path", return_value="/b/program"), caplog.at_level(logging.INFO), ): assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0 - assert "Successfully compiled program to path '/b/program'" in caplog.text + assert f"Successfully compiled program to path '{CORE.firmware_bin}'" in caplog.text -def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None: +def test_command_run_host_executes_program( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: """command_run on host logs and executes the compiled program directly.""" - setup_core(platform=PLATFORM_HOST) + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") with ( patch.object(main, "write_cpp", return_value=0), patch.object(main, "compile_program", return_value=0), - patch.object(main, "_host_program_path", return_value="/b/program"), patch.object(main, "run_external_process", return_value=0) as mock_run, caplog.at_level(logging.INFO), ): assert main.command_run(SimpleNamespace(), {}) == 0 - mock_run.assert_called_with("/b/program") - assert "Running program from path '/b/program'" in caplog.text + program = str(CORE.firmware_bin) + mock_run.assert_called_with(program) + assert f"Running program from path '{program}'" in caplog.text + + +def test_write_cpp_file_project_generation_follows_host_toolchain() -> None: + """Only PlatformIO gets a platformio.ini; ESP-IDF writes its CMake project + here, and the other native builds generate theirs at compile time.""" + setup_core(platform=PLATFORM_HOST) + with ( + patch("esphome.writer.write_cpp"), + patch("esphome.build_gen.platformio.write_project") as mock_pio, + patch("esphome.build_gen.espidf.write_project") as mock_idf, + ): + CORE.toolchain = Toolchain.HOST + assert main.write_cpp_file() == 0 + mock_pio.assert_not_called() + mock_idf.assert_not_called() + CORE.toolchain = Toolchain.PLATFORMIO + assert main.write_cpp_file() == 0 + mock_pio.assert_called_once() + CORE.toolchain = Toolchain.ESP_IDF + assert main.write_cpp_file() == 0 + mock_idf.assert_called_once() + + +def test_compile_program_host_uses_the_platform_hook() -> None: + """The host component's run_compile claims the build.""" + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.components.host.run_compile", return_value=True) as hook, + patch("esphome.__main__._check_and_emit_build_info") as build_info, + ): + assert compile_program(MagicMock(), {}) == 0 + hook.assert_called_once() + build_info.assert_called_once() + + +def test_compile_program_native_toolchain_needs_a_backend() -> None: + """A native toolchain no hook claims must not fall through to PlatformIO.""" + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.platformio.toolchain.run_compile") as mock_pio, + # A platform package without a run_compile hook + patch.dict(sys.modules, {"esphome.components.host": SimpleNamespace()}), + pytest.raises(EsphomeError, match="no platform backend claimed the build"), + ): + compile_program(MagicMock(), {}) + mock_pio.assert_not_called() + + +def test_native_backend_resolves_host() -> None: + from esphome.build_helpers.native import native_backend + from esphome.host import toolchain as host_toolchain + + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.PLATFORMIO + assert native_backend() is None + CORE.toolchain = Toolchain.HOST + assert native_backend() is host_toolchain + # A native toolchain without a backend for the platform must not degrade + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises( + EsphomeError, match="has no native build backend module for platform host" + ): + native_backend() + + +def test_command_idedata_host_prints_json(capsys: CaptureFixture) -> None: + setup_core(platform=PLATFORM_HOST) + CORE.toolchain = Toolchain.HOST + data = {"cxx_path": "g++", "prog_path": "/build/program"} + with patch("esphome.host.toolchain.get_idedata", return_value=data) as mock_get: + assert command_idedata(MagicMock(), CORE.config) == 0 + mock_get.assert_called_once_with() + assert json.loads(capsys.readouterr().out) == data + + +def _native_host_tools(tmp_path: Path) -> tuple[Path, Path, Path]: + objdump, readelf, elf = (tmp_path / n for n in ("objdump", "readelf", "program")) + for tool in (objdump, readelf, elf): + tool.write_text("") + return objdump, readelf, elf + + +def test_command_analyze_memory_native_toolchain( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_write_cpp: Mock, + mock_compile_program: Mock, + mock_get_esphome_components: Mock, + mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, +) -> None: + """A native backend supplies its own binutils and ELF, with no idedata.""" + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + config = {CONF_ESPHOME: {CONF_NAME: "dev"}} + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + ): + assert command_analyze_memory(MockArgs(), config) == 0 + mock_memory_analyzer_cli.assert_called_once_with( + str(elf), str(objdump), str(readelf), set(), idedata=None + ) + mock_ram_strings_analyzer.assert_called_once_with( + str(elf), objdump_path=str(objdump), platform="host" + ) + assert "Mock Memory Report" in capfd.readouterr().out + + +def test_command_analyze_memory_native_missing_tool_fails( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + readelf.unlink() + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + caplog.at_level(logging.ERROR), + ): + assert command_analyze_memory(MockArgs(), {}) == 1 + assert f"{readelf} is missing; the toolchain install may be incomplete" in ( + caplog.text + ) + + +def test_command_analyze_memory_host_missing_elf_fails( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + objdump, readelf, elf = _native_host_tools(tmp_path) + elf.unlink() + with ( + patch("esphome.host.toolchain.get_objdump_path", return_value=objdump), + patch("esphome.host.toolchain.get_readelf_path", return_value=readelf), + patch("esphome.host.toolchain.get_elf_path", return_value=elf), + patch("esphome.host.toolchain.check_analysis_supported"), + caplog.at_level(logging.ERROR), + ): + assert command_analyze_memory(MockArgs(), {}) == 1 + assert f"{elf} is missing; compile the configuration first" in caplog.text + + +def test_command_analyze_memory_host_refuses_before_compiling( + tmp_path: Path, + mock_write_cpp: Mock, + mock_compile_program: Mock, +) -> None: + """A machine whose host program is not ELF fails before the compile.""" + setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev") + CORE.toolchain = Toolchain.HOST + with ( + patch("esphome.host.toolchain.sys.platform", "darwin"), + pytest.raises(EsphomeError, match="analyze-memory reads ELF files"), + ): + command_analyze_memory(MockArgs(), {}) + mock_write_cpp.assert_not_called() + mock_compile_program.assert_not_called() diff --git a/tests/unit_tests/test_nrf52_build.py b/tests/unit_tests/test_nrf52_build.py new file mode 100644 index 00000000000..868bc9549f2 --- /dev/null +++ b/tests/unit_tests/test_nrf52_build.py @@ -0,0 +1,23 @@ +"""Tests for the nRF52 native west build command.""" + +from pathlib import Path + +from esphome.components.nrf52 import _west_build_command + + +def test_west_build_sets_the_cmake_build_type() -> None: + """The picolibc module used to force MinSizeRel, and with it the -DNDEBUG + that keeps libc assert() out of the image; the build sets it itself now.""" + python = Path("/penv/python") + cmd = _west_build_command( + python, + "adafruit_feather_nrf52840", + Path("/build/pio"), + Path("/build/zephyr"), + ) + + # str(), not a literal: the separator differs on Windows + assert cmd[:4] == [str(python), "-m", "west", "build"] + assert "-b" in cmd and "adafruit_feather_nrf52840" in cmd + # CMake arguments come after west's own + assert cmd[cmd.index("--") + 1 :] == ["-DCMAKE_BUILD_TYPE=MinSizeRel"] diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index b78a94a2e7c..d6b27bdbef2 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,11 +1,12 @@ """Tests for esphome.components.nrf52.framework helpers.""" +import errno import hashlib import os from pathlib import Path import sys from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import ANY, call, patch import platformdirs import pytest @@ -14,16 +15,20 @@ from esphome.components.nrf52 import _resolve_toolchain from esphome.components.nrf52.framework import ( _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, + DEFAULT_WEST_PROJECTS, TOOLCHAIN_VERSION, _get_penv_site_packages, _get_platformio_penv_path, _get_toolchain_platform_info, _needs_venv_rebuild, + _wanted_west_projects, check_and_install, get_build_env, get_sdk_nrf_tools_path, + include_west_project, setup_platformio_python_env, ) +from esphome.components.zephyr.const import KEY_SYSBUILD, KEY_ZEPHYR import esphome.config_validation as cv from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain @@ -76,6 +81,8 @@ def test_get_toolchain_platform_info( # --------------------------------------------------------------------------- _TEST_SDK_VERSION = "2.9.0" +# The filter that keeps only DEFAULT_WEST_PROJECTS +_DEFAULTS_FILTER = "-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr" @pytest.fixture @@ -129,6 +136,12 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _mark_west_initialized(framework: Path) -> None: + """What a finished ``west init`` leaves behind.""" + (framework / ".west").mkdir() + (framework / ".west" / "config").touch() + + def _touch_penv_python(penv: Path) -> None: """Create the interpreter file so the rebuild gate sees a live venv.""" python = get_python_env_executable_path(penv, "python") @@ -136,6 +149,33 @@ def _touch_penv_python(penv: Path) -> None: python.touch() +def _subcommand(cmd: list[str]) -> str: + tool = "west" if "west" in cmd else "pip" + return cmd[cmd.index(tool) + 1] + + +def _subcommands(run_command_ok) -> list[str]: + """The west or pip subcommand of each command run, in order.""" + return [_subcommand(c.args[0]) for c in run_command_ok.call_args_list] + + +def _project_filter(run_command_ok) -> str: + """The manifest.project-filter value of the last ``west config`` run.""" + for west_call in reversed(run_command_ok.call_args_list): + cmd = west_call.args[0] + if "manifest.project-filter" in cmd: + return cmd[-1] + raise AssertionError("no west config command ran") + + +def _mark_installed(dirs: SimpleNamespace) -> None: + """Every install step finished: venv, zephyr requirements, SDK, toolchain.""" + _mark_venv_ready(dirs.python_env) + (dirs.python_env / ".zephyr_reqs_ready").touch() + (dirs.framework / ".ready").touch() + (dirs.toolchain / ".ready").touch() + + def _mark_venv_ready(python_env: Path) -> None: """Write the venv sentinel with the current requirements hash and a present interpreter so the rebuild gate passes.""" @@ -151,10 +191,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """All three sentinels present → nothing downloaded or compiled.""" - _mark_venv_ready(nrf52_dirs.python_env) - (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() - (nrf52_dirs.framework / ".ready").touch() - (nrf52_dirs.toolchain / ".ready").touch() + _mark_installed(nrf52_dirs) check_and_install() @@ -189,8 +226,14 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_called_once() - # pip install requirements, west init, west update, pip install zephyr reqs - assert mock_nrf52_ops.run_command_ok.call_count == 4 + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "install", # requirements + "init", + "config", + "update", + "list", + "install", # zephyr requirements + ] # minimal SDK + per-arch toolchain assert mock_nrf52_ops.download_from_mirrors.call_count == 2 assert mock_nrf52_ops.archive_extract_all.call_count == 2 @@ -210,8 +253,13 @@ class TestCheckAndInstall: check_and_install() mock_nrf52_ops.create_venv.assert_not_called() - # west init, west update, pip install zephyr reqs - assert mock_nrf52_ops.run_command_ok.call_count == 3 + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "init", + "config", + "update", + "list", + "install", + ] # minimal SDK + per-arch toolchain assert mock_nrf52_ops.download_from_mirrors.call_count == 2 @@ -243,13 +291,104 @@ class TestCheckAndInstall: check_and_install() - init_cmd, update_cmd = ( - call.args[0] for call in mock_nrf52_ops.run_command_ok.call_args_list[:2] + init, _, update = mock_nrf52_ops.run_command_ok.call_args_list[:3] + assert "-o=--depth=1" in init.args[0] + assert "--fetch-opt=--depth=1" in update.args[0] + # Streamed, so the long clone's progress reaches the log + assert init.kwargs["stream_output"] is True + assert update.kwargs["stream_output"] is True + + def test_interrupted_download_resumes( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A workspace left by a cut-short download is updated in place, not + wiped and cloned again.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + + # A marker left by an earlier failed resume + (nrf52_dirs.framework / ".resume_failed").touch() + + check_and_install() + + assert ( + call(nrf52_dirs.framework, msg=ANY) + not in mock_nrf52_ops.rmdir.call_args_list ) - assert "init" in init_cmd - assert "-o=--depth=1" in init_cmd - assert "update" in update_cmd - assert "--fetch-opt=--depth=1" in update_cmd + assert not (nrf52_dirs.framework / ".resume_failed").exists() + # west update in the workspace (no init), then pip install zephyr reqs + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "config", + "update", + "list", + "install", + ] + update = mock_nrf52_ops.run_command_ok.call_args_list[1] + assert update.kwargs["cwd"] == nrf52_dirs.framework + assert (nrf52_dirs.framework / ".ready").exists() + + def test_failed_resume_keeps_the_download_once( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A first failed resume keeps what was fetched (the network likely + dropped again) and is retried on the next build.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + # config succeeds, the resumed update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, False] + + with pytest.raises(EsphomeError, match="Can't resume"): + check_and_install() + + assert ( + call(nrf52_dirs.framework, msg=ANY) + not in mock_nrf52_ops.rmdir.call_args_list + ) + assert (nrf52_dirs.framework / ".resume_failed").exists() + + def test_cut_short_init_starts_over( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A ``.west`` without its config (init cut short) clones clean.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".west").mkdir() + + check_and_install() + + mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY) + first = mock_nrf52_ops.run_command_ok.call_args_list[0] + assert "init" in first.args[0] + + def test_second_failed_resume_starts_over( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A resume failing twice in a row wipes the workspace and clones clean.""" + _mark_venv_ready(nrf52_dirs.python_env) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".resume_failed").touch() + # resumed update fails; the clean clone and zephyr reqs succeed + mock_nrf52_ops.run_command_ok.side_effect = [True, False, *[True] * 5] + + check_and_install() + + mock_nrf52_ops.rmdir.assert_any_call(nrf52_dirs.framework, msg=ANY) + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "config", + "update", + "init", + "config", + "update", + "list", + "install", + ] def test_requirements_install_failure_raises( self, @@ -281,12 +420,372 @@ class TestCheckAndInstall: ) -> None: """Failing west update raises EsphomeError.""" _mark_venv_ready(nrf52_dirs.python_env) - # init succeeds, update fails - mock_nrf52_ops.run_command_ok.side_effect = [True, False] + # init and config succeed, update fails + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False] with pytest.raises(EsphomeError, match="Can't update"): check_and_install() + def test_fresh_install_fetches_only_default_projects( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A fresh clone leaves every west project out except the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + + check_and_install() + + assert _project_filter(mock_nrf52_ops.run_command_ok) == ( + "-.*,+cmsis,+hal_nordic,+nrfxlib,+zephyr" + ) + stamp = nrf52_dirs.framework / ".west_projects" + assert stamp.read_text(encoding="utf-8").split() == sorted( + DEFAULT_WEST_PROJECTS + ) + + def test_included_project_joins_the_filter( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A project a component includes is fetched with the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + include_west_project("oberon-psa-crypto") + + check_and_install() + + assert "+oberon-psa-crypto" in _project_filter( + mock_nrf52_ops.run_command_ok + ).split(",") + + def test_installed_sdk_fetches_a_newly_needed_project( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A finished install gains a project another config left out, keeping + what it already has.""" + _mark_installed(nrf52_dirs) + (nrf52_dirs.framework / ".west_projects").write_text( + "cmsis\nhal_nordic\nnrfxlib\ntinycrypt\nzephyr", encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + # The names are checked before anything is fetched + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + wanted = "-.*,+cmsis,+hal_nordic,+nrfxlib,+openthread,+tinycrypt,+zephyr" + assert _project_filter(mock_nrf52_ops.run_command_ok) == wanted + mock_nrf52_ops.rmdir.assert_not_called() + + @pytest.mark.parametrize( + "stamp", + [ + pytest.param(None, id="install_from_before_the_filter"), + pytest.param( + "cmsis\nhal_nordic\nnrfxlib\nopenthread\nzephyr", + id="project_already_fetched", + ), + ], + ) + def test_installed_sdk_with_the_project_fetches_nothing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + stamp: str | None, + ) -> None: + """No fetch when the install already has every wanted project; an + install without the stamp (west config, no filter) has them all. The + names are still checked, which only reads the manifest.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + if stamp is not None: + (nrf52_dirs.framework / ".west_projects").write_text( + stamp, encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + + def test_sysbuild_fetches_mcuboot( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Sysbuild always builds the MCUboot image, so it needs the project.""" + _mark_venv_ready(nrf52_dirs.python_env) + CORE.data[KEY_ZEPHYR] = {KEY_SYSBUILD: True} + + check_and_install() + + assert "+mcuboot" in _project_filter(mock_nrf52_ops.run_command_ok).split(",") + + @pytest.mark.parametrize( + ("sdk_version", "has_cmsis_6"), + [("2.9.2", False), ("3.1.0", True), ("3.2.0", True)], + ) + def test_sdk_3_1_and_later_want_cmsis_6( + self, setup_core: Path, sdk_version: str, has_cmsis_6: bool + ) -> None: + """Zephyr 4.1 moved the Cortex-M core headers to the cmsis_6 module.""" + CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(sdk_version)} + + assert ("cmsis_6" in _wanted_west_projects()) is has_cmsis_6 + + def test_default_projects_never_read_the_stamp( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A build wanting only the defaults has them on any install; an + unreadable stamp shows the check never looked.""" + _mark_installed(nrf52_dirs) + (nrf52_dirs.framework / ".west_projects").mkdir() + + check_and_install() + + mock_nrf52_ops.run_command_ok.assert_not_called() + + def test_failed_project_fetch_raises( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A failed fetch of a newly needed project raises, keeps the stamp and + puts the workspace filter back to the stamp's projects and the defaults.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + stamp = nrf52_dirs.framework / ".west_projects" + stamp.write_text("zephyr", encoding="utf-8") + include_west_project("openthread") + # list and config succeed, update fails, the restoring config succeeds + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert stamp.read_text(encoding="utf-8") == "zephyr" + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + "config", + ] + # The defaults always stay in the restored filter + assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER + + def test_failed_fetch_with_a_lost_stamp_keeps_the_defaults_active( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """An unknown installed set must not leave a filter with every module off.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".west" / "config").write_text( + "[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8" + ) + include_west_project("openthread") + # list and config succeed, update fails, the restoring config succeeds + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, True] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert _project_filter(mock_nrf52_ops.run_command_ok) == _DEFAULTS_FILTER + + def test_failed_filter_restore_is_logged( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """When the filter can't be put back after a failed fetch, the user is told.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8") + include_west_project("openthread") + # list and config succeed, update fails, the restoring config fails too + mock_nrf52_ops.run_command_ok.side_effect = [True, True, False, False] + + with pytest.raises(EsphomeError, match="Can't update"): + check_and_install() + + assert "Couldn't put the nRF Connect SDK" in caplog.text + + def test_lost_stamp_on_a_filtered_install_fetches_again( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A workspace with a project filter but no stamp is a filtered install + whose record was lost, so the wanted projects are fetched, not assumed.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + (nrf52_dirs.framework / ".west" / "config").write_text( + "[manifest]\nproject-filter = -.*,+zephyr\n", encoding="utf-8" + ) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + + def test_install_waits_for_another_build_holding_the_lock( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """A held lock is waited on, never mistaken for a filesystem that + cannot lock (filelock's Timeout is an OSError as well).""" + from filelock import Timeout + + _mark_installed(nrf52_dirs) + + with ( + caplog.at_level("INFO"), + patch("filelock.FileLock") as file_lock, + ): + file_lock.return_value.acquire.side_effect = [Timeout("install.lock"), None] + check_and_install() + + assert file_lock.return_value.acquire.call_count == 2 + assert "Waiting for another build" in caplog.text + assert "continuing without a lock" not in caplog.text + + def test_install_from_before_the_filter_still_checks_the_names( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """An install with every project fetches nothing, but an unknown name + is still rejected so a mistake shows on every install alike.""" + _mark_installed(nrf52_dirs) + _mark_west_initialized(nrf52_dirs.framework) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + + mock_nrf52_ops.run_command_ok.reset_mock() + mock_nrf52_ops.run_command_ok.return_value = False + include_west_project("no_such_project") + with pytest.raises(EsphomeError, match="west list failed"): + check_and_install() + + def test_install_lock_is_per_sdk_version( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """Installs of different SDK versions do not wait on each other; the + toolchain they share is only locked while it is missing.""" + _mark_installed(nrf52_dirs) + + with patch("filelock.FileLock") as file_lock: + check_and_install() + + lock_files = [Path(c.args[0]).name for c in file_lock.call_args_list] + assert lock_files == [f"sdk-v{_TEST_SDK_VERSION}.lock"] + + def test_install_runs_unlocked_where_the_filesystem_cannot_lock( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + caplog: pytest.LogCaptureFixture, + ) -> None: + """No soft-lock fallback: its marker outlives a killed build and would + hang every later one, so the install goes ahead without a lock.""" + _mark_installed(nrf52_dirs) + + with patch("filelock.FileLock") as file_lock: + file_lock.return_value.acquire.side_effect = OSError( + errno.ENOSYS, "Function not implemented" + ) + check_and_install() + + assert file_lock.call_args.kwargs == {"fallback_to_soft": False} + assert "continuing without a lock" in caplog.text + + def test_unknown_project_raises_before_anything_is_written( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """A filter naming a project the manifest lacks fetches nothing, so + the names are checked once the update resolved the manifest, and an + unknown one is never recorded as installed.""" + _mark_venv_ready(nrf52_dirs.python_env) + include_west_project("no_such_project") + # init, config and update succeed, list fails + mock_nrf52_ops.run_command_ok.side_effect = [True, True, True, False] + + with pytest.raises(EsphomeError, match="west list failed .*no_such_project"): + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "init", + "config", + "update", + "list", + ] + assert "no_such_project" in mock_nrf52_ops.run_command_ok.call_args.args[0] + assert not (nrf52_dirs.framework / ".west_projects").exists() + + def test_unknown_project_on_an_installed_sdk_fetches_nothing( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """On a finished install the names are checked first, so an unknown one + costs no fetch and leaves the workspace as it was.""" + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.framework / ".ready").touch() + (nrf52_dirs.framework / ".west_projects").write_text("zephyr", encoding="utf-8") + include_west_project("no_such_project") + # list fails before anything is fetched or changed + mock_nrf52_ops.run_command_ok.side_effect = [False] + + with pytest.raises(EsphomeError, match="west list failed"): + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == ["list"] + assert (nrf52_dirs.framework / ".west_projects").read_text( + encoding="utf-8" + ) == "zephyr" + + def test_missing_stamp_and_west_config_fetches_again( + self, + nrf52_dirs: SimpleNamespace, + mock_nrf52_ops: SimpleNamespace, + ) -> None: + """With neither the stamp nor .west/config left, nothing says what the + install has, so the wanted projects are fetched rather than assumed.""" + _mark_installed(nrf52_dirs) + include_west_project("openthread") + + check_and_install() + + assert _subcommands(mock_nrf52_ops.run_command_ok) == [ + "list", + "config", + "update", + ] + def test_toolchain_download_passes_platform_substitutions( self, nrf52_dirs: SimpleNamespace, diff --git a/tests/unit_tests/test_nrf52_toolchain.py b/tests/unit_tests/test_nrf52_toolchain.py new file mode 100644 index 00000000000..ee8f6eaf499 --- /dev/null +++ b/tests/unit_tests/test_nrf52_toolchain.py @@ -0,0 +1,54 @@ +"""Tests for the nRF52 sdk-nrf memory analysis hooks.""" + +import os +from pathlib import Path + +import pytest + +from esphome.components.nrf52 import toolchain +from esphome.components.nrf52.framework import TOOLCHAIN_VERSION, get_sdk_nrf_tools_path +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.core import CORE + + +@pytest.fixture +def nrf52_build(tmp_path: Path) -> Path: + CORE.name = "test_device" + CORE.build_path = tmp_path / "build" / "test_device" + return CORE.build_path / ".pioenvs" / "test_device" / "zephyr" + + +def _set_sdk_version(version: cv.Version) -> None: + CORE.data.setdefault(KEY_CORE, {})[KEY_FRAMEWORK_VERSION] = version + + +def test_binutils_come_from_the_pinned_zephyr_sdk() -> None: + bin_path = ( + get_sdk_nrf_tools_path() + / "toolchains" + / TOOLCHAIN_VERSION + / "arm-zephyr-eabi" + / "bin" + ) + # Windows hosts get the .exe binaries (CI covers both) + suffix = ".exe" if os.name == "nt" else "" + assert toolchain.get_objdump_path() == bin_path / f"arm-zephyr-eabi-objdump{suffix}" + assert toolchain.get_readelf_path() == bin_path / f"arm-zephyr-eabi-readelf{suffix}" + + +def test_elf_uses_the_nested_layout_from_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 9, 2)) + # A flat image left by an older SDK must not be picked over the current + # layout, so the choice follows the version rather than what exists + (nrf52_build / "zephyr.elf").parent.mkdir(parents=True) + (nrf52_build / "zephyr.elf").write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr" / "zephyr.elf" + + +def test_elf_uses_the_flat_layout_before_sdk_2_9_2(nrf52_build: Path) -> None: + _set_sdk_version(cv.Version(2, 6, 1)) + nested = nrf52_build / "zephyr" / "zephyr.elf" + nested.parent.mkdir(parents=True) + nested.write_text("") + assert toolchain.get_elf_path() == nrf52_build / "zephyr.elf" diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 512c883c374..dba8977f1a4 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -407,6 +407,28 @@ def test_convert_libraries_redownloads_when_manifest_missing( assert top[0].data["name"] == "A" +def test_convert_libraries_manifest_optional_uses_default_layout( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A backend accepting manifest-less libraries (the host build, like + PlatformIO's native platform) gets a default manifest instead of a + re-download that would repeat every build.""" + calls = _patch_download_without_manifest( + monkeypatch, tmp_path, manifest_on_force=True + ) + emitted: list[ConvertedLibrary] = [] + backend = _backend(emit=emitted.append) + backend.manifest_optional = True + + with caplog.at_level(logging.DEBUG, logger="esphome.platformio.library"): + top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend) + + assert calls == [False] + assert top[0].data == {"name": "esphome/A"} + assert emitted == top + assert "has no manifest; using PlatformIO's default layout" in caplog.text + + def test_convert_libraries_raises_when_manifest_missing_after_retry( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -1251,3 +1273,50 @@ def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet( _backend(), ) assert "has no version to resolve" not in caplog.text + + +def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None: + """symlink:// is PlatformIO's other spelling for a local library folder.""" + src = setup_core / "lib_dev" + (src / "src").mkdir(parents=True) + (src / "library.json").write_text(json.dumps({"name": "benchmark"})) + url = src.as_uri().replace("file://", "symlink://", 1) + + # Both the name=URL form and an explicit repository take the scheme + for library in ( + Library(f"benchmark={url}", None, None), + Library("benchmark", None, url), + ): + top = convert_libraries([library], _backend()) + assert isinstance(top[0].source, LocalSource) + assert top[0].source_path == src + + +def test_convert_libraries_incompatible_names_the_platform_without_a_framework( + setup_core: Path, +) -> None: + """The host has no framework; the error must not read 'compatible with None'.""" + src = setup_core / "lib_dev" + src.mkdir() + (src / "library.json").write_text( + json.dumps({"name": "Only32", "platforms": "espressif32"}) + ) + backend = _backend() + backend.platform = "native" + backend.framework = None + with pytest.raises(RuntimeError, match="Only32 is not compatible with native"): + convert_libraries([Library("Only32", None, src.as_uri())], backend) + + +@pytest.mark.parametrize( + ("url", "message"), + [ + ("symlink://host/lib", "Unsupported host in symlink:// library URL"), + ("symlink:lib_dev", "symlink:// library URL .* must be an absolute"), + ], +) +def test_convert_libraries_symlink_url_errors_name_the_scheme( + setup_core: Path, url: str, message: str +) -> None: + with pytest.raises(RuntimeError, match=message): + convert_libraries([Library("benchmark", None, url)], _backend())