diff --git a/.clang-tidy.hash b/.clang-tidy.hash index adcebadeb46..87b4ebb2c69 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -b6f8c16c1ddd222134bf4a71910b4c832e764e23caf49f9bce3280b079955fcf +8e48e836c6fc196d3da000d46eb09db243b87fe33518a74e49c8e009d756074a diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index 8c3a62cf194..1c33772c4c3 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -14,6 +14,7 @@ module.exports = { 'chained-pr', 'core', 'small-pr', + 'medium-pr', 'dashboard', 'github-actions', 'by-code-owner', diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 832fcb41dba..fc631980193 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -103,7 +103,7 @@ async function detectCoreChanges(changedFiles) { } // Strategy: PR size detection -async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD) { +async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { const labels = new Set(); if (totalChanges <= SMALL_PR_THRESHOLD) { @@ -111,6 +111,11 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange return labels; } + if (totalChanges <= MEDIUM_PR_THRESHOLD) { + labels.add('medium-pr'); + return labels; + } + const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.additions || 0), 0); diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 483d2cb6267..42588c0bc87 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -35,6 +35,7 @@ async function fetchApiData() { module.exports = async ({ github, context }) => { // Environment variables const SMALL_PR_THRESHOLD = parseInt(process.env.SMALL_PR_THRESHOLD); + const MEDIUM_PR_THRESHOLD = parseInt(process.env.MEDIUM_PR_THRESHOLD); const MAX_LABELS = parseInt(process.env.MAX_LABELS); const TOO_BIG_THRESHOLD = parseInt(process.env.TOO_BIG_THRESHOLD); const COMPONENT_LABEL_THRESHOLD = parseInt(process.env.COMPONENT_LABEL_THRESHOLD); @@ -120,7 +121,7 @@ module.exports = async ({ github, context }) => { detectNewComponents(prFiles), detectNewPlatforms(prFiles, apiData), detectCoreChanges(changedFiles), - detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, TOO_BIG_THRESHOLD), + detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), detectDashboardChanges(changedFiles), detectGitHubActionsChanges(changedFiles), detectCodeOwner(github, context, changedFiles), diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 6fcb50b70a7..6376cf877e5 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -12,6 +12,7 @@ permissions: env: SMALL_PR_THRESHOLD: 30 + MEDIUM_PR_THRESHOLD: 100 MAX_LABELS: 15 TOO_BIG_THRESHOLD: 1000 COMPONENT_LABEL_THRESHOLD: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c9e8c58bcc..f7710589c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,7 @@ jobs: script/build_codeowners.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check + script/generate-rp2040-boards.py --check pytest: name: Run pytest @@ -170,6 +171,8 @@ jobs: - common outputs: integration-tests: ${{ steps.determine.outputs.integration-tests }} + integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }} + integration-test-files: ${{ steps.determine.outputs.integration-test-files }} clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} @@ -210,6 +213,8 @@ jobs: # Extract individual fields echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT + echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT + echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT @@ -261,9 +266,20 @@ jobs: - name: Register matcher run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - name: Run integration tests + env: + INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }} + INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }} run: | . venv/bin/activate - pytest -vv --no-cov --tb=native -n auto tests/integration/ + if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then + echo "Running all integration tests" + pytest -vv --no-cov --tb=native -n auto tests/integration/ + else + # Parse JSON array into bash array to avoid shell expansion issues + mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]') + echo "Running ${#test_files[@]} specific integration tests" + pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" + fi cpp-unit-tests: name: Run C++ unit tests @@ -945,13 +961,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index c2eb886913e..34ff934b773 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -10,6 +10,9 @@ name: Codeowner Approved Label on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] + branches-ignore: + - release + - beta permissions: issues: write @@ -55,18 +58,24 @@ jobs: return; } - if (action === LabelAction.ADD) { - await github.rest.issues.addLabels({ - owner, repo, issue_number: pr_number, labels: [LABEL_NAME] - }); - console.log(`Added '${LABEL_NAME}' label`); - } else if (action === LabelAction.REMOVE) { - try { + try { + if (action === LabelAction.ADD) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr_number, labels: [LABEL_NAME] + }); + console.log(`Added '${LABEL_NAME}' label`); + } else if (action === LabelAction.REMOVE) { await github.rest.issues.removeLabel({ owner, repo, issue_number: pr_number, name: LABEL_NAME }); console.log(`Removed '${LABEL_NAME}' label`); - } catch (error) { - if (error.status !== 404) throw error; + } + } catch (error) { + if (error.status === 403) { + console.log(`Warning: insufficient permissions to update label (expected for fork PRs)`); + } else if (error.status === 404) { + console.log(`Label '${LABEL_NAME}' not present, nothing to remove`); + } else { + throw error; } } diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 02bf0e4a29e..a89c03ba042 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -13,6 +13,9 @@ on: # Needs to be pull_request_target to get write permissions pull_request_target: types: [opened, reopened, synchronize, ready_for_review] + branches-ignore: + - release + - beta permissions: pull-requests: write diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 198b9a6b25c..2ad023ed1b0 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -65,6 +65,18 @@ jobs: return; } + // Check for angle brackets not wrapped in backticks. + // Astro docs MDX treats bare < as JSX component opening tags. + const stripped = title.replace(/`[^`]*`/g, ''); + if (/[<>]/.test(stripped)) { + core.setFailed( + 'PR title contains `<` or `>` not wrapped in backticks.\n' + + 'Astro docs MDX interprets bare `<` as JSX components.\n' + + 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support' + ); + return; + } + // Check title starts with [tag] prefix const bracketPattern = /^\[\w+\]/; if (!bracketPattern.test(title)) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f68e9c873f..0ed41d99c7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download digests - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: digests-* path: /tmp/digests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b036da6ef16..5e2bfe09cee 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.5 + rev: v0.15.6 hooks: # Run the linter. - id: ruff @@ -37,7 +37,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.20.0 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py311-plus] diff --git a/CODEOWNERS b/CODEOWNERS index d60dbc729d9..e72b1647614 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -132,6 +132,7 @@ esphome/components/dashboard_import/* @esphome/core esphome/components/datetime/* @jesserockz @rfdarter esphome/components/debug/* @esphome/core esphome/components/delonghi/* @grob6000 +esphome/components/dew_point/* @CFlix esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter @@ -410,6 +411,7 @@ esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/rp2040/* @jesserockz +esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz esphome/components/rpi_dpi_rgb/* @clydebarrow @@ -435,6 +437,7 @@ esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core +esphome/components/serial_proxy/* @kbx81 esphome/components/sfa30/* @ghsensdev esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw @@ -457,6 +460,7 @@ esphome/components/sonoff_d1/* @anatoly-savchenkov esphome/components/sound_level/* @kahrendt esphome/components/speaker/* @jesserockz @kahrendt esphome/components/speaker/media_player/* @kahrendt @synesthesiam +esphome/components/speaker_source/* @kahrendt esphome/components/spi/* @clydebarrow @esphome/core esphome/components/spi_device/* @clydebarrow esphome/components/spi_led_strip/* @clydebarrow diff --git a/Doxyfile b/Doxyfile index 1a9e0b4e10f..cfdb74bd196 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.3.0-dev +PROJECT_NUMBER = 2026.4.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/__main__.py b/esphome/__main__.py index 16366c42a87..4b0fc2cec7f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -65,6 +65,7 @@ from esphome.util import ( detect_rp2040_bootsel, get_picotool_path, get_serial_ports, + is_picotool_usb_permission_error, list_yaml_files, run_external_command, run_external_process, @@ -73,6 +74,8 @@ from esphome.util import ( _LOGGER = logging.getLogger(__name__) +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] + # Maximum buffer size for serial log reading to prevent unbounded memory growth SERIAL_BUFFER_MAX_SIZE = 65536 @@ -85,6 +88,12 @@ _RP2040_BOOTSEL_INSTRUCTIONS = ( "Then run the upload command again." ) +_RP2040_UDEV_HINT = ( + "You may need to add a udev rule for RP2040 devices. " + "See: https://github.com/raspberrypi/picotool" + "/blob/master/udev/60-picotool.rules" +) + # Special non-component keys that appear in configs _NON_COMPONENT_KEYS = frozenset( { @@ -260,13 +269,17 @@ def choose_upload_log_host( ] # Add RP2040 BOOTSEL device option when uploading + bootsel_permission_error = False if ( purpose == Purpose.UPLOADING and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 and (picotool := _find_picotool()) is not None - and detect_rp2040_bootsel(picotool) > 0 ): - options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL")) + bootsel = detect_rp2040_bootsel(picotool) + if bootsel.device_count > 0: + options.append(("RP2040 BOOTSEL (via picotool)", "BOOTSEL")) + elif bootsel.permission_error: + bootsel_permission_error = True if purpose == Purpose.LOGGING: if has_mqtt_logging(): @@ -291,6 +304,13 @@ def choose_upload_log_host( and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): + if bootsel_permission_error: + _LOGGER.warning( + "An RP2040 device in BOOTSEL mode was detected but could " + "not be accessed due to USB permissions." + ) + if sys.platform.startswith("linux"): + _LOGGER.warning(_RP2040_UDEV_HINT) if not options: raise EsphomeError( f"No RP2040 device found. {_RP2040_BOOTSEL_INSTRUCTIONS}" @@ -687,12 +707,12 @@ def _make_crystal_freq_callback( configured_freq: int, ) -> Callable[[str], str | None]: """Create a callback that checks esptool crystal frequency output.""" - crystal_re = re.compile(r"Crystal frequency:\s+(\d+(?:\.\d+)?)\s*MHz") + crystal_re = re.compile(r"Crystal frequency:\s+(\d+)\s*MHz") def check_crystal_line(line: str) -> str | None: if not (match := crystal_re.search(line)): return None - detected = int(float(match.group(1))) + detected = int(match.group(1)) if detected == configured_freq: return None return ( @@ -705,9 +725,7 @@ def _make_crystal_freq_callback( f" esp32:\n" f" framework:\n" f" sdkconfig_options:\n" - f" CONFIG_XTAL_FREQ_{detected}: 'y'\n" - f" CONFIG_XTAL_FREQ_{configured_freq}: 'n'\n" - f' CONFIG_XTAL_FREQ: "{detected}"\033[0m\n\n' + f" CONFIG_XTAL_FREQ_{detected}: 'y'\033[0m\n\n" ) return check_crystal_line @@ -742,7 +760,11 @@ def upload_using_esptool( mcu = get_esp32_variant().lower() line_callbacks: list[Callable[[str], str | None]] = [] - if CORE.is_esp32 and (configured_freq := _get_configured_xtal_freq()) is not None: + if ( + CORE.is_esp32 + and file is None + and (configured_freq := _get_configured_xtal_freq()) is not None + ): line_callbacks.append(_make_crystal_freq_callback(configured_freq)) def run_esptool(baud_rate): @@ -871,13 +893,10 @@ def upload_using_picotool(config: ConfigType) -> int: if stderr: for line in stderr.splitlines(): safe_print(line) - if "LIBUSB_ERROR_ACCESS" in stderr or "Permission denied" in stderr: + if is_picotool_usb_permission_error(stderr): msg = "Permission denied accessing USB device." if sys.platform.startswith("linux"): - msg += ( - " You may need to add udev rules for RP2040 devices." - " See: https://github.com/raspberrypi/picotool#linux-permissions" - ) + msg += f" {_RP2040_UDEV_HINT}" _LOGGER.error(msg) else: _LOGGER.error("picotool upload failed (exit code %d).", result.returncode) @@ -902,9 +921,11 @@ def _wait_for_serial_port( """ def _port_found() -> bool: - ports = get_serial_ports() if port is not None: - return any(p.path == port for p in ports) + if os.name == "posix": + return os.path.exists(port) + return any(p.path == port for p in get_serial_ports()) + ports = get_serial_ports() if known_ports is not None: return any(p.path not in known_ports for p in ports) return bool(ports) @@ -1288,9 +1309,8 @@ def command_update_all(args: ArgsProtocol) -> int | None: files = list_yaml_files(args.configuration) def build_command(f): - if CORE.dashboard: - return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"] - return ["esphome", "run", f, "--no-logs", "--device", "OTA"] + dashboard = ["--dashboard"] if CORE.dashboard else [] + return [*ESPHOME_COMMAND, *dashboard, "run", f, "--no-logs", "--device", "OTA"] return run_multiple_configs(files, build_command) @@ -1439,7 +1459,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path.write_text(new_raw, encoding="utf-8") - rc = run_external_process("esphome", "config", str(new_path)) + rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() @@ -1457,7 +1477,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: cli_args.insert(0, "--dashboard") try: - rc = run_external_process("esphome", *cli_args) + rc = run_external_process(*ESPHOME_COMMAND, *cli_args) except KeyboardInterrupt: rc = 1 if rc != 0: @@ -1854,7 +1874,7 @@ def run_esphome(argv): # argv[0] is the program path, skip it since we prefix with "esphome" def build_command(f): return ( - ["esphome"] + [*ESPHOME_COMMAND] + [arg for arg in argv[1:] if arg not in args.configuration] + [str(f)] ) diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index bf1bcbfa050..7954c228224 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -1,6 +1,6 @@ """Memory usage analyzer for ESPHome compiled binaries.""" -from collections import defaultdict +from collections import Counter, defaultdict from dataclasses import dataclass, field import logging from pathlib import Path @@ -40,6 +40,15 @@ _READELF_SECTION_PATTERN = re.compile( r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)" ) +# Regex for extracting call targets from objdump disassembly +# Matches direct call instructions across architectures: +# Xtensa: call0/call4/call8/call12/callx0/callx4/callx8/callx12 +# ARM: bl/blx +# Captures the mangled symbol name inside angle brackets. +_CALL_TARGET_PATTERN = re.compile( + r"\t(?:call(?:0|4|8|12)|callx(?:0|4|8|12)|blx?)\s+[\da-fA-F]+ <([^>]+)>" +) + # Component category prefixes _COMPONENT_PREFIX_ESPHOME = "[esphome]" _COMPONENT_PREFIX_EXTERNAL = "[external]" @@ -197,6 +206,8 @@ class MemoryAnalyzer: self._lib_hash_to_name: dict[str, str] = {} # Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns" self._heuristic_to_lib: dict[str, str] = {} + # Function call counts: mangled_name -> call_count + self._function_call_counts: Counter[str] = Counter() def analyze(self) -> dict[str, ComponentMemory]: """Analyze the ELF file and return component memory usage.""" @@ -206,6 +217,7 @@ class MemoryAnalyzer: self._categorize_symbols() self._analyze_cswtch_symbols() self._analyze_sdk_libraries() + self._analyze_function_calls() return dict(self.components) def _parse_sections(self) -> None: @@ -384,8 +396,9 @@ class MemoryAnalyzer: return _LOGGER.info("Demangling %d symbols", len(symbols)) - self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path) - _LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache)) + demangled = batch_demangle(symbols, objdump_path=self.objdump_path) + self._demangle_cache.update(demangled) + _LOGGER.info("Successfully demangled %d symbols", len(demangled)) def _demangle_symbol(self, symbol: str) -> str: """Get demangled C++ symbol name from cache.""" @@ -1011,6 +1024,43 @@ class MemoryAnalyzer: total_size, ) + def _analyze_function_calls(self) -> None: + """Count function call sites by parsing disassembly output. + + Parses direct call instructions (call0/call8/bl/blx) from objdump -d + to count how many times each function is called. This helps identify + inlining candidates — frequently called small functions benefit most + from inlining. + """ + result = run_tool( + [self.objdump_path, "-d", str(self.elf_path)], + timeout=60, + ) + if result is None or result.returncode != 0: + _LOGGER.debug("Failed to disassemble ELF for function call analysis") + return + + self._function_call_counts = Counter( + match.group(1) + for line in result.stdout.splitlines() + if (match := _CALL_TARGET_PATTERN.search(line)) + ) + + # Demangle any call targets not already in the cache + missing = [ + name + for name in self._function_call_counts + if name not in self._demangle_cache + ] + if missing: + self._batch_demangle_symbols(missing) + + _LOGGER.debug( + "Function call analysis: %d unique targets, %d total calls", + len(self._function_call_counts), + sum(self._function_call_counts.values()), + ) + def get_unattributed_ram(self) -> tuple[int, int, int]: """Get unattributed RAM sizes (SDK/framework overhead). diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index d38f1f403a8..644c81926aa 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -232,6 +232,110 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append(f" {size:>6,} B {sym_name}") lines.append("") + # Number of top called functions to show + TOP_CALLS_LIMIT: int = 50 + # Number of inlining candidates to show + INLINE_CANDIDATES_LIMIT: int = 25 + # Maximum function size in bytes to consider for inlining + INLINE_SIZE_THRESHOLD: int = 16 + + def _build_symbol_sizes(self) -> dict[str, int]: + """Build a size lookup from all component symbols: mangled_name -> size.""" + return { + symbol: size + for symbols in self._component_symbols.values() + for symbol, _, size, _ in symbols + } + + def _format_call_row( + self, index: int, mangled: str, count: int, symbol_sizes: dict[str, int] + ) -> str: + """Format a single row for call frequency tables.""" + demangled = self._demangle_cache.get(mangled, mangled) + if len(demangled) > 80: + demangled = f"{demangled[:77]}..." + size = symbol_sizes.get(mangled) + size_str = f"{size:>5,} B" if size is not None else " ?" + return f"{index:>3} {count:>5} {size_str} {demangled}" + + def _add_call_table_header(self, lines: list[str]) -> None: + """Add the header row for call frequency tables.""" + lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function") + lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}") + + def _add_function_call_analysis(self, lines: list[str]) -> None: + """Add function call frequency analysis section. + + Shows the most frequently called functions by call site count. + """ + self._add_section_header(lines, "Top Called Functions") + + symbol_sizes = self._build_symbol_sizes() + + # Sort by call count descending + sorted_calls = sorted( + self._function_call_counts.items(), key=lambda x: x[1], reverse=True + ) + + self._add_call_table_header(lines) + + for i, (mangled, count) in enumerate(sorted_calls[: self.TOP_CALLS_LIMIT]): + lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes)) + + total_calls = sum(self._function_call_counts.values()) + lines.append("") + lines.append( + f"Total: {len(self._function_call_counts)} unique targets, " + f"{total_calls:,} call sites" + ) + lines.append("") + + def _add_inline_candidates(self, lines: list[str]) -> None: + """Add inlining candidates section. + + Shows frequently called functions that are small enough to benefit + from inlining (< 16 bytes). These are the best candidates for + reducing call overhead. + """ + self._add_section_header( + lines, + f"Inlining Candidates (<{self.INLINE_SIZE_THRESHOLD} B, by call count)", + ) + + symbol_sizes = self._build_symbol_sizes() + + # Filter to small functions with known size, sort by call count + candidates = sorted( + ( + (mangled, count) + for mangled, count in self._function_call_counts.items() + if mangled in symbol_sizes + and symbol_sizes[mangled] < self.INLINE_SIZE_THRESHOLD + ), + key=lambda x: x[1], + reverse=True, + ) + + if not candidates: + lines.append("No candidates found.") + lines.append("") + return + + self._add_call_table_header(lines) + + for i, (mangled, count) in enumerate( + candidates[: self.INLINE_CANDIDATES_LIMIT] + ): + lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes)) + + lines.append("") + lines.append( + f"Showing top {min(len(candidates), self.INLINE_CANDIDATES_LIMIT)} " + f"of {len(candidates)} functions under " + f"{self.INLINE_SIZE_THRESHOLD} B" + ) + lines.append("") + def generate_report(self, detailed: bool = False) -> str: """Generate a formatted memory report.""" components = sorted( @@ -534,6 +638,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): if self._cswtch_symbols: self._add_cswtch_analysis(lines) + # Function call frequency analysis + if self._function_call_counts: + self._add_function_call_analysis(lines) + self._add_inline_candidates(lines) + lines.append( "Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included." ) diff --git a/esphome/automation.py b/esphome/automation.py index d9b8b2ec574..36ab30b654a 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,3 +1,5 @@ +import logging + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -57,22 +59,41 @@ def maybe_conf(conf, *validators): return validate +_LOGGER = logging.getLogger(__name__) + + def register_action( name: str, action_type: MockObjClass, schema: cv.Schema, *, - synchronous: bool = False, + synchronous: bool | None = None, ): """Register an action type. - Actions default to ``synchronous=False`` (safe default), meaning string - arguments use owning std::string to prevent dangling references. + All callers must pass ``synchronous`` explicitly. - Set ``synchronous=True`` only for actions that complete synchronously - and never store trigger arguments for later execution. This allows - the code generator to use non-owning StringRef for zero-copy access. + ``synchronous=True`` — the action never defers ``play_next_()`` to a + later point (callback, timer, or ``loop()``). Trigger arguments are + only used during the initial call, so string args can use non-owning + StringRef for zero-copy access. + + ``synchronous=False`` — the action defers ``play_next_()`` via a + callback, timer, or ``Component::loop()``. Trigger arguments must + outlive the initial call, so string args use owning std::string to + prevent dangling references. """ + if synchronous is None: + _LOGGER.warning( + "register_action('%s', ...) is missing the synchronous= parameter. " + "Defaulting to synchronous=False (safe but prevents StringRef " + "optimization). Check the C++ class: use synchronous=False if " + "play_next_() is deferred to a callback, timer, or loop(); " + "use synchronous=True if play_next_() always runs before the " + "initial play/play_complex call returns", + name, + ) + synchronous = False return ACTION_REGISTRY.register(name, action_type, schema, synchronous=synchronous) @@ -353,6 +374,7 @@ async def component_is_idle_condition_to_code( "delay", DelayAction, cv.templatable(cv.positive_time_period_milliseconds), + synchronous=False, ) async def delay_action_to_code( config: ConfigType, @@ -465,7 +487,7 @@ _validate_wait_until = cv.maybe_simple_value( ) -@register_action("wait_until", WaitUntilAction, _validate_wait_until) +@register_action("wait_until", WaitUntilAction, _validate_wait_until, synchronous=False) async def wait_until_action_to_code( config: ConfigType, action_id: ID, @@ -611,7 +633,7 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: Non-synchronous actions (delay, wait_until, script.wait, etc.) store trigger args for later execution, making non-owning types like StringRef - unsafe. Actions that haven't been audited default to non-synchronous. + unsafe. """ if isinstance(actions, list): return any(has_non_synchronous_actions(item) for item in actions) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index f45efb82c1d..9df9b1069c5 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -72,7 +72,7 @@ def get_component_cmakelists(minimal: bool = False) -> str: # Extract compile definitions from build flags (-DXXX -> XXX) compile_defs = [flag[2:] for flag in CORE.build_flags if flag.startswith("-D")] - compile_defs_str = "\n ".join(compile_defs) if compile_defs else "" + compile_defs_str = "\n ".join(sorted(compile_defs)) if compile_defs else "" # Extract compile options (-W flags, excluding linker flags) compile_opts = [ @@ -80,11 +80,11 @@ def get_component_cmakelists(minimal: bool = False) -> str: for flag in CORE.build_flags if flag.startswith("-W") and not flag.startswith("-Wl,") ] - compile_opts_str = "\n ".join(compile_opts) if compile_opts else "" + compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else "" # Extract linker options (-Wl, flags) link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")] - link_opts_str = "\n ".join(link_opts) if link_opts else "" + link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else "" return f"""\ # Auto-generated by ESPHome diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 91cf4eaafcc..cf48ccd9c3d 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -22,7 +22,8 @@ namespace adc { #ifdef USE_ESP32 // clang-format off -#if (ESP_IDF_VERSION_MAJOR == 5 && \ +#if ESP_IDF_VERSION_MAJOR >= 6 || \ + (ESP_IDF_VERSION_MAJOR == 5 && \ ((ESP_IDF_VERSION_MINOR == 0 && ESP_IDF_VERSION_PATCH >= 5) || \ (ESP_IDF_VERSION_MINOR == 1 && ESP_IDF_VERSION_PATCH >= 3) || \ (ESP_IDF_VERSION_MINOR >= 2)) \ diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp index 8496e0f41e4..a79707e2347 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2040.cpp @@ -8,6 +8,13 @@ #endif // CYW43_USES_VSYS_PIN #include +// PICO_VSYS_PIN is defined in pico-sdk board headers (e.g. boards/pico2.h), +// but the Arduino framework's config_autogen.h includes a generic board header +// that doesn't define it. Provide the standard value (pin 29) as a fallback. +#ifndef PICO_VSYS_PIN +#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage) +#endif + namespace esphome { namespace adc { diff --git a/esphome/components/addressable_light/addressable_light_display.cpp b/esphome/components/addressable_light/addressable_light_display.cpp index 16fab15b17b..329620bcf04 100644 --- a/esphome/components/addressable_light/addressable_light_display.cpp +++ b/esphome/components/addressable_light/addressable_light_display.cpp @@ -58,7 +58,10 @@ void HOT AddressableLightDisplay::draw_absolute_pixel_internal(int x, int y, Col if (this->pixel_mapper_f_.has_value()) { // Params are passed by reference, so they may be modified in call. - this->addressable_light_buffer_[(*this->pixel_mapper_f_)(x, y)] = color; + int index = (*this->pixel_mapper_f_)(x, y); + if (index < 0 || static_cast(index) >= this->addressable_light_buffer_.size()) + return; + this->addressable_light_buffer_[index] = color; } else { this->addressable_light_buffer_[y * this->get_width_internal() + x] = color; } diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h index 53f8604b7de..d9b8680547f 100644 --- a/esphome/components/addressable_light/addressable_light_display.h +++ b/esphome/components/addressable_light/addressable_light_display.h @@ -33,7 +33,7 @@ class AddressableLightDisplay : public display::DisplayBuffer { // - Save the current effect index. this->last_effect_index_ = light_state_->get_current_effect_index(); // - Disable any current effect. - light_state_->make_call().set_effect(0).perform(); + light_state_->make_call().set_effect(uint32_t{0}).perform(); } } enabled_ = enabled; diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py index 8f0f3729516..4cfa9e67ec1 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -92,6 +92,7 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( "ags10.new_i2c_address", AGS10NewI2cAddressAction, AGS10_NEW_I2C_ADDRESS_SCHEMA, + synchronous=True, ) async def ags10newi2caddress_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -121,6 +122,7 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( "ags10.set_zero_point", AGS10SetZeroPointAction, AGS10_SET_ZERO_POINT_SCHEMA, + synchronous=True, ) async def ags10setzeropoint_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py index da7a54df54d..a644638f696 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -34,7 +34,10 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "aic3204.set_auto_mute_mode", SetAutoMuteAction, SET_AUTO_MUTE_ACTION_SCHEMA + "aic3204.set_auto_mute_mode", + SetAutoMuteAction, + SET_AUTO_MUTE_ACTION_SCHEMA, + synchronous=True, ) async def aic3204_set_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index b8555861527..aefb18d25cc 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -243,7 +243,10 @@ async def new_alarm_control_panel(config, *args): @automation.register_action( - "alarm_control_panel.arm_away", ArmAwayAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.arm_away", + ArmAwayAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_arm_away_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -255,7 +258,10 @@ async def alarm_action_arm_away_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.arm_home", ArmHomeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.arm_home", + ArmHomeAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_arm_home_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -267,7 +273,10 @@ async def alarm_action_arm_home_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.arm_night", ArmNightAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.arm_night", + ArmNightAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_arm_night_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -279,7 +288,10 @@ async def alarm_action_arm_night_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.disarm", DisarmAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.disarm", + DisarmAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_disarm_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -291,7 +303,10 @@ async def alarm_action_disarm_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.pending", PendingAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.pending", + PendingAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_pending_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -299,7 +314,10 @@ async def alarm_action_pending_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.triggered", TriggeredAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.triggered", + TriggeredAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_trigger_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -307,7 +325,10 @@ async def alarm_action_trigger_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.chime", ChimeAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.chime", + ChimeAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) async def alarm_action_chime_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -315,7 +336,10 @@ async def alarm_action_chime_to_code(config, action_id, template_arg, args): @automation.register_action( - "alarm_control_panel.ready", ReadyAction, ALARM_CONTROL_PANEL_ACTION_SCHEMA + "alarm_control_panel.ready", + ReadyAction, + ALARM_CONTROL_PANEL_ACTION_SCHEMA, + synchronous=True, ) @automation.register_condition( "alarm_control_panel.ready", diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index c4ac7adb236..e9630f5266b 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -69,9 +69,15 @@ SET_FRAME_SCHEMA = cv.Schema( ) -@automation.register_action("animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA) -@automation.register_action("animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA) -@automation.register_action("animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA) +@automation.register_action( + "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +) async def animation_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index b625f92115d..f21230b075e 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -144,9 +144,12 @@ void Anova::update() { return; if (this->current_request_ < 2) { - auto *pkt = this->codec_->get_read_device_status_request(); - if (this->current_request_ == 0) - this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c'); + AnovaPacket *pkt; + if (this->current_request_ == 0) { + pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c'); + } else { + pkt = this->codec_->get_read_device_status_request(); + } auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index dd99862cc27..9772e6afca7 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -453,7 +453,7 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.10") + cg.add_library("esphome/noise-c", "0.1.11") else: cg.add_define("USE_API_PLAINTEXT") @@ -712,6 +712,7 @@ API_RESPOND_ACTION_SCHEMA = cv.All( "api.respond", APIRespondAction, API_RESPOND_ACTION_SCHEMA, + synchronous=True, ) async def api_respond_to_code( config: ConfigType, diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0e28e19db24..81efe5ece58 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2618,6 +2618,14 @@ enum SerialProxyRequestType { SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent) } +enum SerialProxyStatus { + SERIAL_PROXY_STATUS_OK = 0; // Completed successfully; TX drain confirmed + SERIAL_PROXY_STATUS_ASSUMED_SUCCESS = 1; // Platform cannot confirm TX drain; success assumed + SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error + SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed + SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance +} + // Generic request message for simple serial proxy operations message SerialProxyRequest { option (id) = 144; @@ -2628,6 +2636,18 @@ message SerialProxyRequest { SerialProxyRequestType type = 2; // Request type } +// Response to a SerialProxyRequest (e.g. flush completion or failure) +message SerialProxyRequestResponse { + option (id) = 147; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; // Instance index (0-based) + SerialProxyRequestType type = 2; // Which request type this responds to + SerialProxyStatus status = 3; // Result status + string error_message = 4; // Additional detail on failure (optional) +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 8feaad51834..1d0cccf61ca 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -10,9 +10,9 @@ namespace esphome::api { /// Helper to use make_unique_for_overwrite where available (skips zero-fill), -/// falling back to make_unique on older GCC (ESP8266, BK72xx, LN882x). +/// falling back to make_unique on older GCC (ESP8266, LibreTiny). inline std::unique_ptr make_buffer(size_t n) { -#if defined(USE_ESP8266) || defined(USE_BK72XX) || defined(USE_LN882X) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) return std::make_unique(n); #else return std::make_unique_for_overwrite(n); @@ -44,6 +44,12 @@ class APIBuffer { this->reserve(n); this->size_ = n; // no zero-fill } + /// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size. + /// Single grow_ check regardless of argument order. + inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { + this->reserve(std::max(reserve_size, new_size)); + this->size_ = new_size; + } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 5409565c6eb..2778cdee67f 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -155,6 +155,18 @@ APIConnection::~APIConnection() { voice_assistant::global_voice_assistant->client_subscription(this, false); } #endif +#ifdef USE_ZWAVE_PROXY + if (zwave_proxy::global_zwave_proxy != nullptr && zwave_proxy::global_zwave_proxy->get_api_connection() == this) { + zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE); + } +#endif +#ifdef USE_SERIAL_PROXY + for (auto *proxy : App.get_serial_proxies()) { + if (proxy->get_api_connection() == this) { + proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE); + } + } +#endif } void APIConnection::destroy_active_iterator_() { @@ -1445,6 +1457,89 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } #endif +#ifdef USE_SERIAL_PROXY +void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range (max %u)", msg.instance, + static_cast(proxies.size())); + return; + } + proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits, + msg.data_size); +} + +void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + proxies[msg.instance]->write_from_client(msg.data, msg.data_len); +} + +void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + proxies[msg.instance]->set_modem_pins(msg.line_states); +} + +void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + SerialProxyGetModemPinsResponse resp{}; + resp.instance = msg.instance; + resp.line_states = proxies[msg.instance]->get_modem_pins(); + this->send_message(resp); +} + +void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + return; + } + switch (msg.type) { + case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE: + case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + proxies[msg.instance]->serial_proxy_request(this, msg.type); + break; + case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: { + SerialProxyRequestResponse resp{}; + resp.instance = msg.instance; + resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH; + switch (proxies[msg.instance]->flush_port()) { + case uart::FlushResult::SUCCESS: + resp.status = enums::SERIAL_PROXY_STATUS_OK; + break; + case uart::FlushResult::ASSUMED_SUCCESS: + resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS; + break; + case uart::FlushResult::TIMEOUT: + resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT; + break; + case uart::FlushResult::FAILED: + resp.status = enums::SERIAL_PROXY_STATUS_ERROR; + break; + } + this->send_message(resp); + break; + } + default: + ESP_LOGW(TAG, "Unknown serial proxy request type: %u", static_cast(msg.type)); + break; + } +} + +void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } +#endif + #ifdef USE_INFRARED uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *infrared = static_cast(entity); @@ -1666,6 +1761,16 @@ bool APIConnection::send_device_info_response_() { resp.zwave_proxy_feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); resp.zwave_home_id = zwave_proxy::global_zwave_proxy->get_home_id(); #endif +#ifdef USE_SERIAL_PROXY + size_t serial_proxy_index = 0; + for (auto const &proxy : App.get_serial_proxies()) { + if (serial_proxy_index >= SERIAL_PROXY_COUNT) + break; + auto &info = resp.serial_proxies[serial_proxy_index++]; + info.name = StringRef(proxy->get_name()); + info.port_type = proxy->get_port_type(); + } +#endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; #endif @@ -1852,11 +1957,7 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; } #endif -bool APIConnection::try_to_clear_buffer(bool log_out_of_space) { - if (this->flags_.remove) - return false; - if (this->helper_->can_write_without_blocking()) - return true; +bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { delay(0); APIError err = this->helper_->loop(); if (err != APIError::OK) { @@ -1924,8 +2025,7 @@ uint16_t APIConnection::encode_to_buffer(uint32_t calculated_size, MessageEncode // Batch message second or later // Add padding for previous message footer + this message header size_t current_size = shared_buf.size(); - shared_buf.reserve(current_size + total_calculated_size); - shared_buf.resize(current_size + footer_size + header_padding); + shared_buf.reserve_and_resize(current_size + total_calculated_size, current_size + footer_size + header_padding); } // Pre-resize buffer to include payload, then encode through raw pointer diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3d864a7cf23..85c8e777a94 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -14,6 +14,12 @@ #include "api_server.h" #include "esphome/core/application.h" #include "esphome/core/component.h" +#ifdef USE_ESP32_CRASH_HANDLER +#include "esphome/components/esp32/crash_handler.h" +#endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/entity_base.h" #include "esphome/core/string_ref.h" @@ -189,6 +195,15 @@ class APIConnection final : public APIServerConnectionBase { void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) override; + void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) override; + void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) override; + void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) override; + void on_serial_proxy_request(const SerialProxyRequest &msg) override; + void send_serial_proxy_data(const SerialProxyDataReceived &msg); +#endif + #ifdef USE_EVENT void send_event(event::Event *event); #endif @@ -226,6 +241,12 @@ class APIConnection final : public APIServerConnectionBase { this->flags_.log_subscription = msg.level; if (msg.dump_config) App.schedule_dump_config(); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_log(); +#endif } #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; } @@ -254,6 +275,7 @@ class APIConnection final : public APIServerConnectionBase { return static_cast(this->flags_.connection_state) == ConnectionState::CONNECTED || this->is_authenticated(); } + bool is_marked_for_removal() const { return this->flags_.remove; } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } // Get client API version for feature detection @@ -283,9 +305,9 @@ class APIConnection final : public APIServerConnectionBase { // Reserve space for header padding + message + footer // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) // - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext) - shared_buf.reserve(total_size); - // Resize to add header padding so message encoding starts at the correct position - shared_buf.resize(header_padding); + // Reserve full size but only set initial size to header padding + // so message encoding starts at the correct position + shared_buf.reserve_and_resize(total_size, header_padding); } // Convenience overload - computes frame overhead internally @@ -295,7 +317,13 @@ class APIConnection final : public APIServerConnectionBase { this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size); } - bool try_to_clear_buffer(bool log_out_of_space); + bool try_to_clear_buffer(bool log_out_of_space) { + if (this->flags_.remove) + return false; + if (this->helper_->can_write_without_blocking()) + return true; + return this->try_to_clear_buffer_slow_(log_out_of_space); + } bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; const char *get_name() const { return this->helper_->get_client_name(); } @@ -305,6 +333,8 @@ class APIConnection final : public APIServerConnectionBase { } protected: + bool try_to_clear_buffer_slow_(bool log_out_of_space); + // Helper function to handle authentication completion void complete_authentication_(); diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index e432a976b0d..fbee2940226 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -113,10 +113,11 @@ APIError APIFrameHelper::loop() { // Common socket write error handling APIError APIFrameHelper::handle_socket_write_error_() { - if (errno == EWOULDBLOCK || errno == EAGAIN) { + const int err = errno; + if (err == EWOULDBLOCK || err == EAGAIN) { return APIError::WOULD_BLOCK; } - HELPER_LOG("Socket write failed with errno %d", errno); + HELPER_LOG("Socket write failed with errno %d", err); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; } @@ -278,11 +279,12 @@ APIError APIFrameHelper::init_common_() { APIError APIFrameHelper::handle_socket_read_result_(ssize_t received) { if (received == -1) { - if (errno == EWOULDBLOCK || errno == EAGAIN) { + const int err = errno; + if (err == EWOULDBLOCK || err == EAGAIN) { return APIError::WOULD_BLOCK; } state_ = State::FAILED; - HELPER_LOG("Socket read failed with errno %d", errno); + HELPER_LOG("Socket read failed with errno %d", err); return APIError::SOCKET_READ_FAILED; } else if (received == 0) { state_ = State::FAILED; diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f0666debeeb..e78c71507c8 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -12,6 +12,7 @@ #include "esphome/components/socket/socket.h" #include "esphome/core/application.h" #include "esphome/core/log.h" +#include "proto.h" namespace esphome::api { @@ -37,8 +38,6 @@ static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1; // Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there) static constexpr size_t MAX_MESSAGES_PER_BATCH = 34; -class ProtoWriteBuffer; - // Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars) static constexpr size_t CLIENT_INFO_NAME_MAX_LEN = 32; @@ -134,34 +133,41 @@ class APIFrameHelper { // // For log messages: Use Nagle to coalesce multiple small log packets into // fewer larger packets, reducing WiFi overhead. However, we limit batching - // to 3 messages to avoid excessive LWIP buffer pressure on memory-constrained - // devices like ESP8266. LWIP's TCP_OVERSIZE option coalesces the data into - // shared pbufs, but holding data too long waiting for Nagle's timer causes - // buffer exhaustion and dropped messages. + // to avoid excessive LWIP buffer pressure on memory-constrained devices. + // LWIP's TCP_OVERSIZE option coalesces the data into shared pbufs, but + // holding data too long waiting for Nagle's timer causes buffer exhaustion + // and dropped messages. // - // Flow: Log 1 (Nagle on) -> Log 2 (Nagle on) -> Log 3 (NODELAY, flush all) + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) + // + // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) + // Flow (ESP8266): Log 1 (Nagle on) -> Log 2 -> Log 3 (NODELAY, flush all) // void set_nodelay_for_message(bool is_log_message) { if (!is_log_message) { - if (this->nodelay_state_ != NODELAY_ON) { + if (this->nodelay_counter_) { this->set_nodelay_raw_(true); - this->nodelay_state_ = NODELAY_ON; + this->nodelay_counter_ = 0; } return; } - - // Log messages 1-3: state transitions -1 -> 1 -> 2 -> -1 (flush on 3rd) - if (this->nodelay_state_ == NODELAY_ON) { + // Log message: enable Nagle on first, flush after LOG_NAGLE_COUNT + if (!this->nodelay_counter_) this->set_nodelay_raw_(false); - this->nodelay_state_ = 1; - } else if (this->nodelay_state_ >= LOG_NAGLE_COUNT) { + if (++this->nodelay_counter_ > LOG_NAGLE_COUNT) { this->set_nodelay_raw_(true); - this->nodelay_state_ = NODELAY_ON; - } else { - this->nodelay_state_++; + this->nodelay_counter_ = 0; } } - virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; + APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { + // Resize buffer to include footer space if needed (e.g. Noise MAC) + if (frame_footer_size_) + buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); + MessageInfo msg{type, 0, + static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; + return write_protobuf_messages(buffer, std::span(&msg, 1)); + } // Write multiple protobuf messages in a single operation // messages contains (message_type, offset, length) for each message in the buffer // The buffer contains all messages with appropriate padding before each @@ -228,6 +234,17 @@ class APIFrameHelper { EXPLICIT_REJECT = 8, // Noise only }; + // Fast inline state check for read_packet/write_protobuf_messages hot path. + // Returns OK only in DATA state; maps CLOSED/FAILED to BAD_STATE and any + // other intermediate state to WOULD_BLOCK. + inline APIError ESPHOME_ALWAYS_INLINE check_data_state_() const { + if (this->state_ == State::DATA) + return APIError::OK; + if (this->state_ == State::CLOSED || this->state_ == State::FAILED) + return APIError::BAD_STATE; + return APIError::WOULD_BLOCK; + } + // Containers (size varies, but typically 12+ bytes on 32-bit) std::array, API_MAX_SEND_QUEUE> tx_buf_; APIBuffer rx_buf_; @@ -243,12 +260,17 @@ class APIFrameHelper { uint8_t tx_buf_head_{0}; uint8_t tx_buf_tail_{0}; uint8_t tx_buf_count_{0}; - // Nagle batching state for log messages. NODELAY_ON (-1) means NODELAY is enabled - // (immediate send). Values 1-2 count log messages in the current Nagle batch. - // After LOG_NAGLE_COUNT logs, we switch to NODELAY to flush and reset. - static constexpr int8_t NODELAY_ON = -1; - static constexpr int8_t LOG_NAGLE_COUNT = 2; - int8_t nodelay_state_{NODELAY_ON}; + // Nagle batching counter for log messages. 0 means NODELAY is enabled (immediate send). + // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. + // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. + // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. + // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. +#ifdef USE_ESP8266 + static constexpr uint8_t LOG_NAGLE_COUNT = 2; +#else + static constexpr uint8_t LOG_NAGLE_COUNT = 3; +#endif + uint8_t nodelay_counter_{0}; // Internal helper to set TCP_NODELAY socket option void set_nodelay_raw_(bool enable) { diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 22a477aa59d..b635d84f168 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -207,9 +207,7 @@ APIError APINoiseFrameHelper::try_read_frame_() { // During handshake, rx_buf_.size() is used in prologue construction, so // the buffer must be exactly msg_size to avoid prologue mismatch.) uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0); - if (this->rx_buf_.size() != alloc_size) { - this->rx_buf_.resize(alloc_size); - } + this->rx_buf_.resize(alloc_size); if (rx_buf_len_ < msg_size) { // more data to read @@ -260,10 +258,13 @@ APIError APINoiseFrameHelper::state_action_() { // ignore contents, may be used in future for flags // Resize for: existing prologue + 2 size bytes + frame data size_t old_size = this->prologue_.size(); - this->prologue_.resize(old_size + 2 + this->rx_buf_.size()); - this->prologue_[old_size] = (uint8_t) (this->rx_buf_.size() >> 8); - this->prologue_[old_size + 1] = (uint8_t) this->rx_buf_.size(); - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), this->rx_buf_.size()); + size_t rx_size = this->rx_buf_.size(); + this->prologue_.resize(old_size + 2 + rx_size); + this->prologue_[old_size] = (uint8_t) (rx_size >> 8); + this->prologue_[old_size + 1] = (uint8_t) rx_size; + if (rx_size > 0) { + std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + } state_ = State::SERVER_HELLO; } @@ -375,6 +376,7 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso #ifdef USE_STORE_LOG_STR_IN_FLASH // On ESP8266 with flash strings, we need to use PROGMEM-aware functions size_t reason_len = strlen_P(reinterpret_cast(reason)); + reason_len = std::min(reason_len, sizeof(data) - 1); if (reason_len > 0) { memcpy_P(data + 1, reinterpret_cast(reason), reason_len); } @@ -382,6 +384,7 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso // Normal memory access const char *reason_str = LOG_STR_ARG(reason); size_t reason_len = strlen(reason_str); + reason_len = std::min(reason_len, sizeof(data) - 1); if (reason_len > 0) { // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string std::memcpy(data + 1, reason_str, reason_len); @@ -397,14 +400,9 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso state_ = orig_state; } APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { - APIError aerr = this->state_action_(); - if (aerr != APIError::OK) { + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) return aerr; - } - - if (this->state_ != State::DATA) { - return APIError::WOULD_BLOCK; - } aerr = this->try_read_frame_(); if (aerr != APIError::OK) @@ -452,23 +450,10 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = type; return APIError::OK; } -APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - // Resize to include MAC space (required for Noise encryption) - buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); - MessageInfo msg{type, 0, - static_cast(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; - return write_protobuf_messages(buffer, std::span(&msg, 1)); -} - APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - APIError aerr = state_action_(); - if (aerr != APIError::OK) { + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) return aerr; - } - - if (state_ != State::DATA) { - return APIError::WOULD_BLOCK; - } if (messages.empty()) { return APIError::OK; diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 83410febb26..a6b17ff3b92 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,7 +22,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index e2bb56e0acf..e97b558fa39 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -128,46 +128,44 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // Skip indicator byte at position 0 uint8_t varint_pos = 1; - uint32_t consumed = 0; - auto msg_size_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed); + // rx_header_buf_pos_ >= 3 and varint_pos == 1, so len >= 2 + auto msg_size_varint = ProtoVarInt::parse_non_empty(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos); if (!msg_size_varint.has_value()) { // not enough data there yet continue; } - if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) { + if (msg_size_varint.value > MAX_MESSAGE_SIZE) { state_ = State::FAILED; - HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), - MAX_MESSAGE_SIZE); + HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", + static_cast(msg_size_varint.value), MAX_MESSAGE_SIZE); return APIError::BAD_DATA_PACKET; } - rx_header_parsed_len_ = msg_size_varint->as_uint16(); + rx_header_parsed_len_ = static_cast(msg_size_varint.value); // Move to next varint position - varint_pos += consumed; + varint_pos += msg_size_varint.consumed; - auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed); + auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos); if (!msg_type_varint.has_value()) { // not enough data there yet continue; } - if (msg_type_varint->as_uint32() > std::numeric_limits::max()) { + if (msg_type_varint.value > std::numeric_limits::max()) { state_ = State::FAILED; - HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u", msg_type_varint->as_uint32(), - std::numeric_limits::max()); + HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u", + static_cast(msg_type_varint.value), std::numeric_limits::max()); return APIError::BAD_DATA_PACKET; } - rx_header_parsed_type_ = msg_type_varint->as_uint16(); + rx_header_parsed_type_ = static_cast(msg_type_varint.value); rx_header_parsed_ = true; } // header reading done // Reserve space for body (+ null terminator so protobuf StringRef fields // can be safely null-terminated in-place after decode) - if (this->rx_buf_.size() != this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR) { - this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR); - } + this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR); if (rx_buf_len_ < rx_header_parsed_len_) { // more data to read @@ -195,11 +193,11 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { } APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { - if (this->state_ != State::DATA) { - return APIError::WOULD_BLOCK; - } + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) + return aerr; - APIError aerr = this->try_read_frame_(); + aerr = this->try_read_frame_(); if (aerr != APIError::OK) { if (aerr == APIError::BAD_INDICATOR) { // Make sure to tell the remote that we don't @@ -237,16 +235,11 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = this->rx_header_parsed_type_; return APIError::OK; } -APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { - MessageInfo msg{type, 0, static_cast(buffer.get_buffer()->size() - frame_header_padding_)}; - return write_protobuf_messages(buffer, std::span(&msg, 1)); -} - APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { - if (state_ != State::DATA) { - return APIError::BAD_STATE; - } + APIError aerr = this->check_data_state_(); + if (aerr != APIError::OK) + return aerr; if (messages.empty()) { return APIError::OK; @@ -259,9 +252,11 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe uint16_t total_write_len = 0; for (const auto &msg : messages) { - // Calculate varint sizes for header layout - uint8_t size_varint_len = api::ProtoSize::varint(static_cast(msg.payload_size)); - uint8_t type_varint_len = api::ProtoSize::varint(static_cast(msg.message_type)); + // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead + uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE + ? 1 + : (msg.payload_size < ProtoSize::VARINT_THRESHOLD_2_BYTE ? 2 : 3); + uint8_t type_varint_len = msg.message_type < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 : 2; uint8_t total_header_len = 1 + size_varint_len + type_varint_len; // Calculate where to start writing the header @@ -283,8 +278,8 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe // // Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0 // [0] - 0x00 indicator byte - // [1-3] - Payload size varint (3 bytes, for sizes 16384-2097151) - // [4-5] - Message type varint (2 bytes, for types 128-32767) + // [1-3] - Payload size varint (3 bytes, for sizes 16384-65535) + // [4-5] - Message type varint (2 bytes, for types 128-16383) // [6...] - Actual payload data // // The message starts at offset + frame_header_padding_ diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 96d47e9c7bf..f8161c039d3 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -19,7 +19,6 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c438043da40..a4324b8db3f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,13 +7,13 @@ namespace esphome::api { -bool HelloRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->api_version_major = value.as_uint32(); + this->api_version_major = value; break; case 3: - this->api_version_minor = value.as_uint32(); + this->api_version_minor = value; break; default: return false; @@ -316,20 +316,20 @@ uint32_t CoverStateResponse::calculate_size() const { #endif return size; } -bool CoverCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool CoverCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 4: - this->has_position = value.as_bool(); + this->has_position = value != 0; break; case 6: - this->has_tilt = value.as_bool(); + this->has_tilt = value != 0; break; case 8: - this->stop = value.as_bool(); + this->stop = value != 0; break; #ifdef USE_DEVICES case 9: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -423,38 +423,38 @@ uint32_t FanStateResponse::calculate_size() const { #endif return size; } -bool FanCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool FanCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_state = value.as_bool(); + this->has_state = value != 0; break; case 3: - this->state = value.as_bool(); + this->state = value != 0; break; case 6: - this->has_oscillating = value.as_bool(); + this->has_oscillating = value != 0; break; case 7: - this->oscillating = value.as_bool(); + this->oscillating = value != 0; break; case 8: - this->has_direction = value.as_bool(); + this->has_direction = value != 0; break; case 9: - this->direction = static_cast(value.as_uint32()); + this->direction = static_cast(value); break; case 10: - this->has_speed_level = value.as_bool(); + this->has_speed_level = value != 0; break; case 11: - this->speed_level = value.as_int32(); + this->speed_level = static_cast(value); break; case 12: - this->has_preset_mode = value.as_bool(); + this->has_preset_mode = value != 0; break; #ifdef USE_DEVICES case 14: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -571,59 +571,59 @@ uint32_t LightStateResponse::calculate_size() const { #endif return size; } -bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool LightCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_state = value.as_bool(); + this->has_state = value != 0; break; case 3: - this->state = value.as_bool(); + this->state = value != 0; break; case 4: - this->has_brightness = value.as_bool(); + this->has_brightness = value != 0; break; case 22: - this->has_color_mode = value.as_bool(); + this->has_color_mode = value != 0; break; case 23: - this->color_mode = static_cast(value.as_uint32()); + this->color_mode = static_cast(value); break; case 20: - this->has_color_brightness = value.as_bool(); + this->has_color_brightness = value != 0; break; case 6: - this->has_rgb = value.as_bool(); + this->has_rgb = value != 0; break; case 10: - this->has_white = value.as_bool(); + this->has_white = value != 0; break; case 12: - this->has_color_temperature = value.as_bool(); + this->has_color_temperature = value != 0; break; case 24: - this->has_cold_white = value.as_bool(); + this->has_cold_white = value != 0; break; case 26: - this->has_warm_white = value.as_bool(); + this->has_warm_white = value != 0; break; case 14: - this->has_transition_length = value.as_bool(); + this->has_transition_length = value != 0; break; case 15: - this->transition_length = value.as_uint32(); + this->transition_length = value; break; case 16: - this->has_flash_length = value.as_bool(); + this->has_flash_length = value != 0; break; case 17: - this->flash_length = value.as_uint32(); + this->flash_length = value; break; case 18: - this->has_effect = value.as_bool(); + this->has_effect = value != 0; break; #ifdef USE_DEVICES case 28: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -787,14 +787,14 @@ uint32_t SwitchStateResponse::calculate_size() const { #endif return size; } -bool SwitchCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SwitchCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->state = value.as_bool(); + this->state = value != 0; break; #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -863,13 +863,13 @@ uint32_t TextSensorStateResponse::calculate_size() const { return size; } #endif -bool SubscribeLogsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->level = static_cast(value.as_uint32()); + this->level = static_cast(value); break; case 2: - this->dump_config = value.as_bool(); + this->dump_config = value != 0; break; default: return false; @@ -971,13 +971,13 @@ uint32_t HomeassistantActionRequest::calculate_size() const { } #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES -bool HomeassistantActionResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool HomeassistantActionResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->call_id = value.as_uint32(); + this->call_id = value; break; case 2: - this->success = value.as_bool(); + this->success = value != 0; break; default: return false; @@ -1036,38 +1036,38 @@ bool HomeAssistantStateResponse::decode_length(uint32_t field_id, ProtoLengthDel return true; } #endif -bool DSTRule::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool DSTRule::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->time_seconds = value.as_sint32(); + this->time_seconds = decode_zigzag32(static_cast(value)); break; case 2: - this->day = value.as_uint32(); + this->day = value; break; case 3: - this->type = static_cast(value.as_uint32()); + this->type = static_cast(value); break; case 4: - this->month = value.as_uint32(); + this->month = value; break; case 5: - this->week = value.as_uint32(); + this->week = value; break; case 6: - this->day_of_week = value.as_uint32(); + this->day_of_week = value; break; default: return false; } return true; } -bool ParsedTimezone::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ParsedTimezone::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->std_offset_seconds = value.as_sint32(); + this->std_offset_seconds = decode_zigzag32(static_cast(value)); break; case 2: - this->dst_offset_seconds = value.as_sint32(); + this->dst_offset_seconds = decode_zigzag32(static_cast(value)); break; default: return false; @@ -1138,22 +1138,22 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, static_cast(this->supports_response)); return size; } -bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->bool_ = value.as_bool(); + this->bool_ = value != 0; break; case 2: - this->legacy_int = value.as_int32(); + this->legacy_int = static_cast(value); break; case 5: - this->int_ = value.as_sint32(); + this->int_ = decode_zigzag32(static_cast(value)); break; case 6: - this->bool_array.push_back(value.as_bool()); + this->bool_array.push_back(value != 0); break; case 7: - this->int_array.push_back(value.as_sint32()); + this->int_array.push_back(decode_zigzag32(static_cast(value))); break; default: return false; @@ -1198,16 +1198,16 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->string_array.init(count_string_array); ProtoDecodableMessage::decode(buffer, length); } -bool ExecuteServiceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES case 3: - this->call_id = value.as_uint32(); + this->call_id = value; break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES case 4: - this->return_response = value.as_bool(); + this->return_response = value != 0; break; #endif default: @@ -1309,13 +1309,13 @@ uint32_t CameraImageResponse::calculate_size() const { #endif return size; } -bool CameraImageRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->single = value.as_bool(); + this->single = value != 0; break; case 2: - this->stream = value.as_bool(); + this->stream = value != 0; break; default: return false; @@ -1464,53 +1464,53 @@ uint32_t ClimateStateResponse::calculate_size() const { #endif return size; } -bool ClimateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_mode = value.as_bool(); + this->has_mode = value != 0; break; case 3: - this->mode = static_cast(value.as_uint32()); + this->mode = static_cast(value); break; case 4: - this->has_target_temperature = value.as_bool(); + this->has_target_temperature = value != 0; break; case 6: - this->has_target_temperature_low = value.as_bool(); + this->has_target_temperature_low = value != 0; break; case 8: - this->has_target_temperature_high = value.as_bool(); + this->has_target_temperature_high = value != 0; break; case 12: - this->has_fan_mode = value.as_bool(); + this->has_fan_mode = value != 0; break; case 13: - this->fan_mode = static_cast(value.as_uint32()); + this->fan_mode = static_cast(value); break; case 14: - this->has_swing_mode = value.as_bool(); + this->has_swing_mode = value != 0; break; case 15: - this->swing_mode = static_cast(value.as_uint32()); + this->swing_mode = static_cast(value); break; case 16: - this->has_custom_fan_mode = value.as_bool(); + this->has_custom_fan_mode = value != 0; break; case 18: - this->has_preset = value.as_bool(); + this->has_preset = value != 0; break; case 19: - this->preset = static_cast(value.as_uint32()); + this->preset = static_cast(value); break; case 20: - this->has_custom_preset = value.as_bool(); + this->has_custom_preset = value != 0; break; case 22: - this->has_target_humidity = value.as_bool(); + this->has_target_humidity = value != 0; break; #ifdef USE_DEVICES case 24: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -1627,21 +1627,21 @@ uint32_t WaterHeaterStateResponse::calculate_size() const { size += ProtoSize::calc_float(1, this->target_temperature_high); return size; } -bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_fields = value.as_uint32(); + this->has_fields = value; break; case 3: - this->mode = static_cast(value.as_uint32()); + this->mode = static_cast(value); break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif case 6: - this->state = value.as_uint32(); + this->state = value; break; default: return false; @@ -1727,11 +1727,11 @@ uint32_t NumberStateResponse::calculate_size() const { #endif return size; } -bool NumberCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -1808,11 +1808,11 @@ uint32_t SelectStateResponse::calculate_size() const { #endif return size; } -bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -1899,29 +1899,29 @@ uint32_t SirenStateResponse::calculate_size() const { #endif return size; } -bool SirenCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_state = value.as_bool(); + this->has_state = value != 0; break; case 3: - this->state = value.as_bool(); + this->state = value != 0; break; case 4: - this->has_tone = value.as_bool(); + this->has_tone = value != 0; break; case 6: - this->has_duration = value.as_bool(); + this->has_duration = value != 0; break; case 7: - this->duration = value.as_uint32(); + this->duration = value; break; case 8: - this->has_volume = value.as_bool(); + this->has_volume = value != 0; break; #ifdef USE_DEVICES case 10: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2007,17 +2007,17 @@ uint32_t LockStateResponse::calculate_size() const { #endif return size; } -bool LockCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; case 3: - this->has_code = value.as_bool(); + this->has_code = value != 0; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2078,11 +2078,11 @@ uint32_t ListEntitiesButtonResponse::calculate_size() const { #endif return size; } -bool ButtonCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 2: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2178,29 +2178,29 @@ uint32_t MediaPlayerStateResponse::calculate_size() const { #endif return size; } -bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_command = value.as_bool(); + this->has_command = value != 0; break; case 3: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; case 4: - this->has_volume = value.as_bool(); + this->has_volume = value != 0; break; case 6: - this->has_media_url = value.as_bool(); + this->has_media_url = value != 0; break; case 8: - this->has_announcement = value.as_bool(); + this->has_announcement = value != 0; break; case 9: - this->announcement = value.as_bool(); + this->announcement = value != 0; break; #ifdef USE_DEVICES case 10: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -2234,10 +2234,10 @@ bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value } #endif #ifdef USE_BLUETOOTH_PROXY -bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->flags = value.as_uint32(); + this->flags = value; break; default: return false; @@ -2270,19 +2270,19 @@ uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const { } return size; } -bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->request_type = static_cast(value.as_uint32()); + this->request_type = static_cast(value); break; case 3: - this->has_address_type = value.as_bool(); + this->has_address_type = value != 0; break; case 4: - this->address_type = value.as_uint32(); + this->address_type = value; break; default: return false; @@ -2303,10 +2303,10 @@ uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } -bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; default: return false; @@ -2409,13 +2409,13 @@ uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { size += ProtoSize::calc_uint64(1, this->address); return size; } -bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; default: return false; @@ -2434,16 +2434,16 @@ uint32_t BluetoothGATTReadResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len_); return size; } -bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; case 3: - this->response = value.as_bool(); + this->response = value != 0; break; default: return false; @@ -2462,26 +2462,26 @@ bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDeli } return true; } -bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; default: return false; } return true; } -bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; default: return false; @@ -2500,16 +2500,16 @@ bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, Proto } return true; } -bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->handle = value.as_uint32(); + this->handle = value; break; case 3: - this->enable = value.as_bool(); + this->enable = value != 0; break; default: return false; @@ -2628,10 +2628,10 @@ uint32_t BluetoothScannerStateResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, static_cast(this->configured_mode)); return size; } -bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->mode = static_cast(value.as_uint32()); + this->mode = static_cast(value); break; default: return false; @@ -2640,13 +2640,13 @@ bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, ProtoVarIn } #endif #ifdef USE_VOICE_ASSISTANT -bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->subscribe = value.as_bool(); + this->subscribe = value != 0; break; case 2: - this->flags = value.as_uint32(); + this->flags = value; break; default: return false; @@ -2681,13 +2681,13 @@ uint32_t VoiceAssistantRequest::calculate_size() const { size += ProtoSize::calc_length(1, this->wake_word_phrase.size()); return size; } -bool VoiceAssistantResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->port = value.as_uint32(); + this->port = value; break; case 2: - this->error = value.as_bool(); + this->error = value != 0; break; default: return false; @@ -2709,10 +2709,10 @@ bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimi } return true; } -bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->event_type = static_cast(value.as_uint32()); + this->event_type = static_cast(value); break; default: return false; @@ -2730,10 +2730,10 @@ bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDe } return true; } -bool VoiceAssistantAudio::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->end = value.as_bool(); + this->end = value != 0; break; default: return false; @@ -2762,19 +2762,19 @@ uint32_t VoiceAssistantAudio::calculate_size() const { size += ProtoSize::calc_bool(1, this->end); return size; } -bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->event_type = static_cast(value.as_uint32()); + this->event_type = static_cast(value); break; case 4: - this->total_seconds = value.as_uint32(); + this->total_seconds = value; break; case 5: - this->seconds_left = value.as_uint32(); + this->seconds_left = value; break; case 6: - this->is_active = value.as_bool(); + this->is_active = value != 0; break; default: return false; @@ -2796,10 +2796,10 @@ bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLen } return true; } -bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 4: - this->start_conversation = value.as_bool(); + this->start_conversation = value != 0; break; default: return false; @@ -2849,10 +2849,10 @@ uint32_t VoiceAssistantWakeWord::calculate_size() const { } return size; } -bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 5: - this->model_size = value.as_uint32(); + this->model_size = value; break; default: return false; @@ -2986,14 +2986,14 @@ uint32_t AlarmControlPanelStateResponse::calculate_size() const { #endif return size; } -bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; #ifdef USE_DEVICES case 4: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3078,11 +3078,11 @@ uint32_t TextStateResponse::calculate_size() const { #endif return size; } -bool TextCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3163,20 +3163,20 @@ uint32_t DateStateResponse::calculate_size() const { #endif return size; } -bool DateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->year = value.as_uint32(); + this->year = value; break; case 3: - this->month = value.as_uint32(); + this->month = value; break; case 4: - this->day = value.as_uint32(); + this->day = value; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3246,20 +3246,20 @@ uint32_t TimeStateResponse::calculate_size() const { #endif return size; } -bool TimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->hour = value.as_uint32(); + this->hour = value; break; case 3: - this->minute = value.as_uint32(); + this->minute = value; break; case 4: - this->second = value.as_uint32(); + this->second = value; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3389,17 +3389,17 @@ uint32_t ValveStateResponse::calculate_size() const { #endif return size; } -bool ValveCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->has_position = value.as_bool(); + this->has_position = value != 0; break; case 4: - this->stop = value.as_bool(); + this->stop = value != 0; break; #ifdef USE_DEVICES case 5: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3468,11 +3468,11 @@ uint32_t DateTimeStateResponse::calculate_size() const { #endif return size; } -bool DateTimeCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3557,14 +3557,14 @@ uint32_t UpdateStateResponse::calculate_size() const { #endif return size; } -bool UpdateCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 2: - this->command = static_cast(value.as_uint32()); + this->command = static_cast(value); break; #ifdef USE_DEVICES case 3: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif default: @@ -3602,10 +3602,10 @@ uint32_t ZWaveProxyFrame::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len); return size; } -bool ZWaveProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->type = static_cast(value.as_uint32()); + this->type = static_cast(value); break; default: return false; @@ -3668,18 +3668,18 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const { } #endif #ifdef USE_IR_RF -bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { #ifdef USE_DEVICES case 1: - this->device_id = value.as_uint32(); + this->device_id = value; break; #endif case 3: - this->carrier_frequency = value.as_uint32(); + this->carrier_frequency = value; break; case 4: - this->repeat_count = value.as_uint32(); + this->repeat_count = value; break; default: return false; @@ -3733,25 +3733,25 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const { } #endif #ifdef USE_SERIAL_PROXY -bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; case 2: - this->baudrate = value.as_uint32(); + this->baudrate = value; break; case 3: - this->flow_control = value.as_bool(); + this->flow_control = value != 0; break; case 4: - this->parity = static_cast(value.as_uint32()); + this->parity = static_cast(value); break; case 5: - this->stop_bits = value.as_uint32(); + this->stop_bits = value; break; case 6: - this->data_size = value.as_uint32(); + this->data_size = value; break; default: return false; @@ -3768,10 +3768,10 @@ uint32_t SerialProxyDataReceived::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len_); return size; } -bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; default: return false; @@ -3790,23 +3790,23 @@ bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimi } return true; } -bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; case 2: - this->line_states = value.as_uint32(); + this->line_states = value; break; default: return false; } return true; } -bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; default: return false; @@ -3823,37 +3823,51 @@ uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { size += ProtoSize::calc_uint32(1, this->line_states); return size; } -bool SerialProxyRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->instance = value.as_uint32(); + this->instance = value; break; case 2: - this->type = static_cast(value.as_uint32()); + this->type = static_cast(value); break; default: return false; } return true; } +void SerialProxyRequestResponse::encode(ProtoWriteBuffer &buffer) const { + buffer.encode_uint32(1, this->instance); + buffer.encode_uint32(2, static_cast(this->type)); + buffer.encode_uint32(3, static_cast(this->status)); + buffer.encode_string(4, this->error_message); +} +uint32_t SerialProxyRequestResponse::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->instance); + size += ProtoSize::calc_uint32(1, static_cast(this->type)); + size += ProtoSize::calc_uint32(1, static_cast(this->status)); + size += ProtoSize::calc_length(1, this->error_message.size()); + return size; +} #endif #ifdef USE_BLUETOOTH_PROXY -bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { +bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: - this->address = value.as_uint64(); + this->address = value; break; case 2: - this->min_interval = value.as_uint32(); + this->min_interval = value; break; case 3: - this->max_interval = value.as_uint32(); + this->max_interval = value; break; case 4: - this->latency = value.as_uint32(); + this->latency = value; break; case 5: - this->timeout = value.as_uint32(); + this->timeout = value; break; default: return false; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 58cd75028dc..5dd1723f023 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -333,6 +333,13 @@ enum SerialProxyRequestType : uint32_t { SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2, }; +enum SerialProxyStatus : uint32_t { + SERIAL_PROXY_STATUS_OK = 0, + SERIAL_PROXY_STATUS_ASSUMED_SUCCESS = 1, + SERIAL_PROXY_STATUS_ERROR = 2, + SERIAL_PROXY_STATUS_TIMEOUT = 3, + SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4, +}; #endif } // namespace enums @@ -392,7 +399,7 @@ class HelloRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class HelloResponse final : public ProtoMessage { public: @@ -681,7 +688,7 @@ class CoverCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_FAN @@ -749,7 +756,7 @@ class FanCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_LIGHT @@ -839,7 +846,7 @@ class LightCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_SENSOR @@ -929,7 +936,7 @@ class SwitchCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_TEXT_SENSOR @@ -981,7 +988,7 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SubscribeLogsResponse final : public ProtoMessage { public: @@ -1103,7 +1110,7 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1169,7 +1176,7 @@ class DSTRule final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class ParsedTimezone final : public ProtoDecodableMessage { public: @@ -1183,7 +1190,7 @@ class ParsedTimezone final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class GetTimeResponse final : public ProtoDecodableMessage { public: @@ -1253,7 +1260,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: @@ -1278,7 +1285,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -1357,7 +1364,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_CLIMATE @@ -1456,7 +1463,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_WATER_HEATER @@ -1520,7 +1527,7 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_NUMBER @@ -1576,7 +1583,7 @@ class NumberCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_SELECT @@ -1628,7 +1635,7 @@ class SelectCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_SIREN @@ -1688,7 +1695,7 @@ class SirenCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_LOCK @@ -1744,7 +1751,7 @@ class LockCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_BUTTON @@ -1777,7 +1784,7 @@ class ButtonCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_MEDIA_PLAYER @@ -1854,7 +1861,7 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_BLUETOOTH_PROXY @@ -1871,7 +1878,7 @@ class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMes #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothLERawAdvertisement final : public ProtoMessage { public: @@ -1921,7 +1928,7 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: @@ -1955,7 +1962,7 @@ class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTDescriptor final : public ProtoMessage { public: @@ -2046,7 +2053,7 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTReadResponse final : public ProtoMessage { public: @@ -2089,7 +2096,7 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2105,7 +2112,7 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2124,7 +2131,7 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: @@ -2141,7 +2148,7 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: @@ -2321,7 +2328,7 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_VOICE_ASSISTANT @@ -2339,7 +2346,7 @@ class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAudioSettings final : public ProtoMessage { public: @@ -2388,7 +2395,7 @@ class VoiceAssistantResponse final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantEventData final : public ProtoDecodableMessage { public: @@ -2416,7 +2423,7 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: @@ -2436,7 +2443,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: @@ -2457,7 +2464,7 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: @@ -2476,7 +2483,7 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: @@ -2522,7 +2529,7 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: @@ -2624,7 +2631,7 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_TEXT @@ -2679,7 +2686,7 @@ class TextCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_DATETIME_DATE @@ -2733,7 +2740,7 @@ class DateCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_DATETIME_TIME @@ -2787,7 +2794,7 @@ class TimeCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_EVENT @@ -2878,7 +2885,7 @@ class ValveCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_DATETIME_DATETIME @@ -2928,7 +2935,7 @@ class DateTimeCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_UPDATE @@ -2986,7 +2993,7 @@ class UpdateCommandRequest final : public CommandProtoMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_ZWAVE_PROXY @@ -3026,7 +3033,7 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_INFRARED @@ -3071,7 +3078,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class InfraredRFReceiveEvent final : public ProtoMessage { public: @@ -3113,7 +3120,7 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxyDataReceived final : public ProtoMessage { public: @@ -3153,7 +3160,7 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { protected: bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3169,7 +3176,7 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3184,7 +3191,7 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: @@ -3217,7 +3224,26 @@ class SerialProxyRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; +}; +class SerialProxyRequestResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 147; + static constexpr uint8_t ESTIMATED_SIZE = 17; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "serial_proxy_request_response"; } +#endif + uint32_t instance{0}; + enums::SerialProxyRequestType type{}; + enums::SerialProxyStatus status{}; + StringRef error_message{}; + void encode(ProtoWriteBuffer &buffer) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: }; #endif #ifdef USE_BLUETOOTH_PROXY @@ -3238,7 +3264,7 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_varint(uint32_t field_id, ProtoVarInt value) override; + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index c4e850ffba8..d24a2879329 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -13,7 +13,7 @@ namespace esphome::api { static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) { out.append("'"); if (!ref.empty()) { - out.append(ref.c_str()); + out.append(ref.c_str(), ref.size()); } out.append("'"); } @@ -789,6 +789,22 @@ template<> const char *proto_enum_to_string(enums return "UNKNOWN"; } } +template<> const char *proto_enum_to_string(enums::SerialProxyStatus value) { + switch (value) { + case enums::SERIAL_PROXY_STATUS_OK: + return "SERIAL_PROXY_STATUS_OK"; + case enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS: + return "SERIAL_PROXY_STATUS_ASSUMED_SUCCESS"; + case enums::SERIAL_PROXY_STATUS_ERROR: + return "SERIAL_PROXY_STATUS_ERROR"; + case enums::SERIAL_PROXY_STATUS_TIMEOUT: + return "SERIAL_PROXY_STATUS_TIMEOUT"; + case enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED: + return "SERIAL_PROXY_STATUS_NOT_SUPPORTED"; + default: + return "UNKNOWN"; + } +} #endif const char *HelloRequest::dump_to(DumpBuffer &out) const { @@ -2608,6 +2624,14 @@ const char *SerialProxyRequest::dump_to(DumpBuffer &out) const { dump_field(out, "type", static_cast(this->type)); return out.c_str(); } +const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, "SerialProxyRequestResponse"); + dump_field(out, "instance", this->instance); + dump_field(out, "type", static_cast(this->type)); + dump_field(out, "status", static_cast(this->status)); + dump_field(out, "error_message", this->error_message); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index a031d2d969e..10fd88d8e13 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -233,6 +233,7 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_SERIAL_PROXY virtual void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif + #ifdef USE_BLUETOOTH_PROXY virtual void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 200d0938bd5..0e71ad8fcbf 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio from datetime import datetime +import importlib import logging from typing import TYPE_CHECKING, Any import warnings @@ -18,6 +19,7 @@ import contextlib from esphome.const import CONF_KEY, CONF_PORT, __version__ from esphome.core import CORE +from esphome.platformio_api import process_stacktrace from . import CONF_ENCRYPTION @@ -55,9 +57,19 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: addresses=addresses, # Pass all addresses for automatic retry ) dashboard = CORE.dashboard + backtrace_state = False + + # Try platform-specific stacktrace handler first, fall back to generic + platform_process_stacktrace = None + try: + module = importlib.import_module("esphome.components." + CORE.target_platform) + platform_process_stacktrace = getattr(module, "process_stacktrace") + except (AttributeError, ImportError): + pass def on_log(msg: SubscribeLogsResponse) -> None: """Handle a new log message.""" + nonlocal backtrace_state time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") @@ -67,6 +79,15 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: ) for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) + for raw_line in text.splitlines(): + if platform_process_stacktrace: + backtrace_state = platform_process_stacktrace( + config, raw_line, backtrace_state + ) + else: + backtrace_state = process_stacktrace( + config, raw_line, backtrace_state=backtrace_state + ) stop = await async_run(cli, on_log, name=name) try: diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index fb229928e5a..4f5b3f0918f 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -20,20 +20,40 @@ void ProtoWriteBuffer::encode_varint_raw_slow_(uint32_t value) { *this->pos_++ = static_cast(value); } +ProtoVarIntResult ProtoVarInt::parse_slow(const uint8_t *buffer, uint32_t len) { + // Multi-byte varint: first byte already checked to have high bit set + uint32_t result32 = buffer[0] & 0x7F; #ifdef USE_API_VARINT64 -optional ProtoVarInt::parse_wide(const uint8_t *buffer, uint32_t len, uint32_t *consumed, - uint32_t result32) { + uint32_t limit = std::min(len, uint32_t(4)); +#else + uint32_t limit = std::min(len, uint32_t(5)); +#endif + for (uint32_t i = 1; i < limit; i++) { + uint8_t val = buffer[i]; + result32 |= uint32_t(val & 0x7F) << (i * 7); + if ((val & 0x80) == 0) { + return {result32, i + 1}; + } + } +#ifdef USE_API_VARINT64 + return parse_wide(buffer, len, result32); +#else + return {0, PROTO_VARINT_PARSE_FAILED}; +#endif +} + +#ifdef USE_API_VARINT64 +ProtoVarIntResult ProtoVarInt::parse_wide(const uint8_t *buffer, uint32_t len, uint32_t result32) { uint64_t result64 = result32; uint32_t limit = std::min(len, uint32_t(10)); for (uint32_t i = 4; i < limit; i++) { uint8_t val = buffer[i]; result64 |= uint64_t(val & 0x7F) << (i * 7); if ((val & 0x80) == 0) { - *consumed = i + 1; - return ProtoVarInt(result64); + return {result64, i + 1}; } } - return {}; + return {0, PROTO_VARINT_PARSE_FAILED}; } #endif @@ -43,18 +63,16 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size const uint8_t *end = buffer + length; while (ptr < end) { - uint32_t consumed; - - // Parse field header (tag) - auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + // Parse field header (tag) - ptr < end guarantees len >= 1 + auto res = ProtoVarInt::parse_non_empty(ptr, end - ptr); if (!res.has_value()) { break; // Invalid data, stop counting } - uint32_t tag = res->as_uint32(); + uint32_t tag = static_cast(res.value); uint32_t field_type = tag & WIRE_TYPE_MASK; uint32_t field_id = tag >> 3; - ptr += consumed; + ptr += res.consumed; // Count if this is the target field if (field_id == target_field_id) { @@ -64,20 +82,20 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size // Skip field data based on wire type switch (field_type) { case WIRE_TYPE_VARINT: { // VarInt - parse and skip - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { return count; // Invalid data, return what we have } - ptr += consumed; + ptr += res.consumed; break; } case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - parse length and skip data - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { return count; } - uint32_t field_length = res->as_uint32(); - ptr += consumed; + uint32_t field_length = static_cast(res.value); + ptr += res.consumed; if (field_length > static_cast(end - ptr)) { return count; // Out of bounds } @@ -190,41 +208,40 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { const uint8_t *end = buffer + length; while (ptr < end) { - uint32_t consumed; - - // Parse field header - auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + // Parse field header - ptr < end guarantees len >= 1 + auto res = ProtoVarInt::parse_non_empty(ptr, end - ptr); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid field start at offset %ld", (long) (ptr - buffer)); return; } - uint32_t tag = res->as_uint32(); + uint32_t tag = static_cast(res.value); uint32_t field_type = tag & WIRE_TYPE_MASK; uint32_t field_id = tag >> 3; - ptr += consumed; + ptr += res.consumed; switch (field_type) { case WIRE_TYPE_VARINT: { // VarInt - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); return; } - if (!this->decode_varint(field_id, *res)) { - ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu32 "!", field_id, res->as_uint32()); + if (!this->decode_varint(field_id, res.value)) { + ESP_LOGV(TAG, "Cannot decode VarInt field %" PRIu32 " with value %" PRIu64 "!", field_id, + static_cast(res.value)); } - ptr += consumed; + ptr += res.consumed; break; } case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + res = ProtoVarInt::parse(ptr, end - ptr); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); return; } - uint32_t field_length = res->as_uint32(); - ptr += consumed; + uint32_t field_length = static_cast(res.value); + ptr += res.consumed; if (field_length > static_cast(end - ptr)) { ESP_LOGV(TAG, "Out-of-bounds Length Delimited at offset %ld", (long) (ptr - buffer)); return; diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 9bbd97ae00c..6752dfb9cd0 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -99,90 +99,56 @@ inline void encode_varint_to_buffer(uint32_t val, uint8_t *buffer) { * within the same function scope where temporaries are created. */ -/// Representation of a VarInt - in ProtoBuf should be 64bit but we only use 32bit +/// Type used for decoded varint values - uint64_t when BLE needs 64-bit addresses, uint32_t otherwise +#ifdef USE_API_VARINT64 +using proto_varint_value_t = uint64_t; +#else +using proto_varint_value_t = uint32_t; +#endif + +/// Sentinel value for consumed field indicating parse failure +inline constexpr uint32_t PROTO_VARINT_PARSE_FAILED = 0; + +/// Result of parsing a varint: value + number of bytes consumed. +/// consumed == PROTO_VARINT_PARSE_FAILED indicates parse failure (not enough data or invalid). +struct ProtoVarIntResult { + proto_varint_value_t value; + uint32_t consumed; // PROTO_VARINT_PARSE_FAILED = parse failed + + constexpr bool has_value() const { return this->consumed != PROTO_VARINT_PARSE_FAILED; } +}; + +/// Static varint parsing methods for the protobuf wire format. class ProtoVarInt { public: - ProtoVarInt() : value_(0) {} - explicit ProtoVarInt(uint64_t value) : value_(value) {} - - /// Parse a varint from buffer. consumed must be a valid pointer (not null). - static optional parse(const uint8_t *buffer, uint32_t len, uint32_t *consumed) { + /// Parse a varint from buffer. Caller must ensure len >= 1. + /// Returns result with consumed=0 on failure (truncated multi-byte varint). + static inline ProtoVarIntResult ESPHOME_ALWAYS_INLINE parse_non_empty(const uint8_t *buffer, uint32_t len) { #ifdef ESPHOME_DEBUG_API - assert(consumed != nullptr); + assert(len > 0); #endif - if (len == 0) - return {}; // Fast path: single-byte varints (0-127) are the most common case - // (booleans, small enums, field tags). Avoid loop overhead entirely. - if ((buffer[0] & 0x80) == 0) { - *consumed = 1; - return ProtoVarInt(buffer[0]); - } - // 32-bit phase: process remaining bytes with native 32-bit shifts. - // Without USE_API_VARINT64: cover bytes 1-4 (shifts 7, 14, 21, 28) — the uint32_t - // shift at byte 4 (shift by 28) may lose bits 32-34, but those are always zero for valid uint32 values. - // With USE_API_VARINT64: cover bytes 1-3 (shifts 7, 14, 21) so parse_wide handles - // byte 4+ with full 64-bit arithmetic (avoids truncating values > UINT32_MAX). - uint32_t result32 = buffer[0] & 0x7F; -#ifdef USE_API_VARINT64 - uint32_t limit = std::min(len, uint32_t(4)); -#else - uint32_t limit = std::min(len, uint32_t(5)); -#endif - for (uint32_t i = 1; i < limit; i++) { - uint8_t val = buffer[i]; - result32 |= uint32_t(val & 0x7F) << (i * 7); - if ((val & 0x80) == 0) { - *consumed = i + 1; - return ProtoVarInt(result32); - } - } - // 64-bit phase for remaining bytes (BLE addresses etc.) -#ifdef USE_API_VARINT64 - return parse_wide(buffer, len, consumed, result32); -#else - return {}; -#endif + // (booleans, small enums, field tags, small message sizes/types). + if ((buffer[0] & 0x80) == 0) [[likely]] + return {buffer[0], 1}; + return parse_slow(buffer, len); + } + + /// Parse a varint from buffer (safe for empty buffers). + /// Returns result with consumed=0 on failure (empty buffer or truncated varint). + static inline ProtoVarIntResult ESPHOME_ALWAYS_INLINE parse(const uint8_t *buffer, uint32_t len) { + if (len == 0) + return {0, PROTO_VARINT_PARSE_FAILED}; + return parse_non_empty(buffer, len); } -#ifdef USE_API_VARINT64 protected: + // Slow path for multi-byte varints (>= 128), outlined to keep fast path small + static ProtoVarIntResult parse_slow(const uint8_t *buffer, uint32_t len) __attribute__((noinline)); + +#ifdef USE_API_VARINT64 /// Continue parsing varint bytes 4-9 with 64-bit arithmetic. - /// Separated to keep 64-bit shift code (__ashldi3 on 32-bit platforms) out of the common path. - static optional parse_wide(const uint8_t *buffer, uint32_t len, uint32_t *consumed, uint32_t result32) - __attribute__((noinline)); - - public: -#endif - - constexpr uint16_t as_uint16() const { return this->value_; } - constexpr uint32_t as_uint32() const { return this->value_; } - constexpr bool as_bool() const { return this->value_; } - constexpr int32_t as_int32() const { - // Not ZigZag encoded - return static_cast(this->value_); - } - constexpr int32_t as_sint32() const { - // with ZigZag encoding - return decode_zigzag32(static_cast(this->value_)); - } -#ifdef USE_API_VARINT64 - constexpr uint64_t as_uint64() const { return this->value_; } - constexpr int64_t as_int64() const { - // Not ZigZag encoded - return static_cast(this->value_); - } - constexpr int64_t as_sint64() const { - // with ZigZag encoding - return decode_zigzag64(this->value_); - } -#endif - - protected: -#ifdef USE_API_VARINT64 - uint64_t value_; -#else - uint32_t value_; + static ProtoVarIntResult parse_wide(const uint8_t *buffer, uint32_t len, uint32_t result32) __attribute__((noinline)); #endif }; @@ -499,7 +465,7 @@ class ProtoDecodableMessage : public ProtoMessage { protected: ~ProtoDecodableMessage() = default; - virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } + virtual bool decode_varint(uint32_t field_id, proto_varint_value_t value) { return false; } virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } // NOTE: decode_64bit removed - wire type 1 not supported @@ -507,6 +473,12 @@ class ProtoDecodableMessage : public ProtoMessage { class ProtoSize { public: + // Varint encoding thresholds: values below each threshold fit in N bytes + static constexpr uint32_t VARINT_THRESHOLD_1_BYTE = 1 << 7; // 128 + static constexpr uint32_t VARINT_THRESHOLD_2_BYTE = 1 << 14; // 16384 + static constexpr uint32_t VARINT_THRESHOLD_3_BYTE = 1 << 21; // 2097152 + static constexpr uint32_t VARINT_THRESHOLD_4_BYTE = 1 << 28; // 268435456 + /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * @@ -514,7 +486,7 @@ class ProtoSize { * @return The number of bytes needed to encode the value */ static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE varint(uint32_t value) { - if (value < 128) [[likely]] + if (value < VARINT_THRESHOLD_1_BYTE) [[likely]] return 1; // Fast path: 7 bits, most common case if (__builtin_is_constant_evaluated()) return varint_wide(value); @@ -526,11 +498,11 @@ class ProtoSize { static uint32_t varint_slow(uint32_t value) __attribute__((noinline)); // Shared cascade for values >= 128 (used by both constexpr and noinline paths) static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE varint_wide(uint32_t value) { - if (value < 16384) + if (value < VARINT_THRESHOLD_2_BYTE) return 2; - if (value < 2097152) + if (value < VARINT_THRESHOLD_3_BYTE) return 3; - if (value < 268435456) + if (value < VARINT_THRESHOLD_4_BYTE) return 4; return 5; } @@ -636,7 +608,7 @@ class ProtoSize { static constexpr uint32_t calc_sint32(uint32_t field_id_size, int32_t value) { return value ? field_id_size + varint(encode_zigzag32(value)) : 0; } - static constexpr uint32_t calc_sint32_force(uint32_t field_id_size, int32_t value) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_sint32_force(uint32_t field_id_size, int32_t value) { return field_id_size + varint(encode_zigzag32(value)); } static constexpr uint32_t calc_int64(uint32_t field_id_size, int64_t value) { @@ -648,13 +620,13 @@ class ProtoSize { static constexpr uint32_t calc_uint64(uint32_t field_id_size, uint64_t value) { return value ? field_id_size + varint(value) : 0; } - static constexpr uint32_t calc_uint64_force(uint32_t field_id_size, uint64_t value) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_force(uint32_t field_id_size, uint64_t value) { return field_id_size + varint(value); } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; } - static constexpr uint32_t calc_length_force(uint32_t field_id_size, size_t len) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_length_force(uint32_t field_id_size, size_t len) { return field_id_size + varint(static_cast(len)) + static_cast(len); } static constexpr uint32_t calc_sint64(uint32_t field_id_size, int64_t value) { @@ -672,7 +644,8 @@ class ProtoSize { static constexpr uint32_t calc_message(uint32_t field_id_size, uint32_t nested_size) { return nested_size ? field_id_size + varint(nested_size) + nested_size : 0; } - static constexpr uint32_t calc_message_force(uint32_t field_id_size, uint32_t nested_size) { + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_message_force(uint32_t field_id_size, + uint32_t nested_size) { return field_id_size + varint(nested_size) + nested_size; } }; diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index f64e494f5f6..e8c0f163b39 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -52,11 +52,12 @@ bool AsyncClient::connect(const char *host, uint16_t port) { connect_cb_(connect_arg_, this); return true; } - if (errno != EINPROGRESS) { - ESP_LOGE(TAG, "Connect failed: %d", errno); + const int saved_errno = errno; + if (saved_errno != EINPROGRESS) { + ESP_LOGE(TAG, "Connect failed: %d", saved_errno); close(); if (error_cb_) - error_cb_(error_arg_, this, errno); + error_cb_(error_arg_, this, saved_errno); return false; } @@ -79,11 +80,12 @@ size_t AsyncClient::write(const char *data, size_t len) { ssize_t sent = socket_->write(data, len); if (sent < 0) { - if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGE(TAG, "Write error: %d", errno); + const int err = errno; + if (err != EAGAIN && err != EWOULDBLOCK) { + ESP_LOGE(TAG, "Write error: %d", err); close(); if (error_cb_) - error_cb_(error_arg_, this, errno); + error_cb_(error_arg_, this, err); } return 0; } @@ -129,10 +131,11 @@ void AsyncClient::loop() { error_cb_(error_arg_, this, error); } } else if (ret < 0) { - ESP_LOGE(TAG, "Select error: %d", errno); + const int err = errno; + ESP_LOGE(TAG, "Select error: %d", err); close(); if (error_cb_) - error_cb_(error_arg_, this, errno); + error_cb_(error_arg_, this, err); } } else if (connected_) { // For connected sockets, use the Application's select() results @@ -148,11 +151,14 @@ void AsyncClient::loop() { } else if (len > 0) { if (data_cb_) data_cb_(data_arg_, this, buf, len); - } else if (errno != EAGAIN && errno != EWOULDBLOCK) { - ESP_LOGW(TAG, "Read error: %d", errno); - close(); - if (error_cb_) - error_cb_(error_arg_, this, errno); + } else { + const int err = errno; + if (err != EAGAIN && err != EWOULDBLOCK) { + ESP_LOGW(TAG, "Read error: %d", err); + close(); + if (error_cb_) + error_cb_(error_arg_, this, err); + } } } } diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 117ada123db..0780814ea6e 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -89,6 +89,7 @@ AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Actio cv.Required(CONF_ID): cv.use_id(AT581XComponent), } ), + synchronous=True, ) async def at581x_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -160,6 +161,7 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( "at581x.settings", AT581XSettingsAction, RADAR_SETTINGS_SCHEMA, + synchronous=True, ) async def at581x_settings_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/at581x/at581x.cpp b/esphome/components/at581x/at581x.cpp index 728fbe20c69..6fc85b07902 100644 --- a/esphome/components/at581x/at581x.cpp +++ b/esphome/components/at581x/at581x.cpp @@ -135,6 +135,11 @@ bool AT581XComponent::i2c_write_config() { } // Set gain + if (this->gain_ < 0 || static_cast(this->gain_) >= ARRAY_SIZE(GAIN5C_TABLE) || + static_cast(this->gain_ >> 1) >= ARRAY_SIZE(GAIN63_TABLE)) { + ESP_LOGE(TAG, "AT581X gain index out of range: %d", this->gain_); + return false; + } if (!this->i2c_write_reg(GAIN_ADDR_TABLE[0], GAIN5C_TABLE[this->gain_]) || !this->i2c_write_reg(GAIN_ADDR_TABLE[1], GAIN63_TABLE[this->gain_ >> 1])) { ESP_LOGE(TAG, "Failed to write AT581X gain registers"); diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d95fcf66d7b..b28c2ed3d8c 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -214,4 +214,4 @@ async def to_code(config): cg.add_define("USE_AUDIO_MP3_SUPPORT") if data.opus_support: cg.add_define("USE_AUDIO_OPUS_SUPPORT") - add_idf_component(name="esphome/micro-opus", ref="0.3.4") + add_idf_component(name="esphome/micro-opus", ref="0.3.5") diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 2f95a039f5c..3c9b32e6107 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -23,7 +23,10 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "audio_adc.set_mic_gain", SetMicGainAction, SET_MIC_GAIN_ACTION_SCHEMA + "audio_adc.set_mic_gain", + SetMicGainAction, + SET_MIC_GAIN_ACTION_SCHEMA, + synchronous=True, ) async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/audio_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 92e6cb18fa0..a950c1967bf 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -31,15 +31,22 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( ) -@automation.register_action("audio_dac.mute_off", MuteOffAction, MUTE_ACTION_SCHEMA) -@automation.register_action("audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA) +@automation.register_action( + "audio_dac.mute_off", MuteOffAction, MUTE_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True +) async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( - "audio_dac.set_volume", SetVolumeAction, SET_VOLUME_ACTION_SCHEMA + "audio_dac.set_volume", + SetVolumeAction, + SET_VOLUME_ACTION_SCHEMA, + synchronous=True, ) async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 356f3964766..7be26efa7f2 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -38,6 +38,11 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { const auto &data = service_data.data; + if (data.size() < 10) { + ESP_LOGW(TAG, "Service data too short: %zu", data.size()); + return false; + } + const uint8_t protocol_version = data[0] >> 4; if (protocol_version != 1 && protocol_version != 2) { ESP_LOGE(TAG, "Unsupported protocol version: %u", protocol_version); @@ -47,6 +52,11 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { // Some b-parasite versions have an (optional) illuminance sensor. bool has_illuminance = data[0] & 0x1; + if (has_illuminance && data.size() < 18) { + ESP_LOGW(TAG, "Service data too short for illuminance: %zu", data.size()); + return false; + } + // Counter for deduplicating messages. uint8_t counter = data[1] & 0x0f; if (last_processed_counter_ == counter) { diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 1f641185602..37cccc01be6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -685,6 +685,7 @@ async def to_code(config): }, key=CONF_ID, ), + synchronous=True, ) async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 25a69c413a6..5d525e967db 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -136,7 +136,6 @@ optional SettleFilter::new_value(bool value) { return {}; } else { this->steady_ = false; - this->output(value); this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; }); return value; } diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 7b643bba98a..70db235a375 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -138,23 +138,24 @@ void BL0906::read_data_(const uint8_t address, const float reference, sensor::Se this->write_byte(BL0906_READ_COMMAND); this->write_byte(address); - if (this->read_array((uint8_t *) &buffer, sizeof(buffer) - 1)) { - if (bl0906_checksum(address, &buffer) == buffer.checksum) { - if (signed_result) { - data_s24.l = buffer.l; - data_s24.m = buffer.m; - data_s24.h = buffer.h; - } else { - data_u24.l = buffer.l; - data_u24.m = buffer.m; - data_u24.h = buffer.h; - } - } else { - ESP_LOGW(TAG, "Junk on wire. Throwing away partial message"); - while (read() >= 0) - ; - return; - } + if (!this->read_array((uint8_t *) &buffer, sizeof(buffer) - 1)) { + ESP_LOGW(TAG, "Read failed"); + return; + } + if (bl0906_checksum(address, &buffer) != buffer.checksum) { + ESP_LOGW(TAG, "Junk on wire. Throwing away partial message"); + while (read() >= 0) + ; + return; + } + if (signed_result) { + data_s24.l = buffer.l; + data_s24.m = buffer.m; + data_s24.h = buffer.h; + } else { + data_u24.l = buffer.l; + data_u24.m = buffer.m; + data_u24.h = buffer.h; } // Power if (reference == BL0906_PREF) { @@ -190,11 +191,9 @@ void BL0906::bias_correction_(uint8_t address, float measurements, float correct float i_rms0 = measurements * ki; float i_rms = correction * ki; int32_t value = (i_rms * i_rms - i_rms0 * i_rms0) / 256; - data.l = value << 24 >> 24; - data.m = value << 16 >> 24; - if (value < 0) { - data.h = (value << 8 >> 24) | 0b10000000; - } + data.l = value & 0xFF; + data.m = (value >> 8) & 0xFF; + data.h = (value >> 16) & 0xFF; data.address = bl0906_checksum(address, &data); ESP_LOGV(TAG, "RMSOS:%02X%02X%02X%02X%02X%02X", BL0906_WRITE_COMMAND, address, data.l, data.m, data.h, data.address); this->write_byte(BL0906_WRITE_COMMAND); diff --git a/esphome/components/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 42c6f06092b..059e10e962d 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -143,6 +143,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( cv.Required(CONF_ID): cv.use_id(BL0906), } ), + synchronous=True, ) async def reset_energy_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h index 93d54003f5f..e0ca748a222 100644 --- a/esphome/components/bl0940/bl0940.h +++ b/esphome/components/bl0940/bl0940.h @@ -69,10 +69,8 @@ class BL0940 : public PollingComponent, public uart::UARTDevice { void set_energy_calibration_number(number::Number *num) { this->energy_calibration_number_ = num; } #endif -#ifdef USE_BUTTON - // Resets all calibration values to defaults (can be triggered by a button) + // Resets all calibration values to defaults void reset_calibration(); -#endif // Core component methods void loop() override; diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 16ad33141d1..7d385974231 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -173,7 +173,7 @@ void BL0942::received_package_(DataPacket *data) { float i_rms = (uint24_t) data->i_rms / current_reference_; float watt = (int24_t) data->watt / power_reference_; float total_energy_consumption = cf_cnt / energy_reference_; - float frequency = 1000000.0f / data->frequency; + float frequency = data->frequency != 0 ? 1000000.0f / data->frequency : NAN; if (voltage_sensor_ != nullptr) { voltage_sensor_->publish_state(v_rms); diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 37db181584b..56ac2ea1472 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -172,7 +172,10 @@ BLE_REMOVE_BOND_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "ble_client.disconnect", BLEDisconnectAction, BLE_CONNECT_ACTION_SCHEMA + "ble_client.disconnect", + BLEDisconnectAction, + BLE_CONNECT_ACTION_SCHEMA, + synchronous=False, ) async def ble_disconnect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -180,7 +183,10 @@ async def ble_disconnect_to_code(config, action_id, template_arg, args): @automation.register_action( - "ble_client.connect", BLEConnectAction, BLE_CONNECT_ACTION_SCHEMA + "ble_client.connect", + BLEConnectAction, + BLE_CONNECT_ACTION_SCHEMA, + synchronous=False, ) async def ble_connect_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -188,7 +194,10 @@ async def ble_connect_to_code(config, action_id, template_arg, args): @automation.register_action( - "ble_client.ble_write", BLEWriteAction, BLE_WRITE_ACTION_SCHEMA + "ble_client.ble_write", + BLEWriteAction, + BLE_WRITE_ACTION_SCHEMA, + synchronous=False, ) async def ble_write_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -247,6 +256,7 @@ async def ble_write_to_code(config, action_id, template_arg, args): "ble_client.numeric_comparison_reply", BLENumericComparisonReplyAction, BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, + synchronous=True, ) async def numeric_comparison_reply_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -263,7 +273,10 @@ async def numeric_comparison_reply_to_code(config, action_id, template_arg, args @automation.register_action( - "ble_client.passkey_reply", BLEPasskeyReplyAction, BLE_PASSKEY_REPLY_ACTION_SCHEMA + "ble_client.passkey_reply", + BLEPasskeyReplyAction, + BLE_PASSKEY_REPLY_ACTION_SCHEMA, + synchronous=True, ) async def passkey_reply_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -283,6 +296,7 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): "ble_client.remove_bond", BLERemoveBondAction, BLE_REMOVE_BOND_ACTION_SCHEMA, + synchronous=True, ) async def remove_bond_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index e38dc998028..d0d37dbf1cb 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -71,7 +71,10 @@ bool BLENUS::read_array(uint8_t *data, size_t len) { this->has_peek_ = false; data++; if (--len == 0) { // Decrement len first, then check it... - return true; // No more to read +#ifdef USE_UART_DEBUGGER + this->debug_callback_.call(uart::UART_DIRECTION_RX, this->peek_buffer_); +#endif + return true; // No more to read } } @@ -101,10 +104,10 @@ size_t BLENUS::available() { } uart::FlushResult BLENUS::flush() { - constexpr uint32_t timeout_5sec = 5000; + constexpr uint32_t timeout_500ms = 500; uint32_t start = millis(); while (atomic_get(&this->tx_status_) != TX_DISABLED && !ring_buf_is_empty(&global_ble_tx_ring_buf)) { - if (millis() - start > timeout_5sec) { + if (millis() - start > timeout_500ms) { ESP_LOGW(TAG, "Flush timeout"); return uart::FlushResult::TIMEOUT; } diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index 7061b6d3365..c6d7f24ccec 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -16,12 +16,27 @@ namespace ble_scanner { class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { - // Format JSON using stack buffer to avoid heap allocations from string concatenation - char buf[128]; char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + // Escape special characters in the device name for valid JSON + const char *name = device.get_name().c_str(); + char escaped_name[128]; + size_t pos = 0; + for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) { + uint8_t c = static_cast(*name); + if (c == '"' || c == '\\') { + escaped_name[pos++] = '\\'; + escaped_name[pos++] = c; + } else if (c < 0x20) { + pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c); + } else { + escaped_name[pos++] = c; + } + } + escaped_name[pos] = '\0'; + + char buf[256]; snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}", - static_cast(::time(nullptr)), device.address_str_to(addr_buf), device.get_rssi(), - device.get_name().c_str()); + static_cast(::time(nullptr)), device.address_str_to(addr_buf), device.get_rssi(), escaped_name); this->publish_state(buf); return true; } diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py index 2785315af27..ba264f00bfe 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -33,6 +33,7 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.use_id(BM8563), } ), + synchronous=True, ) async def bm8563_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -49,6 +50,7 @@ async def bm8563_write_time_to_code(config, action_id, template_arg, args): cv.Required(CONF_DURATION): cv.templatable(cv.positive_time_period_seconds), } ), + synchronous=True, ) async def bm8563_start_timer_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -66,6 +68,7 @@ async def bm8563_start_timer_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(BM8563), } ), + synchronous=True, ) async def bm8563_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index f396888fd19..f31940df106 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -7,7 +7,7 @@ #include #include -#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID" +#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" namespace esphome { namespace bme280_base { @@ -147,8 +147,11 @@ void BME280Component::setup() { this->calibration_.h1 = read_u8_(BME280_REGISTER_DIG_H1); this->calibration_.h2 = read_s16_le_(BME280_REGISTER_DIG_H2); this->calibration_.h3 = read_u8_(BME280_REGISTER_DIG_H3); - this->calibration_.h4 = read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F); - this->calibration_.h5 = read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4); + // h4 and h5 are signed 12-bit values; shift left then arithmetic right shift to sign-extend + int16_t h4_raw = read_u8_(BME280_REGISTER_DIG_H4) << 4 | (read_u8_(BME280_REGISTER_DIG_H4 + 1) & 0x0F); + this->calibration_.h4 = static_cast(h4_raw << 4) >> 4; + int16_t h5_raw = read_u8_(BME280_REGISTER_DIG_H5 + 1) << 4 | (read_u8_(BME280_REGISTER_DIG_H5) >> 4); + this->calibration_.h5 = static_cast(h5_raw << 4) >> 4; this->calibration_.h6 = read_u8_(BME280_REGISTER_DIG_H6); uint8_t humid_control_val = 0; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index d48a42823b2..239823fa8c9 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -32,8 +32,8 @@ enum BME680Oversampling { /// Struct for storing calibration data for the BME680. struct BME680CalibrationData { uint16_t t1; - uint16_t t2; - uint8_t t3; + int16_t t2; + int8_t t3; uint16_t p1; int16_t p2; diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 392d071b317..75efb6835ab 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -271,10 +271,16 @@ void BME680BSECComponent::read_() { int64_t curr_time_ns = this->get_time_ns_(); if (this->bme680_settings_.trigger_measurement) { + uint32_t start = millis(); while (this->bme680_.power_mode != BME680_SLEEP_MODE) { + if (millis() - start > 50) { + ESP_LOGE(TAG, "Timeout waiting for BME680 to enter sleep mode"); + return; + } this->bme680_status_ = bme680_get_sensor_mode(&this->bme680_); if (this->bme680_status_ != BME680_OK) { - ESP_LOGW(TAG, "Failed to get sensor mode (BME680 Error Code %d)", this->bme680_status_); + ESP_LOGE(TAG, "Failed to get sensor mode (BME680 Error Code %d)", this->bme680_status_); + return; } } } @@ -383,7 +389,7 @@ void BME680BSECComponent::publish_(const bsec_output_t *outputs, uint8_t num_out switch (outputs[i].sensor_id) { case BSEC_OUTPUT_IAQ: case BSEC_OUTPUT_STATIC_IAQ: { - uint8_t accuracy = outputs[i].accuracy; + uint8_t accuracy = std::min(outputs[i].accuracy, std::size(IAQ_ACCURACY_STATES) - 1); this->queue_push_([this, signal]() { this->publish_sensor_(this->iaq_sensor_, signal); }); this->queue_push_([this, accuracy]() { this->publish_sensor_(this->iaq_accuracy_text_sensor_, IAQ_ACCURACY_STATES[accuracy]); diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index 1a42c9d54b6..0210d1e67d7 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -438,6 +438,7 @@ void BME68xBSEC2Component::publish_(const bsec_output_t *outputs, uint8_t num_ou } } if (update_accuracy) { + max_accuracy = std::min(max_accuracy, std::size(IAQ_ACCURACY_STATES) - 1); #ifdef USE_SENSOR this->queue_push_( [this, max_accuracy]() { this->publish_sensor_(this->iaq_accuracy_sensor_, max_accuracy, true); }); diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index 1e8c91d7b79..ed92979d24e 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -6,6 +6,7 @@ namespace esphome { namespace bmi160 { static const char *const TAG = "bmi160"; +static constexpr uint32_t GYRO_WAKEUP_TIMEOUT_MS = 100; const uint8_t BMI160_REGISTER_CHIPID = 0x00; @@ -144,7 +145,7 @@ void BMI160Component::internal_setup_(int stage) { } ESP_LOGV(TAG, " Waiting for gyroscope to wake up"); // wait between 51 & 81ms, doing 100 to be safe - this->set_timeout(10, [this]() { this->internal_setup_(2); }); + this->set_timeout(GYRO_WAKEUP_TIMEOUT_MS, [this]() { this->internal_setup_(2); }); break; case 2: diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index de685e7c278..603966a2b52 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -2,7 +2,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" -#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID" +#define BMP280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response" namespace esphome { namespace bmp280_base { diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 2b73d8735c7..32278dbfbd4 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -10,7 +10,12 @@ #ifdef USE_ESP32 +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else #include "mbedtls/ccm.h" +#endif namespace esphome { namespace bthome_mithermometer { @@ -196,6 +201,37 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da const uint8_t *ciphertext = data.data() + 1; const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // PSA AEAD expects ciphertext + tag concatenated + // BLE advertisement max payload is 31 bytes, so this is always sufficient + static constexpr size_t MAX_CT_WITH_TAG = 32; + uint8_t ct_with_tag[MAX_CT_WITH_TAG]; + size_t ct_with_tag_size = ciphertext_size + BTHOME_MIC_SIZE; + memcpy(ct_with_tag, ciphertext, ciphertext_size); + memcpy(ct_with_tag + ciphertext_size, mic, BTHOME_MIC_SIZE); + + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, BTHOME_BINDKEY_SIZE * 8); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE)); + + mbedtls_svc_key_id_t key_id; + if (psa_import_key(&attributes, this->bindkey_, BTHOME_BINDKEY_SIZE, &key_id) != PSA_SUCCESS) { + ESP_LOGVV(TAG, "psa_import_key() failed."); + return false; + } + + size_t plaintext_length; + psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE), + nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size, + payload.data(), ciphertext_size, &plaintext_length); + psa_destroy_key(key_id); + if (status != PSA_SUCCESS || plaintext_length != ciphertext_size) { + ESP_LOGVV(TAG, "BTHome decryption failed."); + return false; + } +#else mbedtls_ccm_context ctx; mbedtls_ccm_init(&ctx); @@ -213,6 +249,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret); return false; } +#endif return true; } diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 89181d27b4a..a0c59a517a7 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 7b51c2c45c1..c94c8647a95 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -155,6 +155,7 @@ async def register_canbus(var, config): validate_id, key=CONF_DATA, ), + synchronous=True, ) async def canbus_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index e208b0fd66f..ce48bfbba51 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -1,4 +1,5 @@ #include "canbus.h" +#include #include "esphome/core/log.h" namespace esphome { @@ -82,7 +83,7 @@ void Canbus::loop() { std::vector data; // show data received - for (int i = 0; i < can_message.can_data_length_code; i++) { + for (int i = 0; i < std::min(can_message.can_data_length_code, CAN_MAX_DATA_LENGTH); i++) { ESP_LOGV(TAG, " can_message.data[%d]=%02x", i, can_message.data[i]); data.push_back(can_message.data[i]); } diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 5af6ab29a2e..183f16c5f84 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -61,7 +61,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { // Defer save to main loop thread to avoid NVS operations from HTTP thread this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid.c_str(), psk.c_str()); }); #endif - request->redirect(ESPHOME_F("/?save")); + request->send(200, ESPHOME_F("text/plain"), ESPHOME_F("Saved. Connecting...")); } void CaptivePortal::setup() { @@ -71,7 +71,7 @@ void CaptivePortal::setup() { void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { - this->base_->add_handler(this); + this->base_->add_handler_without_auth(this); } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/captive_portal/dns_server_esp32_idf.cpp index bd9989a40cb..56ad9f7176b 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/captive_portal/dns_server_esp32_idf.cpp @@ -14,6 +14,7 @@ static const char *const TAG = "captive_portal.dns"; // DNS constants static constexpr uint16_t DNS_PORT = 53; static constexpr uint16_t DNS_QR_FLAG = 1 << 15; +static constexpr uint16_t DNS_AA_FLAG = 1 << 10; static constexpr uint16_t DNS_OPCODE_MASK = 0x7800; static constexpr uint16_t DNS_QTYPE_A = 0x0001; static constexpr uint16_t DNS_QCLASS_IN = 0x0001; @@ -99,8 +100,9 @@ void DNSServer::process_next_request() { &client_addr_len); if (len < 0) { - if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { - ESP_LOGE(TAG, "recvfrom failed: %d", errno); + const int err = errno; + if (err != EAGAIN && err != EWOULDBLOCK && err != EINTR) { + ESP_LOGE(TAG, "recvfrom failed: %d", err); } return; } @@ -162,8 +164,8 @@ void DNSServer::process_next_request() { } // Build DNS response by modifying the request in-place - header->flags = htons(DNS_QR_FLAG | 0x8000); // Response + Authoritative - header->an_count = htons(1); // One answer + header->flags = htons(DNS_QR_FLAG | DNS_AA_FLAG); // Response + Authoritative + header->an_count = htons(1); // One answer // Add answer section after the question size_t question_len = (ptr + sizeof(DNSQuestion)) - this->buffer_ - sizeof(DNSHeader); diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index e2e5986daff..27092908621 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -287,10 +287,18 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action("cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA) -@automation.register_action("cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA) -@automation.register_action("cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA) -@automation.register_action("cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA) +@automation.register_action( + "cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA, synchronous=True +) async def cc1101_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -317,7 +325,10 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA + "cc1101.send_packet", + SendPacketAction, + SEND_PACKET_ACTION_SCHEMA, + synchronous=True, ) async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -419,9 +430,9 @@ def _register_setter_actions(): cg.add(getattr(var, _setter)(_map[data] if _map else data)) return var - automation.register_action(f"cc1101.{setter_name}", action_cls, schema)( - _setter_action_to_code - ) + automation.register_action( + f"cc1101.{setter_name}", action_cls, schema, synchronous=True + )(_setter_action_to_code) _register_setter_actions() diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index f5b91c502c7..8cf5fa9b0c2 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -476,7 +476,10 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "climate.control", ControlAction, CLIMATE_CONTROL_ACTION_SCHEMA + "climate.control", + ControlAction, + CLIMATE_CONTROL_ACTION_SCHEMA, + synchronous=True, ) async def climate_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 1d95bcc6665..3c82fac977f 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -65,6 +65,7 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( "cm1106.calibrate_zero", CM1106CalibrateZeroAction, CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) async def cm1106_calibration_to_code(config, action_id, template_arg, args) -> None: """Service code generation entry point.""" diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index ece7cca4825..2f0bd26a02d 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -163,7 +163,7 @@ void MeanCombinationComponent::handle_new_value(float value) { return; float sum = 0.0; - size_t count = 0.0; + size_t count = 0; for (const auto &sensor : this->sensors_) { if (std::isfinite(sensor->state)) { @@ -172,6 +172,10 @@ void MeanCombinationComponent::handle_new_value(float value) { } } + if (count == 0) { + this->publish_state(NAN); + return; + } float mean = sum / count; this->publish_state(mean); @@ -238,6 +242,11 @@ void RangeCombinationComponent::handle_new_value(float value) { } } + if (sensor_states.empty()) { + this->publish_state(NAN); + return; + } + sort(sensor_states.begin(), sensor_states.end()); float range = sensor_states.back() - sensor_states.front(); diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 059bf3f26a9..f6da32569fe 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -3,6 +3,7 @@ CODEOWNERS = ["@esphome/core"] CONF_BYTE_ORDER = "byte_order" +CONF_CLIMATE_ID = "climate_id" BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 07b5ea1c635..d2383bd01b8 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -132,6 +132,7 @@ async def to_code(config): cv.Required(CONF_ID): cv.use_id(CS5460AComponent), } ), + synchronous=True, ) async def restart_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 806b79e19e0..ce77b62b7b4 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include namespace esphome::cse7766 { @@ -192,12 +193,12 @@ void CSE7766Component::parse_data_() { this->apparent_power_sensor_->publish_state(apparent_power); } if (have_power && this->reactive_power_sensor_ != nullptr) { - const float reactive_power = apparent_power - power; - if (reactive_power < 0.0f) { - ESP_LOGD(TAG, "Impossible reactive power: %.4f is negative", reactive_power); + const float q_squared = apparent_power * apparent_power - power * power; + if (q_squared < 0.0f) { + ESP_LOGD(TAG, "Impossible reactive power: S^2-P^2 is negative (%.4f)", q_squared); this->reactive_power_sensor_->publish_state(0.0f); } else { - this->reactive_power_sensor_->publish_state(reactive_power); + this->reactive_power_sensor_->publish_state(std::sqrt(q_squared)); } } if (this->power_factor_sensor_ != nullptr && (have_power || power_cycle_exceeds_range)) { diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index 83f8722e7fc..27ab54f0be9 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -62,6 +62,8 @@ void DAC7678Output::register_channel(DAC7678Channel *channel) { } void DAC7678Output::set_channel_value_(uint8_t channel, uint16_t value) { + if (channel >= std::size(this->dac_input_reg_)) + return; if (this->dac_input_reg_[channel] != value) { ESP_LOGV(TAG, "Channel %01u: input_reg=%04u ", channel, value); diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index c45fa307a7c..18f12dbfc6c 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -91,11 +91,10 @@ void DaikinArcClimate::transmit_state() { remote_state[5] = this->operation_mode_() | 0x08; remote_state[6] = this->temperature_(); remote_state[7] = this->humidity_(); - static uint8_t last_humidity = 0x66; - if (remote_state[7] != last_humidity && this->mode != climate::CLIMATE_MODE_OFF) { + if (remote_state[7] != this->last_humidity_ && this->mode != climate::CLIMATE_MODE_OFF) { ESP_LOGD(TAG, "Set Humditiy: %d, %d\n", (int) this->target_humidity, (int) remote_state[7]); remote_header[9] |= 0x10; - last_humidity = remote_state[7]; + this->last_humidity_ = remote_state[7]; } uint16_t fan_speed = this->fan_speed_(); remote_state[8] = fan_speed >> 8; @@ -350,8 +349,9 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) { if (data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { valid_daikin_frame = true; size_t bytes_count = data.size() / 2 / 8; - size_t buf_size = bytes_count * 3 + 1; - std::unique_ptr buf(new char[buf_size]()); // value-initialize (zero-fill) + // Header (20) + state (19) = 39 bytes max; truncates gracefully via buf_append_printf + char buf[40 * 3 + 1] = {}; + constexpr size_t buf_size = sizeof(buf); size_t buf_pos = 0; for (size_t i = 0; i < bytes_count; i++) { uint8_t byte = 0; @@ -363,9 +363,9 @@ bool DaikinArcClimate::on_receive(remote_base::RemoteReceiveData data) { break; } } - buf_pos = buf_append_printf(buf.get(), buf_size, buf_pos, "%02x ", byte); + buf_pos = buf_append_printf(buf, buf_size, buf_pos, "%02x ", byte); } - ESP_LOGD(TAG, "WHOLE FRAME %s size: %d", buf.get(), data.size()); + ESP_LOGD(TAG, "WHOLE FRAME %s size: %d", buf, data.size()); } if (!valid_daikin_frame) { char sbuf[16 * 10 + 1] = {0}; diff --git a/esphome/components/daikin_arc/daikin_arc.h b/esphome/components/daikin_arc/daikin_arc.h index 6cfffd47255..2b4d4375aae 100644 --- a/esphome/components/daikin_arc/daikin_arc.h +++ b/esphome/components/daikin_arc/daikin_arc.h @@ -70,6 +70,7 @@ class DaikinArcClimate : public climate_ir::ClimateIR { // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); + uint8_t last_humidity_{0x66}; }; } // namespace daikin_arc diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 74c9d594f75..90835624bf1 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -187,6 +187,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def datetime_date_set_to_code(config, action_id, template_arg, args): action_var = cg.new_Pvariable(action_id, template_arg) @@ -218,6 +219,7 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ), } ), + synchronous=True, ) async def datetime_time_set_to_code(config, action_id, template_arg, args): action_var = cg.new_Pvariable(action_id, template_arg) @@ -249,6 +251,7 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ), }, ), + synchronous=True, ) async def datetime_datetime_set_to_code(config, action_id, template_arg, args): action_var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index e4f4bb36eba..3da6b800c6f 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -18,6 +18,7 @@ namespace debug { static constexpr size_t DEVICE_INFO_BUFFER_SIZE = 256; static constexpr size_t RESET_REASON_BUFFER_SIZE = 128; +static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128; // buf_append_printf is now provided by esphome/core/helpers.h @@ -94,7 +95,7 @@ class DebugComponent : public PollingComponent { #endif // USE_TEXT_SENSOR const char *get_reset_reason_(std::span buffer); - const char *get_wakeup_cause_(std::span buffer); + const char *get_wakeup_cause_(std::span buffer); uint32_t get_free_heap_(); size_t get_device_info_(std::span buffer, size_t pos); void update_platform_(); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 6898621dd05..aa379599c62 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -5,6 +5,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include #include #include @@ -82,32 +83,74 @@ const char *DebugComponent::get_reset_reason_(std::span= ESP_IDF_VERSION_VAL(6, 0, 0) static const char *const WAKEUP_CAUSES[] = { - "undefined", - "undefined", - "external signal using RTC_IO", - "external signal using RTC_CNTL", - "timer", - "touchpad", - "ULP program", - "GPIO", - "UART", - "WIFI", - "COCPU int", - "COCPU crash", - "BT", + "undefined", // ESP_SLEEP_WAKEUP_UNDEFINED (0) + "undefined", // ESP_SLEEP_WAKEUP_ALL (1) + "external signal using RTC_IO", // ESP_SLEEP_WAKEUP_EXT0 (2) + "external signal using RTC_CNTL", // ESP_SLEEP_WAKEUP_EXT1 (3) + "timer", // ESP_SLEEP_WAKEUP_TIMER (4) + "touchpad", // ESP_SLEEP_WAKEUP_TOUCHPAD (5) + "ULP program", // ESP_SLEEP_WAKEUP_ULP (6) + "GPIO", // ESP_SLEEP_WAKEUP_GPIO (7) + "UART", // ESP_SLEEP_WAKEUP_UART (8) + "UART1", // ESP_SLEEP_WAKEUP_UART1 (9) + "UART2", // ESP_SLEEP_WAKEUP_UART2 (10) + "WIFI", // ESP_SLEEP_WAKEUP_WIFI (11) + "COCPU int", // ESP_SLEEP_WAKEUP_COCPU (12) + "COCPU crash", // ESP_SLEEP_WAKEUP_COCPU_TRAP_TRIG (13) + "BT", // ESP_SLEEP_WAKEUP_BT (14) + "VAD", // ESP_SLEEP_WAKEUP_VAD (15) + "VBAT under voltage", // ESP_SLEEP_WAKEUP_VBAT_UNDER_VOLT (16) }; +#else +static const char *const WAKEUP_CAUSES[] = { + "undefined", // ESP_SLEEP_WAKEUP_UNDEFINED (0) + "undefined", // ESP_SLEEP_WAKEUP_ALL (1) + "external signal using RTC_IO", // ESP_SLEEP_WAKEUP_EXT0 (2) + "external signal using RTC_CNTL", // ESP_SLEEP_WAKEUP_EXT1 (3) + "timer", // ESP_SLEEP_WAKEUP_TIMER (4) + "touchpad", // ESP_SLEEP_WAKEUP_TOUCHPAD (5) + "ULP program", // ESP_SLEEP_WAKEUP_ULP (6) + "GPIO", // ESP_SLEEP_WAKEUP_GPIO (7) + "UART", // ESP_SLEEP_WAKEUP_UART (8) + "WIFI", // ESP_SLEEP_WAKEUP_WIFI (9) + "COCPU int", // ESP_SLEEP_WAKEUP_COCPU (10) + "COCPU crash", // ESP_SLEEP_WAKEUP_COCPU_TRAP_TRIG (11) + "BT", // ESP_SLEEP_WAKEUP_BT (12) +}; +#endif -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { - const char *wake_reason; - unsigned reason = esp_sleep_get_wakeup_cause(); - if (reason < sizeof(WAKEUP_CAUSES) / sizeof(WAKEUP_CAUSES[0])) { - wake_reason = WAKEUP_CAUSES[reason]; - } else { - wake_reason = "unknown source"; +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { + static constexpr auto NUM_CAUSES = sizeof(WAKEUP_CAUSES) / sizeof(WAKEUP_CAUSES[0]); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // IDF 6.0+ returns a bitmap of all wakeup sources + uint32_t causes = esp_sleep_get_wakeup_causes(); + if (causes == 0) { + return WAKEUP_CAUSES[0]; // "undefined" } - // Return the static string directly - no need to copy to buffer - return wake_reason; + char *p = buffer.data(); + char *end = p + buffer.size(); + *p = '\0'; + const char *sep = ""; + for (unsigned i = 0; i < NUM_CAUSES && p < end; i++) { + if (causes & (1U << i)) { + size_t needed = strlen(sep) + strlen(WAKEUP_CAUSES[i]); + if (p + needed >= end) { + break; + } + p += snprintf(p, end - p, "%s%s", sep, WAKEUP_CAUSES[i]); + sep = ", "; + } + } + return buffer.data(); +#else + unsigned reason = esp_sleep_get_wakeup_cause(); + if (reason < NUM_CAUSES) { + return WAKEUP_CAUSES[reason]; + } + return "unknown source"; +#endif } void DebugComponent::log_partition_info_() { @@ -196,9 +239,10 @@ size_t DebugComponent::get_device_info_(std::span uint32_t cpu_freq_mhz = arch_get_cpu_freq_hz() / 1000000; pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32 " MHz", cpu_freq_mhz); - char reason_buffer[RESET_REASON_BUFFER_SIZE]; - const char *reset_reason = get_reset_reason_(std::span(reason_buffer)); - const char *wakeup_cause = get_wakeup_cause_(std::span(reason_buffer)); + char reset_buffer[RESET_REASON_BUFFER_SIZE]; + char wakeup_buffer[WAKEUP_CAUSE_BUFFER_SIZE]; + const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); + const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); uint8_t mac[6]; get_mac_address_raw(mac); diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 4df4aaa8513..0519ab72fe9 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -91,7 +91,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // ESP8266 doesn't have detailed wakeup cause like ESP32 return ""; } diff --git a/esphome/components/debug/debug_host.cpp b/esphome/components/debug/debug_host.cpp index 2fa88f0909c..0dfab86e4c4 100644 --- a/esphome/components/debug/debug_host.cpp +++ b/esphome/components/debug/debug_host.cpp @@ -7,7 +7,7 @@ namespace debug { const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 39269d6f2f2..1d458c602a6 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -12,7 +12,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } uint32_t DebugComponent::get_free_heap_() { return lt_heap_get_free(); } diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2040.cpp index c9d41942dbc..73f08492c86 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2040.cpp @@ -1,23 +1,81 @@ #include "debug_component.h" #ifdef USE_RP2040 +#include "esphome/core/defines.h" #include "esphome/core/log.h" #include +#include +#if defined(PICO_RP2350) +#include +#else +#include +#endif +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif namespace esphome { namespace debug { static const char *const TAG = "debug"; -const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } +const char *DebugComponent::get_reset_reason_(std::span buffer) { + char *buf = buffer.data(); + const size_t size = RESET_REASON_BUFFER_SIZE; + size_t pos = 0; -const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } +#if defined(PICO_RP2350) + uint32_t chip_reset = powman_hw->chip_reset; + if (chip_reset & 0x04000000) // HAD_GLITCH_DETECT + pos = buf_append_str(buf, size, pos, "Power supply glitch|"); + if (chip_reset & 0x00040000) // HAD_RUN_LOW + pos = buf_append_str(buf, size, pos, "RUN pin|"); + if (chip_reset & 0x00020000) // HAD_BOR + pos = buf_append_str(buf, size, pos, "Brown-out|"); + if (chip_reset & 0x00010000) // HAD_POR + pos = buf_append_str(buf, size, pos, "Power-on reset|"); +#else + uint32_t chip_reset = vreg_and_chip_reset_hw->chip_reset; + if (chip_reset & 0x00010000) // HAD_RUN + pos = buf_append_str(buf, size, pos, "RUN pin|"); + if (chip_reset & 0x00000100) // HAD_POR + pos = buf_append_str(buf, size, pos, "Power-on reset|"); +#endif -uint32_t DebugComponent::get_free_heap_() { return rp2040.getFreeHeap(); } + if (watchdog_caused_reboot()) { + bool handled = false; +#ifdef USE_RP2040_CRASH_HANDLER + if (rp2040::crash_handler_has_data()) { + pos = buf_append_str(buf, size, pos, "Crash (HardFault)|"); + handled = true; + } +#endif + if (!handled) { + if (watchdog_enable_caused_reboot()) { + pos = buf_append_str(buf, size, pos, "Watchdog timeout|"); + } else { + pos = buf_append_str(buf, size, pos, "Software reset|"); + } + } + } + + // Remove trailing '|' + if (pos > 0 && buf[pos - 1] == '|') { + buf[pos - 1] = '\0'; + } else if (pos == 0) { + return "Unknown"; + } + + return buf; +} + +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { return ""; } + +uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = rp2040.f_cpu(); + uint32_t cpu_freq = ::rp2040.f_cpu(); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index bd6432e9499..bf87b7ae3dc 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -53,7 +53,7 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { +const char *DebugComponent::get_wakeup_cause_(std::span buffer) { // Zephyr doesn't have detailed wakeup cause like ESP32 return ""; } diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 3cfe7aa6417..4098fd3fb8b 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -405,7 +405,10 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( @automation.register_action( - "deep_sleep.enter", EnterDeepSleepAction, DEEP_SLEEP_ENTER_SCHEMA + "deep_sleep.enter", + EnterDeepSleepAction, + DEEP_SLEEP_ENTER_SCHEMA, + synchronous=True, ) async def deep_sleep_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -428,11 +431,13 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): "deep_sleep.prevent", PreventDeepSleepAction, automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + synchronous=True, ) @automation.register_action( "deep_sleep.allow", AllowDeepSleepAction, automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + synchronous=True, ) async def deep_sleep_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 76cb24c2f4a..9976e5c7f06 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -32,8 +32,8 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { auto code = call.get_code(); switch (state) { case ACP_STATE_ARMED_AWAY: - if (this->get_requires_code_to_arm() && code.has_value()) { - if (*code != "1234") { + if (this->get_requires_code_to_arm()) { + if (!code.has_value() || *code != "1234") { this->status_momentary_error("invalid_code", 5000); return; } @@ -41,8 +41,8 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { this->publish_state(ACP_STATE_ARMED_AWAY); break; case ACP_STATE_DISARMED: - if (this->get_requires_code() && code.has_value()) { - if (*code != "1234") { + if (this->get_requires_code()) { + if (!code.has_value() || *code != "1234") { this->status_momentary_error("invalid_code", 5000); return; } diff --git a/esphome/components/dew_point/__init__.py b/esphome/components/dew_point/__init__.py new file mode 100644 index 00000000000..3b852436c3c --- /dev/null +++ b/esphome/components/dew_point/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@CFlix"] diff --git a/esphome/components/dew_point/dew_point.cpp b/esphome/components/dew_point/dew_point.cpp new file mode 100644 index 00000000000..04ac305e3d2 --- /dev/null +++ b/esphome/components/dew_point/dew_point.cpp @@ -0,0 +1,82 @@ + +#include "dew_point.h" + +namespace esphome::dew_point { + +static const char *const TAG = "dew_point.sensor"; + +void DewPointComponent::setup() { + // Register callbacks for sensor updates + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->add_on_state_callback([this](float state) { + this->temperature_value_ = state; + this->enable_loop(); + }); + // Get initial value + if (this->temperature_sensor_->has_state()) { + this->temperature_value_ = this->temperature_sensor_->get_state(); + } + } + + if (this->humidity_sensor_ != nullptr) { + this->humidity_sensor_->add_on_state_callback([this](float state) { + this->humidity_value_ = state; + this->enable_loop(); + }); + // Get initial value + if (this->humidity_sensor_->has_state()) { + this->humidity_value_ = this->humidity_sensor_->get_state(); + } + } +} + +void DewPointComponent::dump_config() { + LOG_SENSOR("", "Dew Point", this); + ESP_LOGCONFIG(TAG, + "Sources\n" + " Temperature: '%s'\n" + " Humidity: '%s'", + this->temperature_sensor_->get_name().c_str(), this->humidity_sensor_->get_name().c_str()); +} + +float DewPointComponent::get_setup_priority() const { return setup_priority::DATA; } + +void DewPointComponent::loop() { + // Only run once + this->disable_loop(); + + // Check if we have valid values for both sensors + if (std::isnan(this->temperature_value_) || std::isnan(this->humidity_value_)) { + ESP_LOGW(TAG, "Temperature or humidity value is NaN, skipping calculation"); + this->publish_state(NAN); + return; + } + + // Check for valid humidity range + if (this->humidity_value_ <= 0.0f || this->humidity_value_ > 100.0f) { + ESP_LOGW(TAG, "Humidity value out of range (0-100): %.2f", this->humidity_value_); + this->publish_state(NAN); + return; + } + + // Magnus formula constants + const float a{17.625f}; + const float b{243.04f}; + + // Calculate dew point using Magnus formula + // Td = (b * alpha) / (a - alpha) + // where alpha = ln(RH/100) + (a * T) / (b + T) + + const float alpha{std::log(this->humidity_value_ / 100.0f) + + (a * this->temperature_value_) / (b + this->temperature_value_)}; + + const float dew_point{(b * alpha) / (a - alpha)}; + + // Publish the calculated dew point + this->publish_state(dew_point); + + ESP_LOGD(TAG, "'%s' >> %.1f°C (T: %.1f°C, RH: %.1f%%)", this->get_name().c_str(), dew_point, this->temperature_value_, + this->humidity_value_); +} + +} // namespace esphome::dew_point diff --git a/esphome/components/dew_point/dew_point.h b/esphome/components/dew_point/dew_point.h new file mode 100644 index 00000000000..833c50fba25 --- /dev/null +++ b/esphome/components/dew_point/dew_point.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::dew_point { + +class DewPointComponent : public Component, public sensor::Sensor { + public: + void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + + void setup() override; + void dump_config() override; + void loop() override; + + float get_setup_priority() const override; + + protected: + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + float temperature_value_{NAN}; + float humidity_value_{NAN}; +}; + +} // namespace esphome::dew_point diff --git a/esphome/components/dew_point/sensor.py b/esphome/components/dew_point/sensor.py new file mode 100644 index 00000000000..4fee0956021 --- /dev/null +++ b/esphome/components/dew_point/sensor.py @@ -0,0 +1,46 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) + +DEPENDENCIES = ["sensor"] + +dew_point_ns = cg.esphome_ns.namespace("dew_point") +DewPointComponent = dew_point_ns.class_( + "DewPointComponent", cg.Component, sensor.Sensor +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + DewPointComponent, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:weather-rainy", + ) + .extend( + { + cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor), + cv.Required(CONF_HUMIDITY): cv.use_id(sensor.Sensor), + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + + temperature_sensor = await cg.get_variable(config[CONF_TEMPERATURE]) + cg.add(var.set_temperature_sensor(temperature_sensor)) + + humidity_sensor = await cg.get_variable(config[CONF_HUMIDITY]) + cg.add(var.set_humidity_sensor(humidity_sensor)) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 53ebda6bcc8..9df108c9c0a 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -91,6 +91,7 @@ async def to_code(config): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_next_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -106,6 +107,7 @@ async def dfplayer_next_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_previous_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -123,6 +125,7 @@ async def dfplayer_previous_to_code(config, action_id, template_arg, args): }, key=CONF_FILE, ), + synchronous=True, ) async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -143,6 +146,7 @@ async def dfplayer_play_mp3_to_code(config, action_id, template_arg, args): }, key=CONF_FILE, ), + synchronous=True, ) async def dfplayer_play_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -166,6 +170,7 @@ async def dfplayer_play_to_code(config, action_id, template_arg, args): cv.Optional(CONF_LOOP): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -191,6 +196,7 @@ async def dfplayer_play_folder_to_code(config, action_id, template_arg, args): }, key=CONF_DEVICE, ), + synchronous=True, ) async def dfplayer_set_device_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -210,6 +216,7 @@ async def dfplayer_set_device_to_code(config, action_id, template_arg, args): }, key=CONF_VOLUME, ), + synchronous=True, ) async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -227,6 +234,7 @@ async def dfplayer_set_volume_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_volume_up_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -242,6 +250,7 @@ async def dfplayer_volume_up_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -259,6 +268,7 @@ async def dfplayer_volume_down_to_code(config, action_id, template_arg, args): }, key=CONF_EQ_PRESET, ), + synchronous=True, ) async def dfplayer_set_eq_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -276,6 +286,7 @@ async def dfplayer_set_eq_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_sleep_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -291,6 +302,7 @@ async def dfplayer_sleep_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -306,6 +318,7 @@ async def dfplayer_reset_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_start_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -321,6 +334,7 @@ async def dfplayer_start_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_pause_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -336,6 +350,7 @@ async def dfplayer_pause_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -351,6 +366,7 @@ async def dfplayer_stop_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DFPlayer), } ), + synchronous=True, ) async def dfplayer_random_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index d54b147036e..ba77e56abb0 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -52,6 +52,7 @@ async def to_code(config): cv.GenerateID(): cv.use_id(DfrobotSen0395Component), } ), + synchronous=True, ) async def dfrobot_sen0395_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -151,6 +152,7 @@ MMWAVE_SETTINGS_SCHEMA = cv.Schema( "dfrobot_sen0395.settings", DfrobotSen0395SettingsAction, MMWAVE_SETTINGS_SCHEMA, + synchronous=True, ) async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 919fda76a0e..2cc4a4e8951 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -168,6 +168,7 @@ async def register_display(var, config): cv.Required(CONF_ID): cv.templatable(cv.use_id(DisplayPage)), } ), + synchronous=True, ) async def display_page_show_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -188,6 +189,7 @@ async def display_page_show_to_code(config, action_id, template_arg, args): cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), } ), + synchronous=True, ) async def display_page_show_next_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -202,6 +204,7 @@ async def display_page_show_next_to_code(config, action_id, template_arg, args): cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), } ), + synchronous=True, ) async def display_page_show_previous_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 658292ec7a3..c9a0c7ee93e 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -294,50 +294,67 @@ MENU_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("display_menu.up", UpAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.up", UpAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_up_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.down", DownAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.down", DownAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_down_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.left", LeftAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.left", LeftAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_left_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.right", RightAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.right", RightAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_right_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.enter", EnterAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.enter", EnterAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_enter_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.show", ShowAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.show", ShowAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_show_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("display_menu.hide", HideAction, MENU_ACTION_SCHEMA) +@automation.register_action( + "display_menu.hide", HideAction, MENU_ACTION_SCHEMA, synchronous=True +) async def menu_hide_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( - "display_menu.show_main", ShowMainAction, MENU_ACTION_SCHEMA + "display_menu.show_main", + ShowMainAction, + MENU_ACTION_SCHEMA, + synchronous=True, ) async def menu_show_main_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index bd2150e8ddf..052a0f4d01f 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -3,9 +3,14 @@ #if defined(USE_ESP8266_FRAMEWORK_ARDUINO) #include #elif defined(USE_ESP32) +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else #include "mbedtls/esp_config.h" #include "mbedtls/gcm.h" #endif +#endif namespace esphome::dlms_meter { @@ -240,6 +245,35 @@ bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t m br_gcm_flip(&gcm_ctx); br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); #elif defined(USE_ESP32) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior) + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, this->decryption_key_.size() * 8); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_GCM); + + mbedtls_svc_key_id_t key_id; + bool decrypt_failed = true; + if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) { + psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; + if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS && + psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) { + size_t outlen = 0; + if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS && + outlen == message_length) { + decrypt_failed = false; + } + } + psa_aead_abort(&op); + psa_destroy_key(key_id); + } + if (decrypt_failed) { + ESP_LOGE(TAG, "Decryption failed"); + this->receive_buffer_.clear(); + return false; + } +#else size_t outlen = 0; mbedtls_gcm_context gcm_ctx; mbedtls_gcm_init(&gcm_ctx); @@ -252,6 +286,7 @@ bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t m this->receive_buffer_.clear(); return false; } +#endif #else #error "Invalid Platform" #endif diff --git a/esphome/components/ds1307/time.py b/esphome/components/ds1307/time.py index 42b7184db9b..0e7bb976a2d 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -27,6 +27,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(DS1307Component), } ), + synchronous=True, ) async def ds1307_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -42,6 +43,7 @@ async def ds1307_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(DS1307Component), } ), + synchronous=True, ) async def ds1307_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index d9fb2a6d604..d21802ebb6c 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -41,9 +41,9 @@ class DutyTimeSensor : public sensor::Sensor, public PollingComponent { sensor::Sensor *last_duty_time_sensor_{nullptr}; ESPPreferenceObject pref_; - uint32_t total_sec_; - uint32_t last_time_; - uint32_t edge_time_; + uint32_t total_sec_{0}; + uint32_t last_time_{0}; + uint32_t edge_time_{0}; bool last_state_{false}; bool restore_; }; diff --git a/esphome/components/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 1907b3fcfec..456859f8e49 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -90,21 +90,27 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( ) -@register_action("sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA) +@register_action( + "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True +) async def sensor_runtime_start_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -@register_action("sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA) +@register_action( + "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True +) async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -@register_action("sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA) +@register_action( + "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True +) async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index b90e6d5c914..600793f5d35 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -1,3 +1,4 @@ +#include #include #include "e131.h" #ifdef USE_NETWORK @@ -57,7 +58,7 @@ union E131RawPacket { // We need to have at least one `1` value // Get the offset of `property_values[1]` -const size_t E131_MIN_PACKET_SIZE = reinterpret_cast(&((E131RawPacket *) nullptr)->property_values[1]); +const size_t E131_MIN_PACKET_SIZE = offsetof(E131RawPacket, property_values) + sizeof(uint8_t); bool E131Component::join_igmp_groups_() { if (this->listen_method_ != E131_MULTICAST) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 72026a2a842..c252cdb707d 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -152,7 +152,7 @@ void ES8388::dump_config() { bool ES8388::set_volume(float volume) { volume = clamp(volume, 0.0f, 1.0f); - uint8_t value = remap(volume, 0.0f, 1.0f, -96, 0); + uint8_t value = remap(volume, 0.0f, 1.0f, 192, 0); ESP_LOGD(TAG, "Setting ES8388_DACCONTROL4 / ES8388_DACCONTROL5 to 0x%02X (volume: %f)", value, volume); ES8388_ERROR_CHECK(this->write_byte(ES8388_DACCONTROL4, value)); ES8388_ERROR_CHECK(this->write_byte(ES8388_DACCONTROL5, value)); @@ -163,7 +163,7 @@ bool ES8388::set_volume(float volume) { float ES8388::volume() { uint8_t value; ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL4, &value)); - return remap(value, -96, 0, 0.0f, 1.0f); + return remap(value, 192, 0, 0.0f, 1.0f); } bool ES8388::set_mute_state_(bool mute_state) { diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 8ad84656ede..eaa9aa163d9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_BOARD, CONF_COMPONENTS, CONF_DISABLED, + CONF_ENABLE_FULL_PRINTF, CONF_ENABLE_OTA_ROLLBACK, CONF_ESPHOME, CONF_FRAMEWORK, @@ -58,6 +59,7 @@ from .const import ( # noqa KEY_EXTRA_BUILD_FILES, KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, + KEY_IDF_VERSION, KEY_PATH, KEY_REF, KEY_REPO, @@ -419,9 +421,20 @@ def set_core_data(config): CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] = excluded # Initialize Arduino library tracking - cg.add_library() auto-enables libraries CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES] = set() - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( - config[CONF_FRAMEWORK][CONF_VERSION] - ) + framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION]) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver + + # Store the underlying IDF version for framework-agnostic checks + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver + elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver + else: + raise cv.Invalid( + f"Arduino version {framework_ver} has no known ESP-IDF version mapping. " + "Please update ARDUINO_IDF_VERSION_LOOKUP.", + path=[CONF_FRAMEWORK, CONF_VERSION], + ) CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] @@ -954,7 +967,6 @@ CONF_HEAP_IN_IRAM = "heap_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" CONF_USE_FULL_CERTIFICATE_BUNDLE = "use_full_certificate_bundle" CONF_DISABLE_DEBUG_STUBS = "disable_debug_stubs" -CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" @@ -974,6 +986,7 @@ KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" +KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" def require_vfs_select() -> None: @@ -1043,6 +1056,25 @@ def require_mbedtls_pkcs7() -> None: CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True +def require_mbedtls_sha512() -> None: + """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. + + Call this from components that need to verify TLS certificates or signatures + using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C + and CONFIG_MBEDTLS_SHA512_C from being disabled. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True + + +def idf_version() -> cv.Version: + """Return the underlying ESP-IDF version regardless of framework choice. + + For ESP-IDF builds this is the framework version directly. + For Arduino builds this is the mapped IDF version from ARDUINO_IDF_VERSION_LOOKUP. + """ + return CORE.data[KEY_ESP32][KEY_IDF_VERSION] + + def require_fatfs() -> None: """Mark that FATFS support is required by a component. @@ -1442,6 +1474,11 @@ async def to_code(config): cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") cg.add_build_flag("-Wl,-z,noexecstack") + # Arduino already wraps esp_panic_handler for its own backtrace handler, + # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") @@ -1797,6 +1834,21 @@ async def to_code(config): elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]: add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False) + # Disable SHA-384 and SHA-512 in mbedTLS + # ESPHome doesn't use either algorithm. SHA-384 shares the same + # compression function as SHA-512 (mbedtls_internal_sha512_process), + # so both must be disabled to eliminate the ~3KB software fallback + # that IDF 6.0's PSA parallel engine always links in. + # On IDF < 6.0 these are a single config and hardware-only (no + # software fallback), so there was no code size cost to leaving + # them enabled. + # Components that need SHA-384/SHA-512 can call require_mbedtls_sha512() + if idf_version() >= cv.Version(6, 0, 0) and not CORE.data[KEY_ESP32].get( + KEY_MBEDTLS_SHA512_REQUIRED, False + ): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) + # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 7874c1c759c..d0d00723fcb 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -15,6 +15,7 @@ KEY_PATH = "path" KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" +KEY_IDF_VERSION = "idf_version" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 46c000562e1..cba25bca2b2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "esphome/core/defines.h" +#include "crash_handler.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "preferences.h" @@ -36,6 +37,11 @@ void arch_restart() { } void arch_init() { +#ifdef USE_ESP32_CRASH_HANDLER + // Read crash data from previous boot before anything else + esp32::crash_handler_read_and_clear(); +#endif + // Enable the task watchdog only on the loop task (from which we're currently running) esp_task_wdt_add(nullptr); diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp new file mode 100644 index 00000000000..ecf30d78781 --- /dev/null +++ b/esphome/components/esp32/crash_handler.cpp @@ -0,0 +1,355 @@ +#ifdef USE_ESP32 + +#include "esphome/core/defines.h" +#ifdef USE_ESP32_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ARCH_XTENSA +#include +#include +#include +#elif CONFIG_IDF_TARGET_ARCH_RISCV +#include +#endif + +static constexpr uint32_t CRASH_MAGIC = 0xDEADBEEF; +static constexpr size_t MAX_BACKTRACE = 16; + +// Check if an address looks like code (flash-mapped or IRAM). +// Must be safe to call from panic context (no flash access needed). +static inline bool IRAM_ATTR is_code_addr(uint32_t addr) { + return (addr >= SOC_IROM_LOW && addr < SOC_IROM_HIGH) || (addr >= SOC_IRAM_LOW && addr < SOC_IRAM_HIGH); +} + +#if CONFIG_IDF_TARGET_ARCH_RISCV +// Check if a code address is a real return address by verifying the preceding +// instruction is a JAL or JALR with rd=ra (x1). Called at log time (not during +// panic) so flash cache is available and both IRAM and IROM are safely readable. +static inline bool is_return_addr(uint32_t addr) { + if (!is_code_addr(addr) || addr < 4) + return false; + // A return address on the stack points to the instruction after a call. + // Check for 4-byte JAL/JALR call instruction before this address. + // Use memcpy for alignment safety — RISC-V C extension means code addresses + // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. + uint32_t inst; + memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); + // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd + uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode + uint32_t rd = inst & 0xf80; // Extract rd field (bits 11:7) + // Match JAL (0x6f) or JALR (0x67) with rd=ra (x1, encoded as 0x80 = 1<<7) + if ((opcode == 0x6f || opcode == 0x67) && rd == 0x80) + return true; + // Check for 2-byte compressed c.jalr before this address (C extension). + // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 + if (addr >= 2) { + uint16_t c_inst = *(uint16_t *) (addr - 2); + if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) + return true; + } + return false; +} +#endif + +// Raw crash data written by the panic handler wrapper. +// Lives in .noinit so it survives software reset but contains garbage after power cycle. +// Validated by magic marker. Static linkage since it's only used within this file. +// Version field is first so future firmware can always identify the struct layout. +// Magic is second to validate the data. Remaining fields can change between versions. +// Version is uint32_t because it would be padded to 4 bytes anyway before the next +// uint32_t field, so we use the full width rather than wasting 3 bytes of padding. +static constexpr uint32_t CRASH_DATA_VERSION = 1; +struct RawCrashData { + uint32_t version; + uint32_t magic; + uint32_t pc; + uint8_t backtrace_count; + uint8_t reg_frame_count; // Number of entries from registers (not stack-scanned) + uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) + uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) + uint32_t backtrace[MAX_BACKTRACE]; + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) +}; +static RawCrashData __attribute__((section(".noinit"))) +s_raw_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Whether crash data was found and validated this boot. +static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +namespace esphome::esp32 { + +static const char *const TAG = "esp32.crash"; + +void crash_handler_read_and_clear() { + if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { + s_crash_data_valid = true; + // Clamp counts to prevent out-of-bounds reads from corrupt .noinit data + if (s_raw_crash_data.backtrace_count > MAX_BACKTRACE) + s_raw_crash_data.backtrace_count = MAX_BACKTRACE; + if (s_raw_crash_data.reg_frame_count > s_raw_crash_data.backtrace_count) + s_raw_crash_data.reg_frame_count = s_raw_crash_data.backtrace_count; + if (s_raw_crash_data.exception > 4) // panic_exception_t max value + s_raw_crash_data.exception = 4; // Default to PANIC_EXCEPTION_FAULT + if (s_raw_crash_data.pseudo_excause > 1) + s_raw_crash_data.pseudo_excause = 0; + } + // Clear magic regardless so we don't re-report on next normal reboot + s_raw_crash_data.magic = 0; +} + +bool crash_handler_has_data() { return s_crash_data_valid; } + +// Look up the exception cause as a human-readable string. +// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays +// not exposed via any public API. +static const char *get_exception_reason() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + if (s_raw_crash_data.pseudo_excause) { + // SoC-level panic: watchdog, cache error, etc. + // Keep in sync with ESP-IDF's PANIC_RSN_* defines + static const char *const PSEUDO_REASON[] = { + "Unknown reason", // 0 + "Unhandled debug exception", // 1 + "Double exception", // 2 + "Unhandled kernel exception", // 3 + "Coprocessor exception", // 4 + "Interrupt wdt timeout on CPU0", // 5 + "Interrupt wdt timeout on CPU1", // 6 + "Cache error", // 7 + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(PSEUDO_REASON) / sizeof(PSEUDO_REASON[0])) + return PSEUDO_REASON[cause]; + return PSEUDO_REASON[0]; + } + // Real Xtensa exception + static const char *const REASON[] = { + "IllegalInstruction", + "Syscall", + "InstructionFetchError", + "LoadStoreError", + "Level1Interrupt", + "Alloca", + "IntegerDivideByZero", + "PCValue", + "Privileged", + "LoadStoreAlignment", + nullptr, + nullptr, + "InstrPDAddrError", + "LoadStorePIFDataError", + "InstrPIFAddrError", + "LoadStorePIFAddrError", + "InstTLBMiss", + "InstTLBMultiHit", + "InstFetchPrivilege", + nullptr, + "InstrFetchProhibited", + nullptr, + nullptr, + nullptr, + "LoadStoreTLBMiss", + "LoadStoreTLBMultihit", + "LoadStorePrivilege", + nullptr, + "LoadProhibited", + "StoreProhibited", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // For SoC-level panics (watchdog, cache error), mcause holds IDF-internal + // interrupt numbers, not standard RISC-V cause codes. The exception type + // field already identifies these, so just return null to use the type name. + if (s_raw_crash_data.pseudo_excause) + return nullptr; + static const char *const REASON[] = { + "Instruction address misaligned", + "Instruction access fault", + "Illegal instruction", + "Breakpoint", + "Load address misaligned", + "Load access fault", + "Store address misaligned", + "Store access fault", + "Environment call from U-mode", + "Environment call from S-mode", + nullptr, + "Environment call from M-mode", + "Instruction page fault", + "Load page fault", + nullptr, + "Store page fault", + }; + uint32_t cause = s_raw_crash_data.cause; + if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) + return REASON[cause]; +#endif + return "Unknown"; +} + +// Exception type names matching panic_exception_t enum +static const char *get_exception_type() { + static const char *const TYPES[] = { + "Debug exception", // PANIC_EXCEPTION_DEBUG + "Interrupt wdt", // PANIC_EXCEPTION_IWDT + "Task wdt", // PANIC_EXCEPTION_TWDT + "Abort", // PANIC_EXCEPTION_ABORT + "Fault", // PANIC_EXCEPTION_FAULT + }; + uint8_t exc = s_raw_crash_data.exception; + if (exc < sizeof(TYPES) / sizeof(TYPES[0])) + return TYPES[exc]; + return "Unknown"; +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console, making it possible to see partial output if the device +// crashes again during boot, and allowing the CLI's process_stacktrace to match +// and decode each address individually. +void crash_handler_log() { + if (!s_crash_data_valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + const char *reason = get_exception_reason(); + if (reason != nullptr) { + ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + } else { + ESP_LOGE(TAG, " Reason: %s", get_exception_type()); + } + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + uint8_t bt_num = 0; + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + // Register-sourced entries (MEPC/RA) are trusted; only filter stack-scanned ones. + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif +#if CONFIG_IDF_TARGET_ARCH_RISCV + const char *source = (i < s_raw_crash_data.reg_frame_count) ? "backtrace" : "stack scan"; +#else + const char *source = "backtrace"; +#endif + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (%s)", bt_num++, addr, source); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[256]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); + for (uint8_t i = 0; i < s_raw_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + uint32_t addr = s_raw_crash_data.backtrace[i]; +#if CONFIG_IDF_TARGET_ARCH_RISCV + if (i >= s_raw_crash_data.reg_frame_count && !is_return_addr(addr)) + continue; +#endif + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, addr); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::esp32 + +// --- Panic handler wrapper --- +// Intercepts esp_panic_handler() via --wrap linker flag to capture crash data +// into NOINIT memory before the normal panic handler runs. +// +extern "C" { +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism +extern void __real_esp_panic_handler(panic_info_t *info); + +void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { + // Save the faulting PC and exception info + s_raw_crash_data.pc = (uint32_t) info->addr; + s_raw_crash_data.backtrace_count = 0; + s_raw_crash_data.reg_frame_count = 0; + s_raw_crash_data.exception = (uint8_t) info->exception; + s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; + +#if CONFIG_IDF_TARGET_ARCH_XTENSA + // Xtensa: walk the backtrace using the public API + if (info->frame != nullptr) { + auto *xt_frame = (XtExcFrame *) info->frame; + s_raw_crash_data.cause = xt_frame->exccause; + esp_backtrace_frame_t bt_frame = { + .pc = (uint32_t) xt_frame->pc, + .sp = (uint32_t) xt_frame->a1, + .next_pc = (uint32_t) xt_frame->a0, + .exc_frame = xt_frame, + }; + + uint8_t count = 0; + // First frame PC + uint32_t first_pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(first_pc)) { + s_raw_crash_data.backtrace[count++] = first_pc; + } + // Walk remaining frames + while (count < MAX_BACKTRACE && bt_frame.next_pc != 0) { + if (!esp_backtrace_get_next_frame(&bt_frame)) { + break; + } + uint32_t pc = esp_cpu_process_stack_pc(bt_frame.pc); + if (is_code_addr(pc)) { + s_raw_crash_data.backtrace[count++] = pc; + } + } + s_raw_crash_data.backtrace_count = count; + } + +#elif CONFIG_IDF_TARGET_ARCH_RISCV + // RISC-V: capture MEPC + RA, then scan stack for code addresses + if (info->frame != nullptr) { + auto *rv_frame = (RvExcFrame *) info->frame; + s_raw_crash_data.cause = rv_frame->mcause; + uint8_t count = 0; + + // Save MEPC (fault PC) and RA (return address) + if (is_code_addr(rv_frame->mepc)) { + s_raw_crash_data.backtrace[count++] = rv_frame->mepc; + } + if (is_code_addr(rv_frame->ra) && rv_frame->ra != rv_frame->mepc) { + s_raw_crash_data.backtrace[count++] = rv_frame->ra; + } + + // Track how many entries came from registers (MEPC/RA) so we can + // skip return-address validation for them at log time. + s_raw_crash_data.reg_frame_count = count; + + // Scan stack for code addresses — captures broadly during panic, + // filtered by is_return_addr() at log time when flash is accessible. + auto *scan_start = (uint32_t *) rv_frame->sp; + for (uint32_t i = 0; i < 64 && count < MAX_BACKTRACE; i++) { + uint32_t val = scan_start[i]; + if (is_code_addr(val) && val != rv_frame->mepc && val != rv_frame->ra) { + s_raw_crash_data.backtrace[count++] = val; + } + } + s_raw_crash_data.backtrace_count = count; + } +#endif + + // Write version and magic last — ensures all data is written before we mark it valid + s_raw_crash_data.version = CRASH_DATA_VERSION; + s_raw_crash_data.magic = CRASH_MAGIC; + + // Call the real panic handler (prints to UART, does core dump, reboots, etc.) + __real_esp_panic_handler(info); +} + +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +} // extern "C" + +#endif // USE_ESP32_CRASH_HANDLER +#endif // USE_ESP32 diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h new file mode 100644 index 00000000000..97a4d4e1162 --- /dev/null +++ b/esphome/components/esp32/crash_handler.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP32_CRASH_HANDLER + +namespace esphome::esp32 { + +/// Read crash data from NOINIT memory and clear the magic marker. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + +} // namespace esphome::esp32 + +#endif // USE_ESP32_CRASH_HANDLER diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index c0803f40a83..a7180cbcd7e 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -88,8 +88,8 @@ def _translate_pin(value): @dataclass class ESP32ValidationFunctions: - pin_validation: Callable[[Any], Any] - usage_validation: Callable[[Any], Any] + pin_validation: Callable[[int], int] + usage_validation: Callable[[dict[str, Any]], dict[str, Any]] _esp32_validations = { diff --git a/esphome/components/esp32/gpio_esp32.py b/esphome/components/esp32/gpio_esp32.py index 973d2dc0ef3..b3166cf822e 100644 --- a/esphome/components/esp32/gpio_esp32.py +++ b/esphome/components/esp32/gpio_esp32.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import ( @@ -22,7 +23,7 @@ _ESP32_STRAPPING_PINS = {0, 2, 5, 12, 15} _LOGGER = logging.getLogger(__name__) -def esp32_validate_gpio_pin(value): +def esp32_validate_gpio_pin(value: int) -> int: if value < 0 or value > 39: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") if value in _ESP_SDIO_PINS: @@ -41,7 +42,7 @@ def esp32_validate_gpio_pin(value): return value -def esp32_validate_supports(value): +def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c2.py b/esphome/components/esp32/gpio_esp32_c2.py index 32a24050ca7..2d6e3a4a4ed 100644 --- a/esphome/components/esp32/gpio_esp32_c2.py +++ b/esphome/components/esp32/gpio_esp32_c2.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -9,14 +10,14 @@ _ESP32C2_STRAPPING_PINS = {8, 9} _LOGGER = logging.getLogger(__name__) -def esp32_c2_validate_gpio_pin(value): +def esp32_c2_validate_gpio_pin(value: int) -> int: if value < 0 or value > 20: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-20)") return value -def esp32_c2_validate_supports(value): +def esp32_c2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c3.py b/esphome/components/esp32/gpio_esp32_c3.py index c1427cc02aa..93e0b970934 100644 --- a/esphome/components/esp32/gpio_esp32_c3.py +++ b/esphome/components/esp32/gpio_esp32_c3.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -18,7 +19,7 @@ _ESP32C3_STRAPPING_PINS = {2, 8, 9} _LOGGER = logging.getLogger(__name__) -def esp32_c3_validate_gpio_pin(value): +def esp32_c3_validate_gpio_pin(value: int) -> int: if value < 0 or value > 21: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-21)") if value in _ESP32C3_SPI_PSRAM_PINS: @@ -29,7 +30,7 @@ def esp32_c3_validate_gpio_pin(value): return value -def esp32_c3_validate_supports(value): +def esp32_c3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index fa2ce1a689c..639ed64c9ea 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA @@ -22,7 +23,7 @@ _ESP32C5_STRAPPING_PINS = {2, 7, 27, 28} _LOGGER = logging.getLogger(__name__) -def esp32_c5_validate_gpio_pin(value): +def esp32_c5_validate_gpio_pin(value: int) -> int: if value < 0 or value > 28: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-28)") if value in _ESP32C5_SPI_PSRAM_PINS: @@ -33,7 +34,7 @@ def esp32_c5_validate_gpio_pin(value): return value -def esp32_c5_validate_supports(value): +def esp32_c5_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index 5d679dede25..cfd3bca8334 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA @@ -22,7 +23,7 @@ _ESP32C6_STRAPPING_PINS = {8, 9, 15} _LOGGER = logging.getLogger(__name__) -def esp32_c6_validate_gpio_pin(value): +def esp32_c6_validate_gpio_pin(value: int) -> int: if value < 0 or value > 23: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-23)") if value in _ESP32C6_SPI_PSRAM_PINS: @@ -33,7 +34,7 @@ def esp32_c6_validate_gpio_pin(value): return value -def esp32_c6_validate_supports(value): +def esp32_c6_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_c61.py b/esphome/components/esp32/gpio_esp32_c61.py index 77be42db3e7..2f3abe6a0f9 100644 --- a/esphome/components/esp32/gpio_esp32_c61.py +++ b/esphome/components/esp32/gpio_esp32_c61.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -20,7 +21,7 @@ _ESP32C61_STRAPPING_PINS = {8, 9} _LOGGER = logging.getLogger(__name__) -def esp32_c61_validate_gpio_pin(value): +def esp32_c61_validate_gpio_pin(value: int) -> int: if value < 0 or value > 29: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-29)") if value in _ESP32C61_SPI_PSRAM_PINS: @@ -31,7 +32,7 @@ def esp32_c61_validate_gpio_pin(value): return value -def esp32_c61_validate_supports(value): +def esp32_c61_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index f37297764b8..5e7a6158f9b 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -13,7 +14,7 @@ _ESP32H2_STRAPPING_PINS = {2, 3, 8, 9, 25} _LOGGER = logging.getLogger(__name__) -def esp32_h2_validate_gpio_pin(value): +def esp32_h2_validate_gpio_pin(value: int) -> int: if value < 0 or value > 27: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-27)") if value in _ESP32H2_SPI_FLASH_PINS: @@ -33,7 +34,7 @@ def esp32_h2_validate_gpio_pin(value): return value -def esp32_h2_validate_supports(value): +def esp32_h2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 2726c5932fa..865db926524 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA @@ -14,7 +15,7 @@ _ESP32P4_STRAPPING_PINS = {34, 35, 36, 37, 38} _LOGGER = logging.getLogger(__name__) -def esp32_p4_validate_gpio_pin(value): +def esp32_p4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") if value in _ESP32P4_USB_JTAG_PINS: @@ -27,7 +28,7 @@ def esp32_p4_validate_gpio_pin(value): return value -def esp32_p4_validate_supports(value): +def esp32_p4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_s2.py b/esphome/components/esp32/gpio_esp32_s2.py index 331aeb9d94b..4978f48a1c2 100644 --- a/esphome/components/esp32/gpio_esp32_s2.py +++ b/esphome/components/esp32/gpio_esp32_s2.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import ( @@ -26,7 +27,7 @@ _ESP32S2_STRAPPING_PINS = {0, 45, 46} _LOGGER = logging.getLogger(__name__) -def esp32_s2_validate_gpio_pin(value): +def esp32_s2_validate_gpio_pin(value: int) -> int: if value < 0 or value > 46: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-46)") @@ -43,7 +44,7 @@ def esp32_s2_validate_gpio_pin(value): return value -def esp32_s2_validate_supports(value): +def esp32_s2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index aea378f499d..cb0eb8178c3 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -1,4 +1,5 @@ import logging +from typing import Any import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER @@ -27,7 +28,7 @@ _ESP_32S3_STRAPPING_PINS = {0, 3, 45, 46} _LOGGER = logging.getLogger(__name__) -def esp32_s3_validate_gpio_pin(value): +def esp32_s3_validate_gpio_pin(value: int) -> int: if value < 0 or value > 48: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-48)") @@ -49,7 +50,7 @@ def esp32_s3_validate_gpio_pin(value): return value -def esp32_s3_validate_supports(value): +def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index 051b7ce1624..76f1c59c739 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -20,12 +20,6 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } -Mutex::Mutex() { handle_ = xSemaphoreCreateMutex(); } -Mutex::~Mutex() {} -void Mutex::lock() { xSemaphoreTake(this->handle_, portMAX_DELAY); } -bool Mutex::try_lock() { return xSemaphoreTake(this->handle_, 0) == pdTRUE; } -void Mutex::unlock() { xSemaphoreGive(this->handle_); } - // only affects the executing core // so should not be used as a mutex lock, only to get accurate timing IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 8b368afc2e2..43208eb87eb 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -604,11 +604,15 @@ async def ble_enabled_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg) -@automation.register_action("ble.enable", BLEEnableAction, cv.Schema({})) +@automation.register_action( + "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True +) async def ble_enable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) -@automation.register_action("ble.disable", BLEDisableAction, cv.Schema({})) +@automation.register_action( + "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True +) async def ble_disable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index 299fd7705fb..ba87fd88053 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -155,44 +155,38 @@ class BLEEvent { void release() { switch (this->type_) { case GAP: - // GAP events don't have heap allocations + // GAP events never have heap allocations break; case GATTC: - // Param is now stored inline, only delete heap data if it was heap-allocated if (!this->event_.gattc.is_inline && this->event_.gattc.data.heap_data != nullptr) { delete[] this->event_.gattc.data.heap_data; + this->event_.gattc.data.heap_data = nullptr; } - // Clear critical fields to prevent issues if type changes - this->event_.gattc.is_inline = false; - this->event_.gattc.data.heap_data = nullptr; break; case GATTS: - // Param is now stored inline, only delete heap data if it was heap-allocated if (!this->event_.gatts.is_inline && this->event_.gatts.data.heap_data != nullptr) { delete[] this->event_.gatts.data.heap_data; + this->event_.gatts.data.heap_data = nullptr; } - // Clear critical fields to prevent issues if type changes - this->event_.gatts.is_inline = false; - this->event_.gatts.data.heap_data = nullptr; break; } } // Load new event data for reuse (replaces previous event data) + // Note: release() is NOT called here because EventPool::release() already + // calls event->release() before returning to the free list. Every event + // from allocate() is already in a clean state. void load_gap_event(esp_gap_ble_cb_event_t e, esp_ble_gap_cb_param_t *p) { - this->release(); this->type_ = GAP; this->init_gap_data_(e, p); } void load_gattc_event(esp_gattc_cb_event_t e, esp_gatt_if_t i, esp_ble_gattc_cb_param_t *p) { - this->release(); this->type_ = GATTC; this->init_gattc_data_(e, i, p); } void load_gatts_event(esp_gatts_cb_event_t e, esp_gatt_if_t i, esp_ble_gatts_cb_param_t *p) { - this->release(); this->type_ = GATTS; this->init_gatts_data_(e, i, p); } diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index b08e791e7e3..827ddba9554 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -622,6 +622,7 @@ async def to_code(config): ), validate_set_value_action, ), + synchronous=True, ) async def ble_server_characteristic_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -641,6 +642,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a cv.Required(CONF_VALUE): value_schema(), } ), + synchronous=True, ) async def ble_server_descriptor_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -662,6 +664,7 @@ async def ble_server_descriptor_set_value(config, action_id, template_arg, args) ), validate_notify_action, ), + synchronous=True, ) async def ble_server_characteristic_notify(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 37e74672ed8..c5e8f3178d0 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -373,6 +373,7 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( "esp32_ble_tracker.start_scan", ESP32BLEStartScanAction, ESP32_BLE_START_SCAN_ACTION_SCHEMA, + synchronous=True, ) async def esp32_ble_tracker_start_scan_action_to_code( config, action_id, template_arg, args @@ -396,6 +397,7 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( "esp32_ble_tracker.stop_scan", ESP32BLEStopScanAction, ESP32_BLE_STOP_SCAN_ACTION_SCHEMA, + synchronous=True, ) async def esp32_ble_tracker_stop_scan_action_to_code( config, action_id, template_arg, args diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 73a298d279a..6dce70f8396 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -27,8 +27,14 @@ #include #endif +#ifdef USE_ESP32_BLE_DEVICE +#ifdef USE_BLE_TRACKER_PSA_AES +#include +#else #define MBEDTLS_AES_ALT #include +#endif +#endif // USE_ESP32_BLE_DEVICE // bt_trace.h #undef TAG @@ -514,6 +520,11 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { continue; // Possible zero padded advertisement data } + // Validate field fits in remaining payload + if (offset + field_length > len) { + break; + } + // first byte of adv record is adv record type const uint8_t record_type = payload[offset++]; const uint8_t *record = &payload[offset]; @@ -544,7 +555,7 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { // CSS 1.5 TX POWER LEVEL // "The TX Power Level data type indicates the transmitted power level of the packet containing the data type." // CSS 1: Optional in this context (may appear more than once in a block). - this->tx_powers_.push_back(*payload); + this->tx_powers_.push_back(*record); break; } case ESP_BLE_AD_TYPE_APPEARANCE: { @@ -733,23 +744,48 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { } bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { - uint8_t ecb_key[16]; - uint8_t ecb_plaintext[16]; - uint8_t ecb_ciphertext[16]; + static constexpr size_t AES_BLOCK_SIZE = 16; + static constexpr size_t AES_KEY_BITS = 128; + + uint8_t ecb_key[AES_BLOCK_SIZE]; + uint8_t ecb_plaintext[AES_BLOCK_SIZE]; + uint8_t ecb_ciphertext[AES_BLOCK_SIZE]; uint64_t addr64 = esp32_ble::ble_addr_to_uint64(this->address_); - memcpy(&ecb_key, irk, 16); - memset(&ecb_plaintext, 0, 16); + memcpy(&ecb_key, irk, AES_BLOCK_SIZE); + memset(&ecb_plaintext, 0, AES_BLOCK_SIZE); ecb_plaintext[13] = (addr64 >> 40) & 0xff; ecb_plaintext[14] = (addr64 >> 32) & 0xff; ecb_plaintext[15] = (addr64 >> 24) & 0xff; +#ifdef USE_BLE_TRACKER_PSA_AES + // Use PSA Crypto API (mbedtls 4.0 / IDF 6.0+) + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, AES_KEY_BITS); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_ECB_NO_PADDING); + + mbedtls_svc_key_id_t key_id; + if (psa_import_key(&attributes, ecb_key, AES_BLOCK_SIZE, &key_id) != PSA_SUCCESS) { + return false; + } + + size_t output_length; + psa_status_t status = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, ecb_plaintext, AES_BLOCK_SIZE, + ecb_ciphertext, AES_BLOCK_SIZE, &output_length); + psa_destroy_key(key_id); + if (status != PSA_SUCCESS || output_length != AES_BLOCK_SIZE) { + return false; + } +#else + // Use legacy mbedtls AES API (IDF < 6.0) mbedtls_aes_context ctx = {0, 0, {0}}; mbedtls_aes_init(&ctx); - if (mbedtls_aes_setkey_enc(&ctx, ecb_key, 128) != 0) { + if (mbedtls_aes_setkey_enc(&ctx, ecb_key, AES_KEY_BITS) != 0) { mbedtls_aes_free(&ctx); return false; } @@ -760,6 +796,7 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { } mbedtls_aes_free(&ctx); +#endif return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 7f1c2b0f7c8..f50ed107b6d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -12,6 +12,13 @@ #ifdef USE_ESP32 +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls AES API. +// Use the PSA Crypto API instead. +#define USE_BLE_TRACKER_PSA_AES +#endif + #include #include #include diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 3a5d87792b6..afab849a7c6 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -400,7 +400,7 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.1") + add_idf_component(name="espressif/esp32-camera", ref="2.1.5") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 655ae54f0a6..085feb8c8a7 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -146,6 +146,10 @@ void ESP32Camera::dump_config() { } sensor_t *s = esp_camera_sensor_get(); + if (s == nullptr) { + ESP_LOGE(TAG, " Camera sensor not available"); + return; + } auto st = s->status; ESP_LOGCONFIG(TAG, " JPEG Quality: %u\n" @@ -483,6 +487,9 @@ void ESP32Camera::request_image(camera::CameraRequester requester) { this->singl camera::CameraImageReader *ESP32Camera::create_image_reader() { return new ESP32CameraImageReader; } void ESP32Camera::update_camera_parameters() { sensor_t *s = esp_camera_sensor_get(); + if (s == nullptr) { + return; + } /* update image */ s->set_vflip(s, this->vertical_flip_); s->set_hmirror(s, this->horizontal_mirror_); diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 720d81acc4a..3f9185745dd 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -4,14 +4,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 import esphome.config_validation as cv -from esphome.const import ( - CONF_CLK_PIN, - CONF_RESET_PIN, - CONF_VARIANT, - KEY_CORE, - KEY_FRAMEWORK_VERSION, -) -from esphome.core import CORE +from esphome.const import CONF_CLK_PIN, CONF_RESET_PIN, CONF_VARIANT from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] @@ -100,12 +93,12 @@ async def to_code(config): int(config[CONF_SDIO_FREQUENCY] // 1000), ) - framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" - if framework_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.3.2") + idf_ver = esp32.idf_version() + os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" + if idf_ver >= cv.Version(5, 5, 0): + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.11.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 66b41931aac..ca97a181fd4 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -99,8 +99,6 @@ void ESP32RMTLEDStripLightOutput::setup() { channel.gpio_num = gpio_num_t(this->pin_); channel.mem_block_symbols = this->rmt_symbols_; channel.trans_queue_depth = 1; - channel.flags.io_loop_back = 0; - channel.flags.io_od_mode = 0; channel.flags.invert_out = this->invert_out_; channel.flags.with_dma = this->use_dma_; channel.intr_priority = 0; diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 927a59fd616..1ef4f5e037e 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -23,6 +23,7 @@ from esphome.helpers import copy_file_if_changed from .boards import BOARDS, ESP8266_LD_SCRIPTS from .const import ( CONF_EARLY_PIN_INIT, + CONF_ENABLE_FULL_PRINTF, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, @@ -179,6 +180,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_ENABLE_SERIAL): cv.boolean, cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, + cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, } ), set_core_data, @@ -260,6 +262,14 @@ async def to_code(config): if CORE.testing_mode: cg.add_build_flag("-DESPHOME_TESTING_MODE") + # Wrap FILE*-based printf functions to eliminate newlib's _vfiprintf_r + # (~1.6 KB). See printf_stubs.cpp for implementation. + if config.get(CONF_ENABLE_FULL_PRINTF): + cg.add_define("USE_FULL_PRINTF") + else: + for symbol in ("vprintf", "printf", "fprintf"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 229ac61f245..57eb54f0f80 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -6,6 +6,7 @@ KEY_BOARD = "board" KEY_PIN_INITIAL_STATES = "pin_initial_states" CONF_RESTORE_FROM_FLASH = "restore_from_flash" CONF_EARLY_PIN_INIT = "early_pin_init" +CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_FLASH_SIZE = "flash_size" diff --git a/esphome/components/esp8266/helpers.cpp b/esphome/components/esp8266/helpers.cpp index 036594fa178..aadfc311970 100644 --- a/esphome/components/esp8266/helpers.cpp +++ b/esphome/components/esp8266/helpers.cpp @@ -12,19 +12,13 @@ namespace esphome { uint32_t random_uint32() { return os_random(); } bool random_bytes(uint8_t *data, size_t len) { return os_get_random(data, len) == 0; } -// ESP8266 doesn't have mutexes, but that shouldn't be an issue as it's single-core and non-preemptive OS. -Mutex::Mutex() {} -Mutex::~Mutex() {} -void Mutex::lock() {} -bool Mutex::try_lock() { return true; } -void Mutex::unlock() {} +// ESP8266 Mutex is defined inline as a no-op in helpers.h when USE_ESP8266 (or USE_RP2040) is set, +// independent of the ESPHOME_THREAD_SINGLE thread model define. IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); } -// ESP8266 doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// ESP8266 LwIPLock is defined inline as a no-op in helpers.h void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) wifi_get_macaddr(STATION_IF, mac); diff --git a/esphome/components/esp8266/printf_stubs.cpp b/esphome/components/esp8266/printf_stubs.cpp new file mode 100644 index 00000000000..e6d4a748664 --- /dev/null +++ b/esphome/components/esp8266/printf_stubs.cpp @@ -0,0 +1,71 @@ +/* + * Linker wrap stubs for FILE*-based printf functions. + * + * The ESP8266 Arduino framework and libraries may reference printf(), + * vprintf(), and fprintf() which pull in newlib's _vfprintf_r (~900 bytes). + * ESPHome never uses these — all logging writes directly to the UART via + * Arduino's Serial, so the libc FILE*-based printf path is dead code. + * + * These stubs redirect through vsnprintf() (which is already in the binary + * for ESPHome's logging) and fwrite(), allowing the linker to dead-code + * eliminate _vfprintf_r. + * + * Saves ~1.6 KB of flash. + */ + +#if defined(USE_ESP8266) && !defined(USE_FULL_PRINTF) +#include +#include +#include + +namespace esphome::esp8266 {} + +static constexpr size_t PRINTF_BUFFER_SIZE = 512; + +// These stubs are essentially dead code at runtime — ESPHome writes directly +// to the UART via Arduino's Serial, and Serial.printf() has its own implementation. +// The buffer overflow check is purely defensive and should never trigger. +static int write_printf_buffer(FILE *stream, char *buf, int len) { + if (len < 0) { + return len; + } + size_t write_len = len; + if (write_len >= PRINTF_BUFFER_SIZE) { + fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); + abort(); + } + if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { + return -1; + } + return len; +} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vprintf(const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + +int __wrap_printf(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + int len = __wrap_vprintf(fmt, ap); + va_end(ap); + return len; +} + +int __wrap_fprintf(FILE *stream, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char buf[PRINTF_BUFFER_SIZE]; + int len = write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); + va_end(ap); + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP8266 && !USE_FULL_PRINTF diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index a78831c516c..b9b6dcc95a3 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -57,6 +57,7 @@ async def to_code(config) -> None: cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), + synchronous=True, ) async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index 5235a9411ee..a489651b598 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -129,6 +129,7 @@ def adjusted_ldo_id(value): ), } ), + synchronous=True, ) async def ldo_voltage_adjust_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index a1cdf59d2b7..972d2b2b8d5 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -18,6 +18,7 @@ #include #include +#include namespace esphome { @@ -238,6 +239,31 @@ void ESPHomeOTAComponent::handle_data_() { /// and reboots on success. /// /// Authentication has already been handled in the non-blocking states AUTH_SEND/AUTH_READ. + /// + /// Socket I/O strategy: + /// + /// Before this function, the handshake states use non-blocking I/O: + /// read()/write() return immediately with EWOULDBLOCK if no data + /// loop() retries on next iteration (~16ms), no delay needed + /// + /// This function switches to blocking mode with SO_RCVTIMEO/SO_SNDTIMEO: + /// + /// Path | Wait mechanism | WDT strategy + /// --------------|------------------------|--------------------------- + /// Main read | SO_RCVTIMEO (2s block) | feed_wdt() only, no delay + /// readall_() | SO_RCVTIMEO (2s block) | feed_wdt() + delay(0) + /// writeall_() | SO_SNDTIMEO (2s block) | feed_wdt() + delay(1) + /// + /// readall_() uses delay(0) because SO_RCVTIMEO already waited — just yield. + /// writeall_() uses delay(1) because on raw TCP (ESP8266, RP2040) writes + /// never block (tcp_write returns immediately), so delay(1) prevents spinning. + /// + /// Platform details: + /// BSD sockets (ESP32): setblocking(true) makes read/write block + /// lwip sockets (LT): setblocking(true) makes read/write block + /// Raw TCP (8266, RP2040): setblocking is no-op; SO_RCVTIMEO uses + /// socket_delay()/socket_wake() in read(); + /// write() always returns immediately ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; bool update_started = false; size_t total = 0; @@ -249,6 +275,14 @@ void ESPHomeOTAComponent::handle_data_() { size_t size_acknowledged = 0; #endif + // Set socket timeouts and blocking mode (see strategy table above) + struct timeval tv; + tv.tv_sec = 2; + tv.tv_usec = 0; + this->client_->setsockopt(SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + this->client_->setsockopt(SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + this->client_->setblocking(true); + // Acknowledge auth OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); @@ -298,11 +332,13 @@ void ESPHomeOTAComponent::handle_data_() { size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); if (read == -1) { - if (this->would_block_(errno)) { - this->yield_and_feed_watchdog_(); + const int err = errno; + if (this->would_block_(err)) { + // read() already waited up to SO_RCVTIMEO for data, just feed WDT + App.feed_wdt(); continue; } - ESP_LOGW(TAG, "Read err %d", errno); + ESP_LOGW(TAG, "Read err %d", err); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } else if (read == 0) { ESP_LOGW(TAG, "Remote closed"); @@ -391,8 +427,9 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { ssize_t read = this->client_->read(buf + at, len - at); if (read == -1) { - if (!this->would_block_(errno)) { - ESP_LOGW(TAG, "Read err %zu bytes, errno %d", len, errno); + const int err = errno; + if (!this->would_block_(err)) { + ESP_LOGW(TAG, "Read err %zu bytes, errno %d", len, err); return false; } } else if (read == 0) { @@ -401,7 +438,9 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { } else { at += read; } - this->yield_and_feed_watchdog_(); + // read() already waited via SO_RCVTIMEO, just yield without 1ms stall + App.feed_wdt(); + delay(0); } return true; @@ -418,14 +457,18 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { ssize_t written = this->client_->write(buf + at, len - at); if (written == -1) { - if (!this->would_block_(errno)) { - ESP_LOGW(TAG, "Write err %zu bytes, errno %d", len, errno); + const int err = errno; + if (!this->would_block_(err)) { + ESP_LOGW(TAG, "Write err %zu bytes, errno %d", len, err); return false; } + // EWOULDBLOCK: on raw TCP writes never block, delay(1) prevents spinning + this->yield_and_feed_watchdog_(); } else { at += written; + // write() may block up to SO_SNDTIMEO on BSD/lwip sockets, feed WDT + App.feed_wdt(); } - this->yield_and_feed_watchdog_(); } return true; } diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index faeccd910e0..d1a85ae8fd8 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -220,6 +220,7 @@ SEND_SCHEMA.add_extra(_validate_send_action) "espnow.send", SendAction, SEND_SCHEMA, + synchronous=False, ) @automation.register_action( "espnow.broadcast", @@ -232,6 +233,7 @@ SEND_SCHEMA.add_extra(_validate_send_action) ), key=CONF_DATA, ), + synchronous=False, ) async def send_action( config: ConfigType, @@ -271,6 +273,7 @@ async def send_action( PEER_SCHEMA, key=CONF_ADDRESS, ), + synchronous=True, ) @automation.register_action( "espnow.peer.delete", @@ -279,6 +282,7 @@ async def send_action( PEER_SCHEMA, key=CONF_ADDRESS, ), + synchronous=True, ) async def peer_action( config: ConfigType, @@ -303,6 +307,7 @@ async def peer_action( }, key=CONF_CHANNEL, ), + synchronous=True, ) async def channel_action( config: ConfigType, diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 935d2004d49..0504ab7a1f0 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,22 +2,8 @@ import logging from esphome import automation, pins import esphome.codegen as cg -from esphome.components.esp32 import ( - VARIANT_ESP32, - VARIANT_ESP32C3, - VARIANT_ESP32C5, - VARIANT_ESP32C6, - VARIANT_ESP32C61, - VARIANT_ESP32P4, - VARIANT_ESP32S2, - VARIANT_ESP32S3, - add_idf_component, - add_idf_sdkconfig_option, - get_esp32_variant, - include_builtin_idf_component, -) from esphome.components.network import ip_address_literal -from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -49,6 +35,8 @@ from esphome.const import ( CONF_VALUE, KEY_CORE, KEY_FRAMEWORK_VERSION, + Platform, + PlatformFramework, ) from esphome.core import ( CORE, @@ -60,7 +48,6 @@ import esphome.final_validate as fv from esphome.types import ConfigType CONFLICTS_WITH = ["wifi"] -DEPENDENCIES = ["esp32"] AUTO_LOAD = ["network"] LOGGER = logging.getLogger(__name__) @@ -143,6 +130,8 @@ _PHY_TYPE_TO_DEFINE = { } SPI_ETHERNET_TYPES = ["W5500", "DM9051"] +# RP2040-supported SPI ethernet types +RP2040_SPI_ETHERNET_TYPES = ["W5500"] SPI_ETHERNET_DEFAULT_POLLING_INTERVAL = TimePeriodMilliseconds(milliseconds=10) emac_rmii_clock_mode_t = cg.global_ns.enum("emac_rmii_clock_mode_t") @@ -173,16 +162,22 @@ EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") -def _is_framework_spi_polling_mode_supported(): - # SPI Ethernet without IRQ feature is added in - # esp-idf >= (5.3+ ,5.2.1+, 5.1.4) - # Note: Arduino now uses ESP-IDF as a component, so we only check IDF version - framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if framework_version >= cv.Version(5, 3, 0): +def _is_framework_spi_polling_mode_supported() -> bool: + """Check if ESP-IDF framework supports SPI polling mode (ESP32 only). + + SPI Ethernet without IRQ feature is added in + esp-idf >= (5.3+, 5.2.1+, 5.1.4) + """ + if not CORE.is_esp32: + return False + from esphome.components.esp32 import idf_version + + ver = idf_version() + if ver >= cv.Version(5, 3, 0): return True - if cv.Version(5, 3, 0) > framework_version >= cv.Version(5, 2, 1): + if cv.Version(5, 3, 0) > ver >= cv.Version(5, 2, 1): return True - if cv.Version(5, 2, 0) > framework_version >= cv.Version(5, 1, 4): # noqa: SIM103 + if cv.Version(5, 2, 0) > ver >= cv.Version(5, 1, 4): # noqa: SIM103 return True return False @@ -195,52 +190,68 @@ def _validate(config): use_address = CORE.name + config[CONF_DOMAIN] config[CONF_USE_ADDRESS] = use_address - if config[CONF_TYPE] in SPI_ETHERNET_TYPES: - if _is_framework_spi_polling_mode_supported(): - if CONF_POLLING_INTERVAL in config and CONF_INTERRUPT_PIN in config: - raise cv.Invalid( - f"Cannot specify more than one of {CONF_INTERRUPT_PIN}, {CONF_POLLING_INTERVAL}" - ) - if CONF_POLLING_INTERVAL not in config and CONF_INTERRUPT_PIN not in config: - config[CONF_POLLING_INTERVAL] = SPI_ETHERNET_DEFAULT_POLLING_INTERVAL - else: - if CONF_POLLING_INTERVAL in config: - raise cv.Invalid( - "In this version of the framework " - f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " - f"'{CONF_POLLING_INTERVAL}' is not supported." - ) - if CONF_INTERRUPT_PIN not in config: - raise cv.Invalid( - "In this version of the framework " - f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " - f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." - ) - elif config[CONF_TYPE] != "OPENETH": - if CONF_CLK_MODE in config: - mode, pin = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] - LOGGER.warning( - "[ethernet] The 'clk_mode' option is deprecated. " - "Please replace 'clk_mode: %s' with:\n" - " clk:\n" - " mode: %s\n" - " pin: %s\n" - "Removal scheduled for 2026.7.0.", - config[CONF_CLK_MODE], - mode, - pin, - ) - config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode, CONF_PIN: pin}) - del config[CONF_CLK_MODE] - elif CONF_CLK not in config: - raise cv.Invalid("'clk' is a required option for [ethernet].") - variant = get_esp32_variant() - if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): - raise cv.Invalid( - f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " - f"on ESP32 classic and ESP32-P4, not {variant}" + if CORE.is_esp32: + if config[CONF_TYPE] in SPI_ETHERNET_TYPES: + if _is_framework_spi_polling_mode_supported(): + if CONF_POLLING_INTERVAL in config and CONF_INTERRUPT_PIN in config: + raise cv.Invalid( + f"Cannot specify more than one of {CONF_INTERRUPT_PIN}, {CONF_POLLING_INTERVAL}" + ) + if ( + CONF_POLLING_INTERVAL not in config + and CONF_INTERRUPT_PIN not in config + ): + config[CONF_POLLING_INTERVAL] = ( + SPI_ETHERNET_DEFAULT_POLLING_INTERVAL + ) + else: + if CONF_POLLING_INTERVAL in config: + raise cv.Invalid( + "In this version of the framework " + f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " + f"'{CONF_POLLING_INTERVAL}' is not supported." + ) + if CONF_INTERRUPT_PIN not in config: + raise cv.Invalid( + "In this version of the framework " + f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " + f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." + ) + elif config[CONF_TYPE] != "OPENETH": + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32P4, + get_esp32_variant, ) + if CONF_CLK_MODE in config: + mode, pin = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] + LOGGER.warning( + "[ethernet] The 'clk_mode' option is deprecated. " + "Please replace 'clk_mode: %s' with:\n" + " clk:\n" + " mode: %s\n" + " pin: %s\n" + "Removal scheduled for 2026.7.0.", + config[CONF_CLK_MODE], + mode, + pin, + ) + config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode, CONF_PIN: pin}) + del config[CONF_CLK_MODE] + elif CONF_CLK not in config: + raise cv.Invalid("'clk' is a required option for [ethernet].") + variant = get_esp32_variant() + if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): + raise cv.Invalid( + f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " + f"on ESP32 classic and ESP32-P4, not {variant}" + ) + elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_SPI_ETHERNET_TYPES: + raise cv.Invalid( + f"Only {', '.join(RP2040_SPI_ETHERNET_TYPES)} are supported on RP2040, " + f"not {config[CONF_TYPE]}" + ) return config @@ -269,41 +280,47 @@ CLK_SCHEMA = cv.Schema( cv.Required(CONF_PIN): pins.internal_gpio_pin_number, } ) -RMII_SCHEMA = BASE_SCHEMA.extend( - cv.Schema( - { - cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLK_MODE): cv.enum( - CLK_MODES_DEPRECATED, upper=True, space="_" - ), - cv.Optional(CONF_CLK): CLK_SCHEMA, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), - cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), - } - ) +RMII_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_CLK_MODE): cv.enum( + CLK_MODES_DEPRECATED, upper=True, space="_" + ), + cv.Optional(CONF_CLK): CLK_SCHEMA, + cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), + } + ) + ), + cv.only_on([Platform.ESP32]), ) -SPI_SCHEMA = BASE_SCHEMA.extend( - cv.Schema( - { - cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, - cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, - cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLOCK_SPEED, default="26.67MHz"): cv.All( - cv.frequency, cv.int_range(int(8e6), int(80e6)) - ), - # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() - cv.Optional(CONF_POLLING_INTERVAL): cv.All( - cv.positive_time_period_milliseconds, - cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), - ), - } +SPI_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, + cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_CLOCK_SPEED, default="26.67MHz"): cv.All( + cv.frequency, cv.int_range(int(8e6), int(80e6)) + ), + # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), + ), + } + ), ), + cv.only_on([Platform.ESP32, Platform.RP2040]), ) CONFIG_SCHEMA = cv.All( @@ -317,7 +334,7 @@ CONFIG_SCHEMA = cv.All( "KSZ8081": RMII_SCHEMA, "KSZ8081RNA": RMII_SCHEMA, "W5500": SPI_SCHEMA, - "OPENETH": BASE_SCHEMA, + "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "LAN8670": RMII_SCHEMA, }, @@ -328,8 +345,21 @@ CONFIG_SCHEMA = cv.All( def _final_validate_spi(config): + if not CORE.is_esp32: + return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: return + from esphome.components.esp32 import ( + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + get_esp32_variant, + ) + from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface + if spi_configs := fv.full_config.get().get(CONF_SPI): variant = get_esp32_variant() if variant in ( @@ -378,6 +408,49 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if CORE.is_esp32: + await _to_code_esp32(var, config) + elif CORE.is_rp2040: + await _to_code_rp2040(var, config) + + cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) + cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + + if CONF_MANUAL_IP in config: + cg.add_define("USE_ETHERNET_MANUAL_IP") + cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) + + # Add compile-time define for PHY types with specific code + if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): + cg.add_define(phy_define) + + if mac_address := config.get(CONF_MAC_ADDRESS): + cg.add(var.set_fixed_mac(mac_address.parts)) + + cg.add_define("USE_ETHERNET") + + if on_connect_config := config.get(CONF_ON_CONNECT): + cg.add_define("USE_ETHERNET_CONNECT_TRIGGER") + await automation.build_automation( + var.get_connect_trigger(), [], on_connect_config + ) + + if on_disconnect_config := config.get(CONF_ON_DISCONNECT): + cg.add_define("USE_ETHERNET_DISCONNECT_TRIGGER") + await automation.build_automation( + var.get_disconnect_trigger(), [], on_disconnect_config + ) + + CORE.add_job(final_step) + + +async def _to_code_esp32(var, config): + from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + include_builtin_idf_component, + ) + if config[CONF_TYPE] in SPI_ETHERNET_TYPES: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) @@ -415,22 +488,6 @@ async def to_code(config): ) cg.add(var.add_phy_register(reg)) - cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) - - if CONF_MANUAL_IP in config: - cg.add_define("USE_ETHERNET_MANUAL_IP") - cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) - - # Add compile-time define for PHY types with specific code - if phy_define := _PHY_TYPE_TO_DEFINE.get(config[CONF_TYPE]): - cg.add_define(phy_define) - - if mac_address := config.get(CONF_MAC_ADDRESS): - cg.add(var.set_fixed_mac(mac_address.parts)) - - cg.add_define("USE_ETHERNET") - # Disable WiFi when using Ethernet to save memory add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) # Also disable WiFi/BT coexistence since WiFi is disabled @@ -443,27 +500,35 @@ async def to_code(config): # Add LAN867x 10BASE-T1S PHY support component add_idf_component(name="espressif/lan867x", ref="2.0.0") - if on_connect_config := config.get(CONF_ON_CONNECT): - cg.add_define("USE_ETHERNET_CONNECT_TRIGGER") - await automation.build_automation( - var.get_connect_trigger(), [], on_connect_config - ) - if on_disconnect_config := config.get(CONF_ON_DISCONNECT): - cg.add_define("USE_ETHERNET_DISCONNECT_TRIGGER") - await automation.build_automation( - var.get_disconnect_trigger(), [], on_disconnect_config - ) +async def _to_code_rp2040(var, config): + cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) + cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) + cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) + cg.add(var.set_cs_pin(config[CONF_CS_PIN])) + if CONF_INTERRUPT_PIN in config: + cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) + if CONF_RESET_PIN in config: + cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) - CORE.add_job(final_step) + cg.add_define("USE_ETHERNET_SPI") + cg.add_library("lwIP_w5500", None) def _final_validate_rmii_pins(config: ConfigType) -> None: """Validate that RMII pins are not used by other components.""" + if not CORE.is_esp32: + return # RMII validation is ESP32-only # Only validate for RMII-based PHYs on ESP32/ESP32P4 if config[CONF_TYPE] in SPI_ETHERNET_TYPES or config[CONF_TYPE] == "OPENETH": return # SPI and OPENETH don't use RMII + from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32P4, + get_esp32_variant, + ) + variant = get_esp32_variant() if variant == VARIANT_ESP32: rmii_pins = ESP32_RMII_FIXED_PINS @@ -521,3 +586,14 @@ async def final_step(): if ip_state_count := CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0): cg.add_define("USE_ETHERNET_IP_STATE_LISTENERS") cg.add_define("ESPHOME_ETHERNET_IP_STATE_LISTENERS", ip_state_count) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "ethernet_component_esp32.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, + "ethernet_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + } +) diff --git a/esphome/components/ethernet/esp_eth_phy_jl1101.c b/esphome/components/ethernet/esp_eth_phy_jl1101.c index a19f7aa6b00..b81d8227d45 100644 --- a/esphome/components/ethernet/esp_eth_phy_jl1101.c +++ b/esphome/components/ethernet/esp_eth_phy_jl1101.c @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include "esphome/core/defines.h" + #ifdef USE_ESP32 #include diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index d6b0d40cd95..4421a1c7aaf 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -1,563 +1,28 @@ #include "ethernet_component.h" -#include "esphome/core/application.h" -#include "esphome/core/helpers.h" + +#ifdef USE_ETHERNET + #include "esphome/core/log.h" -#include "esphome/core/util.h" - -#ifdef USE_ESP32 - -#include -#include -#include "esp_event.h" - -#ifdef USE_ETHERNET_LAN8670 -#include "esp_eth_phy_lan867x.h" -#endif - -#ifdef USE_ETHERNET_SPI -#include -#include -#endif namespace esphome::ethernet { -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) -// work around IDF compile issue on P4 https://github.com/espressif/esp-idf/pull/15637 -#ifdef USE_ESP32_VARIANT_ESP32P4 -#undef ETH_ESP32_EMAC_DEFAULT_CONFIG -#define ETH_ESP32_EMAC_DEFAULT_CONFIG() \ - { \ - .smi_gpio = {.mdc_num = 31, .mdio_num = 52}, .interface = EMAC_DATA_INTERFACE_RMII, \ - .clock_config = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) 50}}, \ - .dma_burst_len = ETH_DMA_BURST_LEN_32, .intr_priority = 0, \ - .emac_dataif_gpio = \ - {.rmii = {.tx_en_num = 49, .txd0_num = 34, .txd1_num = 35, .crs_dv_num = 28, .rxd0_num = 29, .rxd1_num = 30}}, \ - .clock_config_out_in = {.rmii = {.clock_mode = EMAC_CLK_EXT_IN, .clock_gpio = (emac_rmii_clock_gpio_t) -1}}, \ - } -#endif -#endif - -static const char *const TAG = "ethernet"; - -// PHY register size for hex logging -static constexpr size_t PHY_REG_SIZE = 2; - EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { - ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); - this->mark_failed(); -} - -#define ESPHL_ERROR_CHECK(err, message) \ - if ((err) != ESP_OK) { \ - this->log_error_and_mark_failed_(err, message); \ - return; \ - } - -#define ESPHL_ERROR_CHECK_RET(err, message, ret) \ - if ((err) != ESP_OK) { \ - this->log_error_and_mark_failed_(err, message); \ - return ret; \ - } - EthernetComponent::EthernetComponent() { global_eth_component = this; } -void EthernetComponent::setup() { - if (esp_reset_reason() != ESP_RST_DEEPSLEEP) { - // Delay here to allow power to stabilise before Ethernet is initialized. - delay(300); // NOLINT - } - - esp_err_t err; - -#ifdef USE_ETHERNET_SPI - // Install GPIO ISR handler to be able to service SPI Eth modules interrupts - gpio_install_isr_service(0); - - spi_bus_config_t buscfg = { - .mosi_io_num = this->mosi_pin_, - .miso_io_num = this->miso_pin_, - .sclk_io_num = this->clk_pin_, - .quadwp_io_num = -1, - .quadhd_io_num = -1, - .data4_io_num = -1, - .data5_io_num = -1, - .data6_io_num = -1, - .data7_io_num = -1, - .max_transfer_sz = 0, - .flags = 0, - .intr_flags = 0, - }; - -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) - auto host = SPI2_HOST; -#else - auto host = SPI3_HOST; -#endif - - err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); - ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); -#endif - - err = esp_netif_init(); - ESPHL_ERROR_CHECK(err, "ETH netif init error"); - err = esp_event_loop_create_default(); - ESPHL_ERROR_CHECK(err, "ETH event loop error"); - - esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); - this->eth_netif_ = esp_netif_new(&cfg); - - // Init MAC and PHY configs to default - eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); - eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); - -#ifdef USE_ETHERNET_SPI // Configure SPI interface and Ethernet driver for specific SPI module - spi_device_interface_config_t devcfg = { - .command_bits = 0, - .address_bits = 0, - .dummy_bits = 0, - .mode = 0, - .duty_cycle_pos = 0, - .cs_ena_pretrans = 0, - .cs_ena_posttrans = 0, - .clock_speed_hz = this->clock_speed_, - .input_delay_ns = 0, - .spics_io_num = this->cs_pin_, - .flags = 0, - .queue_size = 20, - .pre_cb = nullptr, - .post_cb = nullptr, - }; - -#if CONFIG_ETH_SPI_ETHERNET_W5500 - eth_w5500_config_t w5500_config = ETH_W5500_DEFAULT_CONFIG(host, &devcfg); -#endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); -#endif - -#if CONFIG_ETH_SPI_ETHERNET_W5500 - w5500_config.int_gpio_num = this->interrupt_pin_; -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - w5500_config.poll_period_ms = this->polling_interval_; -#endif -#endif - -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - dm9051_config.int_gpio_num = this->interrupt_pin_; -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - dm9051_config.poll_period_ms = this->polling_interval_; -#endif -#endif - - phy_config.phy_addr = this->phy_addr_spi_; - phy_config.reset_gpio_num = this->reset_pin_; - - esp_eth_mac_t *mac = nullptr; -#elif defined(USE_ETHERNET_OPENETH) - esp_eth_mac_t *mac = esp_eth_mac_new_openeth(&mac_config); -#else - phy_config.phy_addr = this->phy_addr_; - phy_config.reset_gpio_num = this->power_pin_; - - eth_esp32_emac_config_t esp32_emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG(); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) - esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; - esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; -#else - esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; - esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; -#endif - esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = (emac_rmii_clock_gpio_t) this->clk_pin_; - - esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); -#endif - - switch (this->type_) { -#ifdef USE_ETHERNET_OPENETH - case ETHERNET_TYPE_OPENETH: { - phy_config.autonego_timeout_ms = 1000; - this->phy_ = esp_eth_phy_new_dp83848(&phy_config); - break; - } -#endif -#if CONFIG_ETH_USE_ESP32_EMAC -#ifdef USE_ETHERNET_LAN8720 - case ETHERNET_TYPE_LAN8720: { - this->phy_ = esp_eth_phy_new_lan87xx(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_RTL8201 - case ETHERNET_TYPE_RTL8201: { - this->phy_ = esp_eth_phy_new_rtl8201(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_DP83848 - case ETHERNET_TYPE_DP83848: { - this->phy_ = esp_eth_phy_new_dp83848(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_IP101 - case ETHERNET_TYPE_IP101: { - this->phy_ = esp_eth_phy_new_ip101(&phy_config); - break; - } -#endif -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) - case ETHERNET_TYPE_JL1101: { - this->phy_ = esp_eth_phy_new_jl1101(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_KSZ8081 - case ETHERNET_TYPE_KSZ8081: - case ETHERNET_TYPE_KSZ8081RNA: { - this->phy_ = esp_eth_phy_new_ksz80xx(&phy_config); - break; - } -#endif -#ifdef USE_ETHERNET_LAN8670 - case ETHERNET_TYPE_LAN8670: { - this->phy_ = esp_eth_phy_new_lan867x(&phy_config); - break; - } -#endif -#endif -#ifdef USE_ETHERNET_SPI -#if CONFIG_ETH_SPI_ETHERNET_W5500 - case ETHERNET_TYPE_W5500: { - mac = esp_eth_mac_new_w5500(&w5500_config, &mac_config); - this->phy_ = esp_eth_phy_new_w5500(&phy_config); - break; - } -#endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - case ETHERNET_TYPE_DM9051: { - mac = esp_eth_mac_new_dm9051(&dm9051_config, &mac_config); - this->phy_ = esp_eth_phy_new_dm9051(&phy_config); - break; - } -#endif -#endif - default: { - this->mark_failed(); - return; - } - } - - esp_eth_config_t eth_config = ETH_DEFAULT_CONFIG(mac, this->phy_); - this->eth_handle_ = nullptr; - err = esp_eth_driver_install(ð_config, &this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH driver install error"); - -#ifndef USE_ETHERNET_SPI -#ifdef USE_ETHERNET_KSZ8081 - if (this->type_ == ETHERNET_TYPE_KSZ8081RNA && this->clk_mode_ == EMAC_CLK_OUT) { - // KSZ8081RNA default is incorrect. It expects a 25MHz clock instead of the 50MHz we provide. - this->ksz8081_set_clock_reference_(mac); - } -#endif // USE_ETHERNET_KSZ8081 - - for (const auto &phy_register : this->phy_registers_) { - this->write_phy_register_(mac, phy_register); - } -#endif - - // use ESP internal eth mac - uint8_t mac_addr[6]; - if (this->fixed_mac_.has_value()) { - memcpy(mac_addr, this->fixed_mac_->data(), 6); - } else { - esp_read_mac(mac_addr, ESP_MAC_ETH); - } - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_MAC_ADDR, mac_addr); - ESPHL_ERROR_CHECK(err, "set mac address error"); - - /* attach Ethernet driver to TCP/IP stack */ - err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); - ESPHL_ERROR_CHECK(err, "ETH netif attach error"); - - // Register user defined event handers - err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); - ESPHL_ERROR_CHECK(err, "ETH event handler register error"); - err = esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, &EthernetComponent::got_ip_event_handler, nullptr); - ESPHL_ERROR_CHECK(err, "GOT IP event handler register error"); -#if USE_NETWORK_IPV6 - err = esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, &EthernetComponent::got_ip6_event_handler, nullptr); - ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); -#endif /* USE_NETWORK_IPV6 */ - - /* start Ethernet driver state machine */ - err = esp_eth_start(this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH start error"); -} - -void EthernetComponent::loop() { - const uint32_t now = App.get_loop_component_start_time(); - - switch (this->state_) { - case EthernetComponentState::STOPPED: - if (this->started_) { - ESP_LOGI(TAG, "Starting connection"); - this->state_ = EthernetComponentState::CONNECTING; - this->start_connect_(); - } - break; - case EthernetComponentState::CONNECTING: - if (!this->started_) { - ESP_LOGI(TAG, "Stopped connection"); - this->state_ = EthernetComponentState::STOPPED; - } else if (this->connected_) { - // connection established - ESP_LOGI(TAG, "Connected"); - this->state_ = EthernetComponentState::CONNECTED; - - this->dump_connect_params_(); - this->status_clear_warning(); -#ifdef USE_ETHERNET_CONNECT_TRIGGER - this->connect_trigger_.trigger(); -#endif - } else if (now - this->connect_begin_ > 15000) { - ESP_LOGW(TAG, "Connecting failed; reconnecting"); - this->start_connect_(); - } - break; - case EthernetComponentState::CONNECTED: - if (!this->started_) { - ESP_LOGI(TAG, "Stopped connection"); - this->state_ = EthernetComponentState::STOPPED; -#ifdef USE_ETHERNET_DISCONNECT_TRIGGER - this->disconnect_trigger_.trigger(); -#endif - } else if (!this->connected_) { - ESP_LOGW(TAG, "Connection lost; reconnecting"); - this->state_ = EthernetComponentState::CONNECTING; - this->start_connect_(); -#ifdef USE_ETHERNET_DISCONNECT_TRIGGER - this->disconnect_trigger_.trigger(); -#endif - } else { - this->finish_connect_(); - // When connected and stable, disable the loop to save CPU cycles - this->disable_loop(); - } - break; - } -} - -void EthernetComponent::dump_config() { - const char *eth_type; - switch (this->type_) { -#ifdef USE_ETHERNET_LAN8720 - case ETHERNET_TYPE_LAN8720: - eth_type = "LAN8720"; - break; -#endif -#ifdef USE_ETHERNET_RTL8201 - case ETHERNET_TYPE_RTL8201: - eth_type = "RTL8201"; - break; -#endif -#ifdef USE_ETHERNET_DP83848 - case ETHERNET_TYPE_DP83848: - eth_type = "DP83848"; - break; -#endif -#ifdef USE_ETHERNET_IP101 - case ETHERNET_TYPE_IP101: - eth_type = "IP101"; - break; -#endif -#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) - case ETHERNET_TYPE_JL1101: - eth_type = "JL1101"; - break; -#endif -#ifdef USE_ETHERNET_KSZ8081 - case ETHERNET_TYPE_KSZ8081: - eth_type = "KSZ8081"; - break; - - case ETHERNET_TYPE_KSZ8081RNA: - eth_type = "KSZ8081RNA"; - break; -#endif -#if CONFIG_ETH_SPI_ETHERNET_W5500 - case ETHERNET_TYPE_W5500: - eth_type = "W5500"; - break; -#endif -#if CONFIG_ETH_SPI_ETHERNET_DM9051 - case ETHERNET_TYPE_DM9051: - eth_type = "DM9051"; - break; -#endif -#ifdef USE_ETHERNET_OPENETH - case ETHERNET_TYPE_OPENETH: - eth_type = "OPENETH"; - break; -#endif -#ifdef USE_ETHERNET_LAN8670 - case ETHERNET_TYPE_LAN8670: - eth_type = "LAN8670"; - break; -#endif - - default: - eth_type = "Unknown"; - break; - } - - ESP_LOGCONFIG(TAG, - "Ethernet:\n" - " Connected: %s", - YESNO(this->is_connected())); - this->dump_connect_params_(); -#ifdef USE_ETHERNET_SPI - ESP_LOGCONFIG(TAG, - " CLK Pin: %u\n" - " MISO Pin: %u\n" - " MOSI Pin: %u\n" - " CS Pin: %u", - this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - if (this->polling_interval_ != 0) { - ESP_LOGCONFIG(TAG, " Polling Interval: %lu ms", this->polling_interval_); - } else -#endif - { - ESP_LOGCONFIG(TAG, " IRQ Pin: %d", this->interrupt_pin_); - } - ESP_LOGCONFIG(TAG, - " Reset Pin: %d\n" - " Clock Speed: %d MHz", - this->reset_pin_, this->clock_speed_ / 1000000); -#else - if (this->power_pin_ != -1) { - ESP_LOGCONFIG(TAG, " Power Pin: %u", this->power_pin_); - } - ESP_LOGCONFIG(TAG, - " CLK Pin: %u\n" - " MDC Pin: %u\n" - " MDIO Pin: %u\n" - " PHY addr: %u", - this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); -#endif - ESP_LOGCONFIG(TAG, " Type: %s", eth_type); -} - float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; } -network::IPAddresses EthernetComponent::get_ip_addresses() { - network::IPAddresses addresses; - esp_netif_ip_info_t ip; - esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); - if (err != ESP_OK) { - ESP_LOGV(TAG, "esp_netif_get_ip_info failed: %s", esp_err_to_name(err)); - // TODO: do something smarter - // return false; - } else { - addresses[0] = network::IPAddress(&ip.ip); - } -#if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - assert(count < addresses.size()); - for (int i = 0; i < count; i++) { - addresses[i + 1] = network::IPAddress(&if_ip6s[i]); - } -#endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } - return addresses; -} - -network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { - LwIPLock lock; - const ip_addr_t *dns_ip = dns_getserver(num); - return dns_ip; -} - -void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event, void *event_data) { - const char *event_name; - - switch (event) { - case ETHERNET_EVENT_START: - event_name = "ETH started"; - global_eth_component->started_ = true; - global_eth_component->enable_loop_soon_any_context(); - break; - case ETHERNET_EVENT_STOP: - event_name = "ETH stopped"; - global_eth_component->started_ = false; - global_eth_component->connected_ = false; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes - break; - case ETHERNET_EVENT_CONNECTED: - event_name = "ETH connected"; - // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here -#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP) - if (global_eth_component->manual_ip_.has_value()) { - global_eth_component->notify_ip_state_listeners_(); - } +#ifdef USE_ETHERNET_MANUAL_IP +void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } #endif - break; - case ETHERNET_EVENT_DISCONNECTED: - event_name = "ETH disconnected"; - global_eth_component->connected_ = false; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes - break; - default: - return; - } - ESP_LOGV(TAG, "[Ethernet event] %s (num=%" PRId32 ")", event_name, event); -} +// set_use_address() is guaranteed to be called during component setup by Python code generation, +// so use_address_ will always be valid when get_use_address() is called - no fallback needed. +const char *EthernetComponent::get_use_address() const { return this->use_address_; } -void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, - void *event_data) { - ip_event_got_ip_t *event = (ip_event_got_ip_t *) event_data; - const esp_netif_ip_info_t *ip_info = &event->ip_info; - ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip)); - global_eth_component->got_ipv4_address_ = true; -#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) - global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#else - global_eth_component->connected_ = true; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#endif /* USE_NETWORK_IPV6 */ -#ifdef USE_ETHERNET_IP_STATE_LISTENERS - global_eth_component->notify_ip_state_listeners_(); -#endif -} - -#if USE_NETWORK_IPV6 -void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, - void *event_data) { - ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data; - ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip)); - global_eth_component->ipv6_count_ += 1; -#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) - global_eth_component->connected_ = - global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT); - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#else - global_eth_component->connected_ = global_eth_component->got_ipv4_address_; - global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes -#endif -#ifdef USE_ETHERNET_IP_STATE_LISTENERS - global_eth_component->notify_ip_state_listeners_(); -#endif -} -#endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; } #ifdef USE_ETHERNET_IP_STATE_LISTENERS void EthernetComponent::notify_ip_state_listeners_() { @@ -570,315 +35,6 @@ void EthernetComponent::notify_ip_state_listeners_() { } #endif // USE_ETHERNET_IP_STATE_LISTENERS -void EthernetComponent::finish_connect_() { -#if USE_NETWORK_IPV6 - // Retry IPv6 link-local setup if it failed during initial connect - // This handles the case where min_ipv6_addr_count is NOT set (or is 0), - // allowing us to reach CONNECTED state with just IPv4. - // If IPv6 setup failed in start_connect_() because the interface wasn't ready: - // - Bootup timing issues (#10281) - // - Cable unplugged/network interruption (#10705) - // We can now retry since we're in CONNECTED state and the interface is definitely up. - if (!this->ipv6_setup_done_) { - esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); - if (err == ESP_OK) { - ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); - } - // Always set the flag to prevent continuous retries - // If IPv6 setup fails here with the interface up and stable, it's - // likely a persistent issue (IPv6 disabled at router, hardware - // limitation, etc.) that won't be resolved by further retries. - // The device continues to work with IPv4. - this->ipv6_setup_done_ = true; - } -#endif /* USE_NETWORK_IPV6 */ -} - -void EthernetComponent::start_connect_() { - global_eth_component->got_ipv4_address_ = false; -#if USE_NETWORK_IPV6 - global_eth_component->ipv6_count_ = 0; - this->ipv6_setup_done_ = false; -#endif /* USE_NETWORK_IPV6 */ - this->connect_begin_ = millis(); - this->status_set_warning(LOG_STR("waiting for IP configuration")); - - esp_err_t err; - err = esp_netif_set_hostname(this->eth_netif_, App.get_name().c_str()); - if (err != ERR_OK) { - ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); - } - - esp_netif_ip_info_t info; -#ifdef USE_ETHERNET_MANUAL_IP - if (this->manual_ip_.has_value()) { - info.ip = this->manual_ip_->static_ip; - info.gw = this->manual_ip_->gateway; - info.netmask = this->manual_ip_->subnet; - } else -#endif - { - info.ip.addr = 0; - info.gw.addr = 0; - info.netmask.addr = 0; - } - - esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT; - - err = esp_netif_dhcpc_get_status(this->eth_netif_, &status); - ESPHL_ERROR_CHECK(err, "DHCPC Get Status Failed!"); - - ESP_LOGV(TAG, "DHCP Client Status: %d", status); - - err = esp_netif_dhcpc_stop(this->eth_netif_); - if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { - ESPHL_ERROR_CHECK(err, "DHCPC stop error"); - } - - err = esp_netif_set_ip_info(this->eth_netif_, &info); - ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); - -#ifdef USE_ETHERNET_MANUAL_IP - if (this->manual_ip_.has_value()) { - LwIPLock lock; - if (this->manual_ip_->dns1.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns1; - dns_setserver(0, &d); - } - if (this->manual_ip_->dns2.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns2; - dns_setserver(1, &d); - } - } else -#endif - { - err = esp_netif_dhcpc_start(this->eth_netif_); - if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { - ESPHL_ERROR_CHECK(err, "DHCPC start error"); - } - } -#if USE_NETWORK_IPV6 - // Attempt to create IPv6 link-local address - // We MUST attempt this here, not just in finish_connect_(), because with - // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. - // However, this may fail with ESP_FAIL if the interface is not up yet: - // - At bootup when link isn't ready (#10281) - // - After disconnection/cable unplugged (#10705) - // We'll retry in finish_connect_() if it fails here. - err = esp_netif_create_ip6_linklocal(this->eth_netif_); - if (err != ESP_OK) { - if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { - // This is a programming error, not a transient failure - ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); - } else { - // ESP_FAIL means the interface isn't up yet - // This is expected and non-fatal, happens in multiple scenarios: - // - During reconnection after network interruptions (#10705) - // - At bootup when the link isn't ready yet (#10281) - // We'll retry once we reach CONNECTED state and the interface is up - ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); - // Don't mark component as failed - this is a transient error - } - } -#endif /* USE_NETWORK_IPV6 */ - - this->connect_begin_ = millis(); - this->status_set_warning(); -} - -void EthernetComponent::dump_connect_params_() { - esp_netif_ip_info_t ip; - esp_netif_get_ip_info(this->eth_netif_, &ip); - const ip_addr_t *dns_ip1; - const ip_addr_t *dns_ip2; - { - LwIPLock lock; - dns_ip1 = dns_getserver(0); - dns_ip2 = dns_getserver(1); - } - - // Use stack buffers for IP address formatting to avoid heap allocations - char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; - char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGCONFIG(TAG, - " IP Address: %s\n" - " Hostname: '%s'\n" - " Subnet: %s\n" - " Gateway: %s\n" - " DNS1: %s\n" - " DNS2: %s\n" - " MAC Address: %s\n" - " Is Full Duplex: %s\n" - " Link Speed: %u", - network::IPAddress(&ip.ip).str_to(ip_buf), App.get_name().c_str(), - network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), - network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), - this->get_eth_mac_address_pretty_into_buffer(mac_buf), - YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); - -#if USE_NETWORK_IPV6 - struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; - uint8_t count = 0; - count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); - assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); - for (int i = 0; i < count; i++) { - ESP_LOGCONFIG(TAG, " IPv6: " IPV6STR, IPV62STR(if_ip6s[i])); - } -#endif /* USE_NETWORK_IPV6 */ -} - -#ifdef USE_ETHERNET_SPI -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } -void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT -void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } -#endif -#else -void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } -void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } -void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } -void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } -void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } -#endif -void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } -#ifdef USE_ETHERNET_MANUAL_IP -void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } -#endif - -// set_use_address() is guaranteed to be called during component setup by Python code generation, -// so use_address_ will always be valid when get_use_address() is called - no fallback needed. -const char *EthernetComponent::get_use_address() const { return this->use_address_; } - -void EthernetComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; } - -void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { - esp_err_t err; - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); - ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); -} - -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - -const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( - std::span buf) { - uint8_t mac[6]; - get_eth_mac_address_raw(mac); - format_mac_addr_upper(mac, buf.data()); - return buf.data(); -} - -eth_duplex_t EthernetComponent::get_duplex_mode() { - esp_err_t err; - eth_duplex_t duplex_mode; - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); - ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_DUPLEX_MODE error", ETH_DUPLEX_HALF); - return duplex_mode; -} - -eth_speed_t EthernetComponent::get_link_speed() { - esp_err_t err; - eth_speed_t speed; - err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); - ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_SPEED error", ETH_SPEED_10M); - return speed; -} - -bool EthernetComponent::powerdown() { - ESP_LOGI(TAG, "Powering down ethernet PHY"); - if (this->phy_ == nullptr) { - ESP_LOGE(TAG, "Ethernet PHY not assigned"); - return false; - } - this->connected_ = false; - this->started_ = false; - // No need to enable_loop() here as this is only called during shutdown/reboot - if (this->phy_->pwrctl(this->phy_, false) != ESP_OK) { - ESP_LOGE(TAG, "Error powering down ethernet PHY"); - return false; - } - return true; -} - -#ifndef USE_ETHERNET_SPI - -#ifdef USE_ETHERNET_KSZ8081 -constexpr uint8_t KSZ80XX_PC2R_REG_ADDR = 0x1F; - -void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { - esp_err_t err; - - uint32_t phy_control_2; - err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); - ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE - char hex_buf[format_hex_pretty_size(PHY_REG_SIZE)]; -#endif - ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); - - /* - * Bit 7 is `RMII Reference Clock Select`. Default is `0`. - * KSZ8081RNA: - * 0 - clock input to XI (Pin 8) is 25 MHz for RMII - 25 MHz clock mode. - * 1 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. - * KSZ8081RND: - * 0 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. - * 1 - clock input to XI (Pin 8) is 25 MHz (driven clock only, not a crystal) for RMII - 25 MHz clock mode. - */ - if ((phy_control_2 & (1 << 7)) != (1 << 7)) { - phy_control_2 |= 1 << 7; - err = mac->write_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, phy_control_2); - ESPHL_ERROR_CHECK(err, "Write PHY Control 2 failed"); - err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); - ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); - ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", - format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); - } -} -#endif // USE_ETHERNET_KSZ8081 - -void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { - esp_err_t err; - -#ifdef USE_ETHERNET_RTL8201 - constexpr uint8_t eth_phy_psr_reg_addr = 0x1F; - if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { - ESP_LOGD(TAG, "Select PHY Register Page: 0x%02" PRIX32, register_data.page); - err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, register_data.page); - ESPHL_ERROR_CHECK(err, "Select PHY Register page failed"); - } -#endif - - ESP_LOGD(TAG, "Writing PHY reg 0x%02" PRIX32 " = 0x%04" PRIX32, register_data.address, register_data.value); - err = mac->write_phy_reg(mac, this->phy_addr_, register_data.address, register_data.value); - ESPHL_ERROR_CHECK(err, "Writing PHY Register failed"); - -#ifdef USE_ETHERNET_RTL8201 - if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { - ESP_LOGD(TAG, "Select PHY Register Page 0x00"); - err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, 0x0); - ESPHL_ERROR_CHECK(err, "Select PHY Register Page 0 failed"); - } -#endif -} - -#endif - } // namespace esphome::ethernet -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index e54e1543e3f..ad2368e03fe 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -7,14 +7,25 @@ #include "esphome/core/automation.h" #include "esphome/components/network/ip_address.h" -#ifdef USE_ESP32 +#ifdef USE_ETHERNET +#ifdef USE_ESP32 #include "esp_eth.h" #include "esp_eth_mac.h" +#include "esp_eth_mac_esp.h" #include "esp_netif.h" #include "esp_mac.h" #include "esp_idf_version.h" +#if CONFIG_ETH_USE_ESP32_EMAC +extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); +#endif +#endif // USE_ESP32 + +#ifdef USE_RP2040 +#include +#endif + namespace esphome::ethernet { #ifdef USE_ETHERNET_IP_STATE_LISTENERS @@ -68,6 +79,12 @@ enum class EthernetComponentState : uint8_t { CONNECTED, }; +// Platform-neutral duplex/speed types +#ifndef USE_ESP32 +enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL }; +enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M }; +#endif + class EthernetComponent : public Component { public: EthernetComponent(); @@ -78,6 +95,28 @@ class EthernetComponent : public Component { void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } + void set_type(EthernetType type); +#ifdef USE_ETHERNET_MANUAL_IP + void set_manual_ip(const ManualIP &manual_ip); +#endif + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } + + network::IPAddresses get_ip_addresses(); + network::IPAddress get_dns_address(uint8_t num); + const char *get_use_address() const; + void set_use_address(const char *use_address); + void get_eth_mac_address_raw(uint8_t *mac); + // Remove before 2026.9.0 + ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") + std::string get_eth_mac_address_pretty(); + const char *get_eth_mac_address_pretty_into_buffer(std::span buf); + eth_duplex_t get_duplex_mode(); + eth_speed_t get_link_speed(); + bool powerdown(); + +#ifdef USE_ESP32 + esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } + #ifdef USE_ETHERNET_SPI void set_clk_pin(uint8_t clk_pin); void set_miso_pin(uint8_t miso_pin); @@ -97,26 +136,17 @@ class EthernetComponent : public Component { void set_clk_pin(uint8_t clk_pin); void set_clk_mode(emac_rmii_clock_mode_t clk_mode); void add_phy_register(PHYRegister register_value); -#endif - void set_type(EthernetType type); -#ifdef USE_ETHERNET_MANUAL_IP - void set_manual_ip(const ManualIP &manual_ip); -#endif - void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } +#endif // USE_ETHERNET_SPI +#endif // USE_ESP32 - network::IPAddresses get_ip_addresses(); - network::IPAddress get_dns_address(uint8_t num); - const char *get_use_address() const; - void set_use_address(const char *use_address); - void get_eth_mac_address_raw(uint8_t *mac); - // Remove before 2026.9.0 - ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") - std::string get_eth_mac_address_pretty(); - const char *get_eth_mac_address_pretty_into_buffer(std::span buf); - eth_duplex_t get_duplex_mode(); - eth_speed_t get_link_speed(); - esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } - bool powerdown(); +#ifdef USE_RP2040 + void set_clk_pin(uint8_t clk_pin); + void set_miso_pin(uint8_t miso_pin); + void set_mosi_pin(uint8_t mosi_pin); + void set_cs_pin(uint8_t cs_pin); + void set_interrupt_pin(int8_t interrupt_pin); + void set_reset_pin(int8_t reset_pin); +#endif // USE_RP2040 #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } @@ -128,19 +158,22 @@ class EthernetComponent : public Component { #ifdef USE_ETHERNET_DISCONNECT_TRIGGER Trigger<> *get_disconnect_trigger() { return &this->disconnect_trigger_; } #endif + protected: + void start_connect_(); + void finish_connect_(); + void dump_connect_params_(); + +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + void notify_ip_state_listeners_(); +#endif + +#ifdef USE_ESP32 static void eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); static void got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #if LWIP_IPV6 static void got_ip6_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif /* LWIP_IPV6 */ -#ifdef USE_ETHERNET_IP_STATE_LISTENERS - void notify_ip_state_listeners_(); -#endif - - void start_connect_(); - void finish_connect_(); - void dump_connect_params_(); void log_error_and_mark_failed_(esp_err_t err, const char *message); #ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. @@ -172,7 +205,25 @@ class EthernetComponent : public Component { uint8_t phy_addr_{0}; uint8_t mdc_pin_{23}; uint8_t mdio_pin_{18}; -#endif +#endif // USE_ETHERNET_SPI + + // ESP32 pointers + esp_netif_t *eth_netif_{nullptr}; + esp_eth_handle_t eth_handle_; + esp_eth_phy_t *phy_{nullptr}; +#endif // USE_ESP32 + +#ifdef USE_RP2040 + Wiznet5500lwIP *eth_{nullptr}; + uint8_t clk_pin_; + uint8_t miso_pin_; + uint8_t mosi_pin_; + uint8_t cs_pin_; + int8_t interrupt_pin_{-1}; + int8_t reset_pin_{-1}; +#endif // USE_RP2040 + + // Common members #ifdef USE_ETHERNET_MANUAL_IP optional manual_ip_{}; #endif @@ -189,10 +240,6 @@ class EthernetComponent : public Component { bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - // Pointers at the end (naturally aligned) - esp_netif_t *eth_netif_{nullptr}; - esp_eth_handle_t eth_handle_; - esp_eth_phy_t *phy_{nullptr}; optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS @@ -214,10 +261,12 @@ class EthernetComponent : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern EthernetComponent *global_eth_component; -#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) +#ifdef USE_ESP32 +#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) extern "C" esp_eth_phy_t *esp_eth_phy_new_jl1101(const eth_phy_config_t *config); #endif +#endif // USE_ESP32 } // namespace esphome::ethernet -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp new file mode 100644 index 00000000000..ac8680f3e13 --- /dev/null +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -0,0 +1,841 @@ +#include "ethernet_component.h" + +#if defined(USE_ETHERNET) && defined(USE_ESP32) + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include "esp_event.h" + +#ifdef USE_ETHERNET_LAN8670 +#include "esp_eth_phy_lan867x.h" +#endif + +#ifdef USE_ETHERNET_SPI +#include +#include +#endif + +namespace esphome::ethernet { + +static const char *const TAG = "ethernet"; + +// PHY register size for hex logging +static constexpr size_t PHY_REG_SIZE = 2; + +void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { + ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); + this->mark_failed(); +} + +#define ESPHL_ERROR_CHECK(err, message) \ + if ((err) != ESP_OK) { \ + this->log_error_and_mark_failed_(err, message); \ + return; \ + } + +#define ESPHL_ERROR_CHECK_RET(err, message, ret) \ + if ((err) != ESP_OK) { \ + this->log_error_and_mark_failed_(err, message); \ + return ret; \ + } + +void EthernetComponent::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + switch (this->state_) { + case EthernetComponentState::STOPPED: + if (this->started_) { + ESP_LOGI(TAG, "Starting connection"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTING: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; + } else if (this->connected_) { + // connection established + ESP_LOGI(TAG, "Connected"); + this->state_ = EthernetComponentState::CONNECTED; + + this->dump_connect_params_(); + this->status_clear_warning(); +#ifdef USE_ETHERNET_CONNECT_TRIGGER + this->connect_trigger_.trigger(); +#endif + } else if (now - this->connect_begin_ > 15000) { + ESP_LOGW(TAG, "Connecting failed; reconnecting"); + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTED: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else if (!this->connected_) { + ESP_LOGW(TAG, "Connection lost; reconnecting"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else { + this->finish_connect_(); + // When connected and stable, disable the loop to save CPU cycles + this->disable_loop(); + } + break; + } +} + +void EthernetComponent::setup() { + if (esp_reset_reason() != ESP_RST_DEEPSLEEP) { + // Delay here to allow power to stabilise before Ethernet is initialized. + delay(300); // NOLINT + } + + esp_err_t err; + +#ifdef USE_ETHERNET_SPI + // Install GPIO ISR handler to be able to service SPI Eth modules interrupts + gpio_install_isr_service(0); + + spi_bus_config_t buscfg = { + .mosi_io_num = this->mosi_pin_, + .miso_io_num = this->miso_pin_, + .sclk_io_num = this->clk_pin_, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + .data4_io_num = -1, + .data5_io_num = -1, + .data6_io_num = -1, + .data7_io_num = -1, + .max_transfer_sz = 0, + .flags = 0, + .intr_flags = 0, + }; + +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + auto host = SPI2_HOST; +#else + auto host = SPI3_HOST; +#endif + + err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); + ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); +#endif + + err = esp_netif_init(); + ESPHL_ERROR_CHECK(err, "ETH netif init error"); + err = esp_event_loop_create_default(); + ESPHL_ERROR_CHECK(err, "ETH event loop error"); + + esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); + this->eth_netif_ = esp_netif_new(&cfg); + + // Init MAC and PHY configs to default + eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); + eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); + +#ifdef USE_ETHERNET_SPI // Configure SPI interface and Ethernet driver for specific SPI module + spi_device_interface_config_t devcfg = { + .command_bits = 0, + .address_bits = 0, + .dummy_bits = 0, + .mode = 0, + .duty_cycle_pos = 0, + .cs_ena_pretrans = 0, + .cs_ena_posttrans = 0, + .clock_speed_hz = this->clock_speed_, + .input_delay_ns = 0, + .spics_io_num = this->cs_pin_, + .flags = 0, + .queue_size = 20, + .pre_cb = nullptr, + .post_cb = nullptr, + }; + +#if CONFIG_ETH_SPI_ETHERNET_W5500 + eth_w5500_config_t w5500_config = ETH_W5500_DEFAULT_CONFIG(host, &devcfg); +#endif +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); +#endif + +#if CONFIG_ETH_SPI_ETHERNET_W5500 + w5500_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + w5500_config.poll_period_ms = this->polling_interval_; +#endif +#endif + +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + dm9051_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + dm9051_config.poll_period_ms = this->polling_interval_; +#endif +#endif + + phy_config.phy_addr = this->phy_addr_spi_; + phy_config.reset_gpio_num = this->reset_pin_; + + esp_eth_mac_t *mac = nullptr; +#elif defined(USE_ETHERNET_OPENETH) + esp_eth_mac_t *mac = esp_eth_mac_new_openeth(&mac_config); +#else + phy_config.phy_addr = this->phy_addr_; + phy_config.reset_gpio_num = this->power_pin_; + + eth_esp32_emac_config_t esp32_emac_config = eth_esp32_emac_default_config(); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) + esp32_emac_config.smi_gpio.mdc_num = this->mdc_pin_; + esp32_emac_config.smi_gpio.mdio_num = this->mdio_pin_; +#else + esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; + esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; +#endif + esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; + esp32_emac_config.clock_config.rmii.clock_gpio = (emac_rmii_clock_gpio_t) this->clk_pin_; + + esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); +#endif + + switch (this->type_) { +#ifdef USE_ETHERNET_OPENETH + case ETHERNET_TYPE_OPENETH: { + phy_config.autonego_timeout_ms = 1000; + this->phy_ = esp_eth_phy_new_dp83848(&phy_config); + break; + } +#endif +#if CONFIG_ETH_USE_ESP32_EMAC +#ifdef USE_ETHERNET_LAN8720 + case ETHERNET_TYPE_LAN8720: { + this->phy_ = esp_eth_phy_new_lan87xx(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_RTL8201 + case ETHERNET_TYPE_RTL8201: { + this->phy_ = esp_eth_phy_new_rtl8201(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_DP83848 + case ETHERNET_TYPE_DP83848: { + this->phy_ = esp_eth_phy_new_dp83848(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_IP101 + case ETHERNET_TYPE_IP101: { + this->phy_ = esp_eth_phy_new_ip101(&phy_config); + break; + } +#endif +#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) + case ETHERNET_TYPE_JL1101: { + this->phy_ = esp_eth_phy_new_jl1101(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_KSZ8081 + case ETHERNET_TYPE_KSZ8081: + case ETHERNET_TYPE_KSZ8081RNA: { + this->phy_ = esp_eth_phy_new_ksz80xx(&phy_config); + break; + } +#endif +#ifdef USE_ETHERNET_LAN8670 + case ETHERNET_TYPE_LAN8670: { + this->phy_ = esp_eth_phy_new_lan867x(&phy_config); + break; + } +#endif +#endif +#ifdef USE_ETHERNET_SPI +#if CONFIG_ETH_SPI_ETHERNET_W5500 + case ETHERNET_TYPE_W5500: { + mac = esp_eth_mac_new_w5500(&w5500_config, &mac_config); + this->phy_ = esp_eth_phy_new_w5500(&phy_config); + break; + } +#endif +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + case ETHERNET_TYPE_DM9051: { + mac = esp_eth_mac_new_dm9051(&dm9051_config, &mac_config); + this->phy_ = esp_eth_phy_new_dm9051(&phy_config); + break; + } +#endif +#endif + default: { + this->mark_failed(); + return; + } + } + + esp_eth_config_t eth_config = ETH_DEFAULT_CONFIG(mac, this->phy_); + this->eth_handle_ = nullptr; + err = esp_eth_driver_install(ð_config, &this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH driver install error"); + +#ifndef USE_ETHERNET_SPI +#ifdef USE_ETHERNET_KSZ8081 + if (this->type_ == ETHERNET_TYPE_KSZ8081RNA && this->clk_mode_ == EMAC_CLK_OUT) { + // KSZ8081RNA default is incorrect. It expects a 25MHz clock instead of the 50MHz we provide. + this->ksz8081_set_clock_reference_(mac); + } +#endif // USE_ETHERNET_KSZ8081 + + for (const auto &phy_register : this->phy_registers_) { + this->write_phy_register_(mac, phy_register); + } +#endif + + // use ESP internal eth mac + uint8_t mac_addr[6]; + if (this->fixed_mac_.has_value()) { + memcpy(mac_addr, this->fixed_mac_->data(), 6); + } else { + esp_read_mac(mac_addr, ESP_MAC_ETH); + } + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_MAC_ADDR, mac_addr); + ESPHL_ERROR_CHECK(err, "set mac address error"); + + /* attach Ethernet driver to TCP/IP stack */ + err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); + ESPHL_ERROR_CHECK(err, "ETH netif attach error"); + + // Register user defined event handers + err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); + ESPHL_ERROR_CHECK(err, "ETH event handler register error"); + err = esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, &EthernetComponent::got_ip_event_handler, nullptr); + ESPHL_ERROR_CHECK(err, "GOT IP event handler register error"); +#if USE_NETWORK_IPV6 + err = esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, &EthernetComponent::got_ip6_event_handler, nullptr); + ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); +#endif /* USE_NETWORK_IPV6 */ + + /* start Ethernet driver state machine */ + err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); +} + +void EthernetComponent::dump_config() { + const char *eth_type; + switch (this->type_) { +#ifdef USE_ETHERNET_LAN8720 + case ETHERNET_TYPE_LAN8720: + eth_type = "LAN8720"; + break; +#endif +#ifdef USE_ETHERNET_RTL8201 + case ETHERNET_TYPE_RTL8201: + eth_type = "RTL8201"; + break; +#endif +#ifdef USE_ETHERNET_DP83848 + case ETHERNET_TYPE_DP83848: + eth_type = "DP83848"; + break; +#endif +#ifdef USE_ETHERNET_IP101 + case ETHERNET_TYPE_IP101: + eth_type = "IP101"; + break; +#endif +#if defined(USE_ETHERNET_JL1101) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 4, 2) || !defined(PLATFORMIO)) + case ETHERNET_TYPE_JL1101: + eth_type = "JL1101"; + break; +#endif +#ifdef USE_ETHERNET_KSZ8081 + case ETHERNET_TYPE_KSZ8081: + eth_type = "KSZ8081"; + break; + + case ETHERNET_TYPE_KSZ8081RNA: + eth_type = "KSZ8081RNA"; + break; +#endif +#if CONFIG_ETH_SPI_ETHERNET_W5500 + case ETHERNET_TYPE_W5500: + eth_type = "W5500"; + break; +#endif +#if CONFIG_ETH_SPI_ETHERNET_DM9051 + case ETHERNET_TYPE_DM9051: + eth_type = "DM9051"; + break; +#endif +#ifdef USE_ETHERNET_OPENETH + case ETHERNET_TYPE_OPENETH: + eth_type = "OPENETH"; + break; +#endif +#ifdef USE_ETHERNET_LAN8670 + case ETHERNET_TYPE_LAN8670: + eth_type = "LAN8670"; + break; +#endif + + default: + eth_type = "Unknown"; + break; + } + + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Connected: %s", + YESNO(this->is_connected())); + this->dump_connect_params_(); +#ifdef USE_ETHERNET_SPI + ESP_LOGCONFIG(TAG, + " CLK Pin: %u\n" + " MISO Pin: %u\n" + " MOSI Pin: %u\n" + " CS Pin: %u", + this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + if (this->polling_interval_ != 0) { + ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_); + } else +#endif + { + ESP_LOGCONFIG(TAG, " IRQ Pin: %d", this->interrupt_pin_); + } + ESP_LOGCONFIG(TAG, + " Reset Pin: %d\n" + " Clock Speed: %d MHz", + this->reset_pin_, this->clock_speed_ / 1000000); +#else + if (this->power_pin_ != -1) { + ESP_LOGCONFIG(TAG, " Power Pin: %u", this->power_pin_); + } + ESP_LOGCONFIG(TAG, + " CLK Pin: %u\n" + " MDC Pin: %u\n" + " MDIO Pin: %u\n" + " PHY addr: %u", + this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); +#endif + ESP_LOGCONFIG(TAG, " Type: %s", eth_type); +} + +network::IPAddresses EthernetComponent::get_ip_addresses() { + network::IPAddresses addresses; + esp_netif_ip_info_t ip; + esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); + if (err != ESP_OK) { + ESP_LOGV(TAG, "esp_netif_get_ip_info failed: %s", esp_err_to_name(err)); + // TODO: do something smarter + // return false; + } else { + addresses[0] = network::IPAddress(&ip.ip); + } +#if USE_NETWORK_IPV6 + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + uint8_t count = 0; + count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); + assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); + assert(count < addresses.size()); + for (int i = 0; i < count; i++) { + addresses[i + 1] = network::IPAddress(&if_ip6s[i]); + } +#endif /* USE_NETWORK_IPV6 */ + + return addresses; +} + +network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { + LwIPLock lock; + const ip_addr_t *dns_ip = dns_getserver(num); + return dns_ip; +} + +void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event, void *event_data) { + const char *event_name; + + switch (event) { + case ETHERNET_EVENT_START: + event_name = "ETH started"; + global_eth_component->started_ = true; + global_eth_component->enable_loop_soon_any_context(); + break; + case ETHERNET_EVENT_STOP: + event_name = "ETH stopped"; + global_eth_component->started_ = false; + global_eth_component->connected_ = false; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes + break; + case ETHERNET_EVENT_CONNECTED: + event_name = "ETH connected"; + // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here +#if defined(USE_ETHERNET_IP_STATE_LISTENERS) && defined(USE_ETHERNET_MANUAL_IP) + if (global_eth_component->manual_ip_.has_value()) { + global_eth_component->notify_ip_state_listeners_(); + } +#endif + break; + case ETHERNET_EVENT_DISCONNECTED: + event_name = "ETH disconnected"; + global_eth_component->connected_ = false; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes + break; + default: + return; + } + + ESP_LOGV(TAG, "[Ethernet event] %s (num=%" PRId32 ")", event_name, event); +} + +void EthernetComponent::got_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, + void *event_data) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *) event_data; + const esp_netif_ip_info_t *ip_info = &event->ip_info; + ESP_LOGV(TAG, "[Ethernet event] ETH Got IP " IPSTR, IP2STR(&ip_info->ip)); + global_eth_component->got_ipv4_address_ = true; +#if USE_NETWORK_IPV6 && (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) + global_eth_component->connected_ = global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#else + global_eth_component->connected_ = true; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#endif /* USE_NETWORK_IPV6 */ +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + global_eth_component->notify_ip_state_listeners_(); +#endif +} + +#if USE_NETWORK_IPV6 +void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, + void *event_data) { + ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data; + ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip)); + global_eth_component->ipv6_count_ += 1; +#if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) + global_eth_component->connected_ = + global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT); + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#else + global_eth_component->connected_ = global_eth_component->got_ipv4_address_; + global_eth_component->enable_loop_soon_any_context(); // Enable loop when connection state changes +#endif +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + global_eth_component->notify_ip_state_listeners_(); +#endif +} +#endif /* USE_NETWORK_IPV6 */ + +void EthernetComponent::finish_connect_() { +#if USE_NETWORK_IPV6 + // Retry IPv6 link-local setup if it failed during initial connect + // This handles the case where min_ipv6_addr_count is NOT set (or is 0), + // allowing us to reach CONNECTED state with just IPv4. + // If IPv6 setup failed in start_connect_() because the interface wasn't ready: + // - Bootup timing issues (#10281) + // - Cable unplugged/network interruption (#10705) + // We can now retry since we're in CONNECTED state and the interface is definitely up. + if (!this->ipv6_setup_done_) { + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); + } + // Always set the flag to prevent continuous retries + // If IPv6 setup fails here with the interface up and stable, it's + // likely a persistent issue (IPv6 disabled at router, hardware + // limitation, etc.) that won't be resolved by further retries. + // The device continues to work with IPv4. + this->ipv6_setup_done_ = true; + } +#endif /* USE_NETWORK_IPV6 */ +} + +void EthernetComponent::start_connect_() { + global_eth_component->got_ipv4_address_ = false; +#if USE_NETWORK_IPV6 + global_eth_component->ipv6_count_ = 0; + this->ipv6_setup_done_ = false; +#endif /* USE_NETWORK_IPV6 */ + this->connect_begin_ = millis(); + this->status_set_warning(LOG_STR("waiting for IP configuration")); + + esp_err_t err; + err = esp_netif_set_hostname(this->eth_netif_, App.get_name().c_str()); + if (err != ERR_OK) { + ESP_LOGW(TAG, "esp_netif_set_hostname failed: %s", esp_err_to_name(err)); + } + + esp_netif_ip_info_t info; +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + info.ip = this->manual_ip_->static_ip; + info.gw = this->manual_ip_->gateway; + info.netmask = this->manual_ip_->subnet; + } else +#endif + { + info.ip.addr = 0; + info.gw.addr = 0; + info.netmask.addr = 0; + } + + esp_netif_dhcp_status_t status = ESP_NETIF_DHCP_INIT; + + err = esp_netif_dhcpc_get_status(this->eth_netif_, &status); + ESPHL_ERROR_CHECK(err, "DHCPC Get Status Failed!"); + + ESP_LOGV(TAG, "DHCP Client Status: %d", status); + + err = esp_netif_dhcpc_stop(this->eth_netif_); + if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { + ESPHL_ERROR_CHECK(err, "DHCPC stop error"); + } + + err = esp_netif_set_ip_info(this->eth_netif_, &info); + ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); + +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + LwIPLock lock; + if (this->manual_ip_->dns1.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns1; + dns_setserver(0, &d); + } + if (this->manual_ip_->dns2.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns2; + dns_setserver(1, &d); + } + } else +#endif + { + err = esp_netif_dhcpc_start(this->eth_netif_); + if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { + ESPHL_ERROR_CHECK(err, "DHCPC start error"); + } + } +#if USE_NETWORK_IPV6 + // Attempt to create IPv6 link-local address + // We MUST attempt this here, not just in finish_connect_(), because with + // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. + // However, this may fail with ESP_FAIL if the interface is not up yet: + // - At bootup when link isn't ready (#10281) + // - After disconnection/cable unplugged (#10705) + // We'll retry in finish_connect_() if it fails here. + err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err != ESP_OK) { + if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { + // This is a programming error, not a transient failure + ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); + } else { + // ESP_FAIL means the interface isn't up yet + // This is expected and non-fatal, happens in multiple scenarios: + // - During reconnection after network interruptions (#10705) + // - At bootup when the link isn't ready yet (#10281) + // We'll retry once we reach CONNECTED state and the interface is up + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); + // Don't mark component as failed - this is a transient error + } + } +#endif /* USE_NETWORK_IPV6 */ + + this->connect_begin_ = millis(); + this->status_set_warning(); +} + +void EthernetComponent::dump_connect_params_() { + esp_netif_ip_info_t ip; + esp_netif_get_ip_info(this->eth_netif_, &ip); + const ip_addr_t *dns_ip1; + const ip_addr_t *dns_ip2; + { + LwIPLock lock; + dns_ip1 = dns_getserver(0); + dns_ip2 = dns_getserver(1); + } + + // Use stack buffers for IP address formatting to avoid heap allocations + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGCONFIG(TAG, + " IP Address: %s\n" + " Hostname: '%s'\n" + " Subnet: %s\n" + " Gateway: %s\n" + " DNS1: %s\n" + " DNS2: %s\n" + " MAC Address: %s\n" + " Is Full Duplex: %s\n" + " Link Speed: %u", + network::IPAddress(&ip.ip).str_to(ip_buf), App.get_name().c_str(), + network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), + network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), + this->get_eth_mac_address_pretty_into_buffer(mac_buf), + YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); + +#if USE_NETWORK_IPV6 + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + uint8_t count = 0; + count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); + assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES); + for (int i = 0; i < count; i++) { + ESP_LOGCONFIG(TAG, " IPv6: " IPV6STR, IPV62STR(if_ip6s[i])); + } +#endif /* USE_NETWORK_IPV6 */ +} + +#ifdef USE_ETHERNET_SPI +void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } +void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } +void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } +void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } +void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } +void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } +void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT +void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } +#endif +#else +void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } +void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } +void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } +void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } +void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } +void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } +void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } +#endif + +void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + esp_err_t err; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); + ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); +} + +std::string EthernetComponent::get_eth_mac_address_pretty() { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); +} + +const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( + std::span buf) { + uint8_t mac[6]; + get_eth_mac_address_raw(mac); + format_mac_addr_upper(mac, buf.data()); + return buf.data(); +} + +eth_duplex_t EthernetComponent::get_duplex_mode() { + esp_err_t err; + eth_duplex_t duplex_mode; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); + ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_DUPLEX_MODE error", ETH_DUPLEX_HALF); + return duplex_mode; +} + +eth_speed_t EthernetComponent::get_link_speed() { + esp_err_t err; + eth_speed_t speed; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); + ESPHL_ERROR_CHECK_RET(err, "ETH_CMD_G_SPEED error", ETH_SPEED_10M); + return speed; +} + +bool EthernetComponent::powerdown() { + ESP_LOGI(TAG, "Powering down ethernet PHY"); + if (this->phy_ == nullptr) { + ESP_LOGE(TAG, "Ethernet PHY not assigned"); + return false; + } + this->connected_ = false; + this->started_ = false; + // No need to enable_loop() here as this is only called during shutdown/reboot + if (this->phy_->pwrctl(this->phy_, false) != ESP_OK) { + ESP_LOGE(TAG, "Error powering down ethernet PHY"); + return false; + } + return true; +} + +#ifndef USE_ETHERNET_SPI + +#ifdef USE_ETHERNET_KSZ8081 +constexpr uint8_t KSZ80XX_PC2R_REG_ADDR = 0x1F; + +void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { + esp_err_t err; + + uint32_t phy_control_2; + err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); + ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(PHY_REG_SIZE)]; +#endif + ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); + + /* + * Bit 7 is `RMII Reference Clock Select`. Default is `0`. + * KSZ8081RNA: + * 0 - clock input to XI (Pin 8) is 25 MHz for RMII - 25 MHz clock mode. + * 1 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. + * KSZ8081RND: + * 0 - clock input to XI (Pin 8) is 50 MHz for RMII - 50 MHz clock mode. + * 1 - clock input to XI (Pin 8) is 25 MHz (driven clock only, not a crystal) for RMII - 25 MHz clock mode. + */ + if ((phy_control_2 & (1 << 7)) != (1 << 7)) { + phy_control_2 |= 1 << 7; + err = mac->write_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, phy_control_2); + ESPHL_ERROR_CHECK(err, "Write PHY Control 2 failed"); + err = mac->read_phy_reg(mac, this->phy_addr_, KSZ80XX_PC2R_REG_ADDR, &(phy_control_2)); + ESPHL_ERROR_CHECK(err, "Read PHY Control 2 failed"); + ESP_LOGVV(TAG, "KSZ8081 PHY Control 2: %s", + format_hex_pretty_to(hex_buf, (uint8_t *) &phy_control_2, PHY_REG_SIZE)); + } +} +#endif // USE_ETHERNET_KSZ8081 + +void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { + esp_err_t err; + +#ifdef USE_ETHERNET_RTL8201 + constexpr uint8_t eth_phy_psr_reg_addr = 0x1F; + if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { + ESP_LOGD(TAG, "Select PHY Register Page: 0x%02" PRIX32, register_data.page); + err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, register_data.page); + ESPHL_ERROR_CHECK(err, "Select PHY Register page failed"); + } +#endif + + ESP_LOGD(TAG, "Writing PHY reg 0x%02" PRIX32 " = 0x%04" PRIX32, register_data.address, register_data.value); + err = mac->write_phy_reg(mac, this->phy_addr_, register_data.address, register_data.value); + ESPHL_ERROR_CHECK(err, "Writing PHY Register failed"); + +#ifdef USE_ETHERNET_RTL8201 + if (this->type_ == ETHERNET_TYPE_RTL8201 && register_data.page) { + ESP_LOGD(TAG, "Select PHY Register Page 0x00"); + err = mac->write_phy_reg(mac, this->phy_addr_, eth_phy_psr_reg_addr, 0x0); + ESPHL_ERROR_CHECK(err, "Select PHY Register Page 0 failed"); + } +#endif +} + +#endif + +} // namespace esphome::ethernet + +#endif // USE_ETHERNET && USE_ESP32 diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp new file mode 100644 index 00000000000..8fe930bdbf4 --- /dev/null +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -0,0 +1,296 @@ +#include "ethernet_component.h" + +#if defined(USE_ETHERNET) && defined(USE_RP2040) + +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include "esphome/components/rp2040/gpio.h" + +#include +#include +#include + +namespace esphome::ethernet { + +static const char *const TAG = "ethernet"; + +void EthernetComponent::setup() { + // Configure SPI pins + SPI.setRX(this->miso_pin_); + SPI.setTX(this->mosi_pin_); + SPI.setSCK(this->clk_pin_); + + // Toggle reset pin if configured + if (this->reset_pin_ >= 0) { + rp2040::RP2040GPIOPin reset_pin; + reset_pin.set_pin(this->reset_pin_); + reset_pin.set_flags(gpio::FLAG_OUTPUT); + reset_pin.setup(); + reset_pin.digital_write(false); + delay(1); // NOLINT + reset_pin.digital_write(true); + delay(10); // NOLINT - wait for W5500 to initialize after reset + } + + // Create the W5500 device instance + this->eth_ = new Wiznet5500lwIP(this->cs_pin_, SPI, this->interrupt_pin_); // NOLINT + + // Set hostname before begin() so the LWIP netif gets it + this->eth_->hostname(App.get_name().c_str()); + + // Configure static IP if set (must be done before begin()) +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + IPAddress ip(this->manual_ip_->static_ip); + IPAddress gateway(this->manual_ip_->gateway); + IPAddress subnet(this->manual_ip_->subnet); + IPAddress dns1(this->manual_ip_->dns1); + IPAddress dns2(this->manual_ip_->dns2); + this->eth_->config(ip, gateway, subnet, dns1, dns2); + } +#endif + + // Begin with fixed MAC or auto-generated + bool success; + if (this->fixed_mac_.has_value()) { + success = this->eth_->begin(this->fixed_mac_->data()); + } else { + success = this->eth_->begin(); + } + + if (!success) { + ESP_LOGE(TAG, "Failed to initialize W5500 Ethernet"); + this->mark_failed(); + return; + } + + // Make this the default interface for routing + this->eth_->setDefault(true); + + // The arduino-pico LwipIntfDev automatically handles packet processing + // via __addEthernetPacketHandler when no interrupt pin is used, + // or via GPIO interrupt when one is provided. + + // Mark as started - connection will be detected in loop() + this->started_ = true; +} + +void EthernetComponent::loop() { + // On RP2040, we need to poll connection state since there are no events + if (this->eth_ != nullptr) { + bool link_up = this->eth_->linkStatus() == LinkON; + bool has_ip = this->eth_->connected(); + + if (!link_up) { + if (this->started_) { + this->started_ = false; + this->connected_ = false; + } + } else { + if (!this->started_) { + this->started_ = true; + } + bool was_connected = this->connected_; + this->connected_ = has_ip; + if (this->connected_ && !was_connected) { +#ifdef USE_ETHERNET_IP_STATE_LISTENERS + this->notify_ip_state_listeners_(); +#endif + } + } + } + + // Call common state machine + const uint32_t now = App.get_loop_component_start_time(); + + switch (this->state_) { + case EthernetComponentState::STOPPED: + if (this->started_) { + ESP_LOGI(TAG, "Starting connection"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTING: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; + } else if (this->connected_) { + // connection established + ESP_LOGI(TAG, "Connected"); + this->state_ = EthernetComponentState::CONNECTED; + + this->dump_connect_params_(); + this->status_clear_warning(); +#ifdef USE_ETHERNET_CONNECT_TRIGGER + this->connect_trigger_.trigger(); +#endif + } else if (now - this->connect_begin_ > 15000) { + ESP_LOGW(TAG, "Connecting failed; reconnecting"); + this->start_connect_(); + } + break; + case EthernetComponentState::CONNECTED: + if (!this->started_) { + ESP_LOGI(TAG, "Stopped connection"); + this->state_ = EthernetComponentState::STOPPED; +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else if (!this->connected_) { + ESP_LOGW(TAG, "Connection lost; reconnecting"); + this->state_ = EthernetComponentState::CONNECTING; + this->start_connect_(); +#ifdef USE_ETHERNET_DISCONNECT_TRIGGER + this->disconnect_trigger_.trigger(); +#endif + } else { + this->finish_connect_(); + } + break; + } +} + +void EthernetComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Type: W5500\n" + " Connected: %s\n" + " CLK Pin: %u\n" + " MISO Pin: %u\n" + " MOSI Pin: %u\n" + " CS Pin: %u\n" + " IRQ Pin: %d\n" + " Reset Pin: %d", + YESNO(this->is_connected()), this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_, + this->interrupt_pin_, this->reset_pin_); + this->dump_connect_params_(); +} + +network::IPAddresses EthernetComponent::get_ip_addresses() { + network::IPAddresses addresses; + if (this->eth_ != nullptr) { + addresses[0] = network::IPAddress(this->eth_->localIP()); + } + return addresses; +} + +network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { + const ip_addr_t *dns_ip = dns_getserver(num); + return dns_ip; +} + +void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + if (this->eth_ != nullptr) { + this->eth_->macAddress(mac); + } else { + memset(mac, 0, 6); + } +} + +std::string EthernetComponent::get_eth_mac_address_pretty() { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); +} + +const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( + std::span buf) { + uint8_t mac[6]; + get_eth_mac_address_raw(mac); + format_mac_addr_upper(mac, buf.data()); + return buf.data(); +} + +eth_duplex_t EthernetComponent::get_duplex_mode() { + // W5500 is always full duplex + return ETH_DUPLEX_FULL; +} + +eth_speed_t EthernetComponent::get_link_speed() { + // W5500 is always 100Mbps + return ETH_SPEED_100M; +} + +bool EthernetComponent::powerdown() { + ESP_LOGI(TAG, "Powering down ethernet"); + if (this->eth_ != nullptr) { + this->eth_->end(); + } + this->connected_ = false; + this->started_ = false; + return true; +} + +void EthernetComponent::start_connect_() { + this->got_ipv4_address_ = false; + this->connect_begin_ = millis(); + this->status_set_warning(LOG_STR("waiting for IP configuration")); + + // Hostname is already set in setup() via LwipIntf::setHostname() + +#ifdef USE_ETHERNET_MANUAL_IP + if (this->manual_ip_.has_value()) { + // Static IP was already configured before begin() in setup() + // Set DNS servers + if (this->manual_ip_->dns1.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns1; + dns_setserver(0, &d); + } + if (this->manual_ip_->dns2.is_set()) { + ip_addr_t d; + d = this->manual_ip_->dns2; + dns_setserver(1, &d); + } + } +#endif + + this->connect_begin_ = millis(); + this->status_set_warning(); +} + +void EthernetComponent::finish_connect_() { + // No additional work needed on RP2040 for now + // IPv6 link-local could be added here in the future +} + +void EthernetComponent::dump_connect_params_() { + if (this->eth_ == nullptr) { + return; + } + + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; + char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + + auto *netif = this->eth_->getNetIf(); + ESP_LOGCONFIG( + TAG, + " IP Address: %s\n" + " Hostname: '%s'\n" + " Subnet: %s\n" + " Gateway: %s\n" + " DNS1: %s\n" + " DNS2: %s\n" + " MAC Address: %s", + network::IPAddress(&netif->ip_addr).str_to(ip_buf), App.get_name().c_str(), + network::IPAddress(&netif->netmask).str_to(subnet_buf), network::IPAddress(&netif->gw).str_to(gateway_buf), + network::IPAddress(dns_getserver(0)).str_to(dns1_buf), network::IPAddress(dns_getserver(1)).str_to(dns2_buf), + this->get_eth_mac_address_pretty_into_buffer(mac_buf)); +} + +void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } +void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } +void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } +void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } +void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } +void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } + +} // namespace esphome::ethernet + +#endif // USE_ETHERNET && USE_RP2040 diff --git a/esphome/components/ethernet/ethernet_helpers.c b/esphome/components/ethernet/ethernet_helpers.c new file mode 100644 index 00000000000..49fbe825c84 --- /dev/null +++ b/esphome/components/ethernet/ethernet_helpers.c @@ -0,0 +1,13 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "esp_eth_mac_esp.h" + +// ETH_ESP32_EMAC_DEFAULT_CONFIG() uses out-of-order designated initializers +// which are valid in C but not in C++. This wrapper allows C++ code to get +// the default config without replicating the macro's contents. +#if CONFIG_ETH_USE_ESP32_EMAC +eth_esp32_emac_config_t eth_esp32_emac_default_config(void) { + return (eth_esp32_emac_config_t) ETH_ESP32_EMAC_DEFAULT_CONFIG(); +} +#endif +#endif // USE_ESP32 diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp index 72ce9c86e22..15ef6a1f205 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp @@ -1,7 +1,7 @@ #include "ethernet_info_text_sensor.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 +#ifdef USE_ETHERNET namespace esphome::ethernet_info { @@ -49,4 +49,4 @@ void MACAddressEthernetInfo::dump_config() { LOG_TEXT_SENSOR("", "EthernetInfo M } // namespace esphome::ethernet_info -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 912a39a83f3..11002d51bad 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -4,7 +4,7 @@ #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/ethernet/ethernet_component.h" -#ifdef USE_ESP32 +#ifdef USE_ETHERNET namespace esphome::ethernet_info { @@ -50,4 +50,4 @@ class MACAddressEthernetInfo final : public Component, public text_sensor::TextS } // namespace esphome::ethernet_info -#endif // USE_ESP32 +#endif // USE_ETHERNET diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 14cc1505ad8..300902b8cad 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -129,7 +129,9 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( ) -@automation.register_action("event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA) +@automation.register_action( + "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True +) async def event_fire_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index c1f72bb05d5..1538e303f12 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -81,7 +81,10 @@ EzoPMPArbitraryCommandAction = ezo_pmp_ns.class_( @automation.register_action( - "ezo_pmp.find", EzoPMPFindAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA + "ezo_pmp.find", + EzoPMPFindAction, + EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_find_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -92,6 +95,7 @@ async def ezo_pmp_find_to_code(config, action_id, template_arg, args): "ezo_pmp.dose_continuously", EzoPMPDoseContinuouslyAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_continuously_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -102,6 +106,7 @@ async def ezo_pmp_dose_continuously_to_code(config, action_id, template_arg, arg "ezo_pmp.clear_total_volume_dosed", EzoPMPClearTotalVolumeDispensedAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_clear_total_volume_dosed_to_code( config, action_id, template_arg, args @@ -114,6 +119,7 @@ async def ezo_pmp_clear_total_volume_dosed_to_code( "ezo_pmp.clear_calibration", EzoPMPClearCalibrationAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_clear_calibration_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -121,7 +127,10 @@ async def ezo_pmp_clear_calibration_to_code(config, action_id, template_arg, arg @automation.register_action( - "ezo_pmp.pause_dosing", EzoPMPPauseDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA + "ezo_pmp.pause_dosing", + EzoPMPPauseDosingAction, + EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_pause_dosing_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -129,7 +138,10 @@ async def ezo_pmp_pause_dosing_to_code(config, action_id, template_arg, args): @automation.register_action( - "ezo_pmp.stop_dosing", EzoPMPStopDosingAction, EZO_PMP_NO_ARGS_ACTION_SCHEMA + "ezo_pmp.stop_dosing", + EzoPMPStopDosingAction, + EZO_PMP_NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_stop_dosing_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -149,7 +161,10 @@ EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA = cv.All( @automation.register_action( - "ezo_pmp.dose_volume", EzoPMPDoseVolumeAction, EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA + "ezo_pmp.dose_volume", + EzoPMPDoseVolumeAction, + EZO_PMP_DOSE_VOLUME_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -178,6 +193,7 @@ EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA = cv.All( "ezo_pmp.dose_volume_over_time", EzoPMPDoseVolumeOverTimeAction, EZO_PMP_DOSE_VOLUME_OVER_TIME_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_volume_over_time_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -209,6 +225,7 @@ EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA = cv.All( "ezo_pmp.dose_with_constant_flow_rate", EzoPMPDoseWithConstantFlowRateAction, EZO_PMP_DOSE_WITH_CONSTANT_FLOW_RATE_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_dose_with_constant_flow_rate_to_code( config, action_id, template_arg, args @@ -239,6 +256,7 @@ EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA = cv.All( "ezo_pmp.set_calibration_volume", EzoPMPSetCalibrationVolumeAction, EZO_PMP_SET_CALIBRATION_VOLUME_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_set_calibration_volume_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -262,6 +280,7 @@ EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA = cv.All( "ezo_pmp.change_i2c_address", EzoPMPChangeI2CAddressAction, EZO_PMP_CHANGE_I2C_ADDRESS_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_change_i2c_address_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -285,6 +304,7 @@ EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA = cv.All( "ezo_pmp.arbitrary_command", EzoPMPArbitraryCommandAction, EZO_PMP_ARBITRARY_COMMAND_ACTION_SCHEMA, + synchronous=True, ) async def ezo_pmp_arbitrary_command_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index da28c577c8e..df71c6ab3fd 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -365,6 +365,7 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args): cv.Optional(CONF_OFF_SPEED_CYCLE, default=True): cv.boolean, } ), + synchronous=True, ) async def fan_cycle_speed_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 115b89433bc..2637097be81 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -261,6 +261,7 @@ async def to_code(config): }, key=CONF_FINGER_ID, ), + synchronous=True, ) async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -282,6 +283,7 @@ async def fingerprint_grow_enroll_to_code(config, action_id, template_arg, args) cv.GenerateID(): cv.use_id(FingerprintGrowComponent), } ), + synchronous=True, ) async def fingerprint_grow_cancel_enroll_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -299,6 +301,7 @@ async def fingerprint_grow_cancel_enroll_to_code(config, action_id, template_arg }, key=CONF_FINGER_ID, ), + synchronous=True, ) async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -317,6 +320,7 @@ async def fingerprint_grow_delete_to_code(config, action_id, template_arg, args) cv.GenerateID(): cv.use_id(FingerprintGrowComponent), } ), + synchronous=True, ) async def fingerprint_grow_delete_all_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -337,6 +341,7 @@ FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA = cv.maybe_simple_value( "fingerprint_grow.led_control", LEDControlAction, FINGERPRINT_GROW_LED_CONTROL_ACTION_SCHEMA, + synchronous=True, ) async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -359,6 +364,7 @@ async def fingerprint_grow_led_control_to_code(config, action_id, template_arg, cv.Required(CONF_COUNT): cv.templatable(cv.uint8_t), } ), + synchronous=True, ) async def fingerprint_grow_aura_led_control_to_code( config, action_id, template_arg, args diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 3db29bea356..520c068e6f4 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -84,7 +84,7 @@ template class RestoringGlobalStringComponent : public P this->rtc_ = global_preferences->make_preference(1944399030U ^ this->name_hash_); bool hasdata = this->rtc_.load(&temp); if (hasdata) { - this->value_.assign(temp + 1, temp[0]); + this->value_.assign(temp + 1, static_cast(temp[0])); } this->last_checked_value_.assign(this->value_); } diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index c43cd07fe08..801c97e3f56 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -171,7 +171,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo bool prev_b = false; int16_t prev_y = 0; for (uint32_t i = 0; i < this->width_; i++) { - float v = (trace->get_tracedata()->get_value(i) - ymin) / yrange; + float v = yrange != 0 ? (trace->get_tracedata()->get_value(i) - ymin) / yrange : NAN; if (!std::isnan(v) && (thick > 0)) { int16_t x = this->width_ - 1 - i + x_offset; uint8_t bit = 1 << ((i % (thick * LineType::PATTERN_LENGTH)) / thick); diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index 869c05387ff..210e2f7babb 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -72,6 +72,7 @@ async def to_code(config): cv.Required(CONF_DIRECTION): cv.enum(DIRECTION_TYPE, upper=True), } ), + synchronous=True, ) async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -96,6 +97,7 @@ async def grove_tb6612fng_run_to_code(config, action_id, template_arg, args): cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), } ), + synchronous=True, ) async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -115,6 +117,7 @@ async def grove_tb6612fng_break_to_code(config, action_id, template_arg, args): cv.Required(CONF_CHANNEL): cv.templatable(cv.int_range(min=0, max=1)), } ), + synchronous=True, ) async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -133,6 +136,7 @@ async def grove_tb6612fng_stop_to_code(config, action_id, template_arg, args): cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), } ), + synchronous=True, ) async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -149,6 +153,7 @@ async def grove_tb6612fng_standby_to_code(config, action_id, template_arg, args) cv.Required(CONF_ID): cv.use_id(GROVE_TB6612FNG), } ), + synchronous=True, ) async def grove_tb6612fng_no_standby_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -166,6 +171,7 @@ async def grove_tb6612fng_no_standby_to_code(config, action_id, template_arg, ar cv.Required(CONF_ADDRESS): cv.i2c_address, } ), + synchronous=True, ) async def grove_tb6612fng_change_address_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index 428c8ec4a8c..c10fa4cf257 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -139,7 +139,8 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps, } void GroveMotorDriveTB6612FNG::stepper_stop() { - if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_STOP, nullptr, 1) != i2c::ERROR_OK) { + uint8_t status = 0; + if (this->write_register(GROVE_MOTOR_DRIVER_I2C_CMD_STEPPER_STOP, &status, 1) != i2c::ERROR_OK) { ESP_LOGW(TAG, "Send stop stepper failed!"); this->status_set_warning(); return; diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index b0ddd4b99d8..833d6a36ddb 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -56,8 +56,8 @@ class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { } protected: - bool waiting_to_update_; - uint32_t last_send_; + bool waiting_to_update_{false}; + uint32_t last_send_{0}; struct GrowattPhase { sensor::Sensor *voltage_sensor_{nullptr}; diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 6c208f6caa4..caaaa18dd68 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -319,10 +319,16 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "climate.haier.display_on", DisplayOnAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.display_on", + DisplayOnAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.display_off", DisplayOffAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.display_off", + DisplayOffAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) async def display_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -330,10 +336,16 @@ async def display_action_to_code(config, action_id, template_arg, args): @automation.register_action( - "climate.haier.beeper_on", BeeperOnAction, HAIER_HON_BASE_ACTION_SCHEMA + "climate.haier.beeper_on", + BeeperOnAction, + HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.beeper_off", BeeperOffAction, HAIER_HON_BASE_ACTION_SCHEMA + "climate.haier.beeper_off", + BeeperOffAction, + HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) async def beeper_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -345,11 +357,13 @@ async def beeper_action_to_code(config, action_id, template_arg, args): "climate.haier.start_self_cleaning", StartSelfCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "climate.haier.start_steri_cleaning", StartSteriCleaningAction, HAIER_HON_BASE_ACTION_SCHEMA, + synchronous=True, ) async def start_cleaning_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -368,6 +382,7 @@ async def start_cleaning_to_code(config, action_id, template_arg, args): ), } ), + synchronous=True, ) async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -391,6 +406,7 @@ async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, ar ), } ), + synchronous=True, ) async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -403,10 +419,16 @@ async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, @automation.register_action( - "climate.haier.health_on", HealthOnAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.health_on", + HealthOnAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.health_off", HealthOffAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.health_off", + HealthOffAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) async def health_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -414,13 +436,22 @@ async def health_action_to_code(config, action_id, template_arg, args): @automation.register_action( - "climate.haier.power_on", PowerOnAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.power_on", + PowerOnAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.power_off", PowerOffAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.power_off", + PowerOffAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "climate.haier.power_toggle", PowerToggleAction, HAIER_BASE_ACTION_SCHEMA + "climate.haier.power_toggle", + PowerToggleAction, + HAIER_BASE_ACTION_SCHEMA, + synchronous=True, ) async def power_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index be5035caa17..b8889ef2bdc 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -1375,9 +1375,8 @@ void HonClimate::process_protocol_reset() { bool HonClimate::should_get_big_data_() { if (this->big_data_sensors_ > 0) { - static uint8_t counter = 0; - counter = (counter + 1) % 3; - return counter == 1; + this->big_data_counter_ = (this->big_data_counter_ + 1) % 3; + return this->big_data_counter_ == 1; } return false; } diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 4565ed2981d..9bddac3f923 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -188,6 +188,7 @@ class HonClimate : public HaierClimateBase { float active_alarm_count_{NAN}; std::chrono::steady_clock::time_point last_alarm_request_; int big_data_sensors_{0}; + uint8_t big_data_counter_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; HonSettings settings_{}; diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 7a86c1b6b7a..cae966a9656 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -52,6 +52,7 @@ CONFIG_SCHEMA = ( "fan.hbridge.brake", BrakeAction, maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), + synchronous=True, ) async def fan_hbridge_brake_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 2f39b47f3cd..29b428e3109 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -68,7 +68,10 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "hc8.calibrate", HC8CalibrateAction, CALIBRATION_ACTION_SCHEMA + "hc8.calibrate", + HC8CalibrateAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) async def hc8_calibration_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index 7215a4cfb7a..a6265b9b980 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -114,7 +114,10 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "hdc302x.heater_on", HeaterOnAction, HDC302X_HEATER_ON_ACTION_SCHEMA + "hdc302x.heater_on", + HeaterOnAction, + HDC302X_HEATER_ON_ACTION_SCHEMA, + synchronous=True, ) async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -127,7 +130,10 @@ async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): @automation.register_action( - "hdc302x.heater_off", HeaterOffAction, HDC302X_ACTION_SCHEMA + "hdc302x.heater_off", + HeaterOffAction, + HDC302X_ACTION_SCHEMA, + synchronous=True, ) async def hdc302x_heater_off_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 937e8cd473b..11f180857bb 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -114,7 +114,7 @@ void HeatpumpIRClimate::setup() { this->current_temperature = state; IRSenderESPHome esp_sender(this->transmitter_); - this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature + 0.5))); + this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature))); // current temperature changed, publish state this->publish_state(); diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index c34f033d926..0877b832617 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -96,7 +96,7 @@ class HitachiClimate : public climate_ir::ClimateIR { void set_power_(bool on); uint8_t get_mode_(); void set_mode_(uint8_t mode); - void set_temp_(uint8_t celsius, bool set_previous = false); + void set_temp_(uint8_t celsius, bool set_previous = true); uint8_t get_fan_(); void set_fan_(uint8_t speed); void set_swing_v_toggle_(bool on); diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index efd64b65139..cb6d5cdfd6d 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -170,6 +170,7 @@ async def to_code(config): }, key=CONF_NAME, ), + synchronous=True, ) async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -192,6 +193,7 @@ async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): }, key=CONF_FACE_ID, ), + synchronous=True, ) async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -210,6 +212,7 @@ async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), + synchronous=True, ) async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -225,6 +228,7 @@ async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), + synchronous=True, ) async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -240,6 +244,7 @@ async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(HlkFm22xComponent), } ), + synchronous=True, ) async def hlk_fm22x_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 7c7c8782dee..7a0dc0690cc 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -6,6 +6,7 @@ namespace esphome::hlk_fm22x { static const char *const TAG = "hlk_fm22x"; +static constexpr uint32_t PAYLOAD_TIMEOUT_MS = 20; void HlkFm22xComponent::setup() { ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X..."); @@ -133,6 +134,21 @@ void HlkFm22xComponent::recv_command_() { checksum ^= byte; length |= byte; + // Wait for remaining data (payload + checksum) to arrive. + // Header bytes are already consumed, so we must finish reading this message. + uint32_t start = millis(); + while (this->available() < length + 1) { + if (millis() - start > PAYLOAD_TIMEOUT_MS) { + ESP_LOGE(TAG, "Timeout waiting for payload (%u bytes)", length); + // Drain any partial payload bytes to resync the parser + while (this->available() > 0) { + this->read(); + } + return; + } + delay(1); + } + // Read up to buffer size; discard excess bytes while still computing checksum // GET_ALL_FACE_IDS can return all enrolled face data (hundreds of bytes) // but handlers only need the first few bytes diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp index 2146e961bc9..c113cb48a6f 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.cpp +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -7,7 +7,55 @@ namespace esphome::hmac_sha256 { constexpr size_t SHA256_DIGEST_SIZE = 32; -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_HMAC_SHA256_PSA) + +// ESP-IDF 6.0 ships mbedtls 4.0 which removed the legacy mbedtls_md HMAC API. +// Use the PSA Crypto MAC API instead. + +HmacSHA256::~HmacSHA256() { + psa_mac_abort(&this->op_); + psa_destroy_key(this->key_id_); +} + +void HmacSHA256::init(const uint8_t *key, size_t len) { + psa_mac_abort(&this->op_); + psa_destroy_key(this->key_id_); + + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_HMAC); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_SIGN_MESSAGE); + psa_set_key_algorithm(&attributes, PSA_ALG_HMAC(PSA_ALG_SHA_256)); + psa_import_key(&attributes, key, len, &this->key_id_); + + this->op_ = PSA_MAC_OPERATION_INIT; + psa_mac_sign_setup(&this->op_, this->key_id_, PSA_ALG_HMAC(PSA_ALG_SHA_256)); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { psa_mac_update(&this->op_, data, len); } + +void HmacSHA256::calculate() { + size_t mac_length; + psa_mac_sign_finish(&this->op_, this->digest_, sizeof(this->digest_), &mac_length); +} + +void HmacSHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, SHA256_DIGEST_SIZE); } + +void HmacSHA256::get_hex(char *output) { + format_hex_to(output, SHA256_DIGEST_SIZE * 2 + 1, this->digest_, SHA256_DIGEST_SIZE); +} + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { + return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0; +} + +bool HmacSHA256::equals_hex(const char *expected) { + char hex_output[SHA256_DIGEST_SIZE * 2 + 1]; + this->get_hex(hex_output); + hex_output[SHA256_DIGEST_SIZE * 2] = '\0'; + return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0; +} + +#elif defined(USE_HMAC_SHA256_MBEDTLS) HmacSHA256::~HmacSHA256() { mbedtls_md_free(&this->ctx_); } @@ -93,7 +141,7 @@ bool HmacSHA256::equals_bytes(const uint8_t *expected) { return this->ohash_.equ bool HmacSHA256::equals_hex(const char *expected) { return this->ohash_.equals_hex(expected); } -#endif // USE_ESP32 || USE_LIBRETINY +#endif // USE_HMAC_SHA256_PSA / USE_HMAC_SHA256_MBEDTLS } // namespace esphome::hmac_sha256 #endif diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h index 85622cac46b..22129b1182f 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.h +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -5,7 +5,19 @@ #include -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls_md HMAC API. +// Use the PSA Crypto MAC API instead. +#define USE_HMAC_SHA256_PSA +#include +#else +#define USE_HMAC_SHA256_MBEDTLS +#include "mbedtls/md.h" +#endif +#elif defined(USE_LIBRETINY) +#define USE_HMAC_SHA256_MBEDTLS #include "mbedtls/md.h" #else #include "esphome/components/sha256/sha256.h" @@ -45,7 +57,12 @@ class HmacSHA256 { bool equals_hex(const char *expected); protected: -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_HMAC_SHA256_PSA) + static constexpr size_t SHA256_DIGEST_SIZE = 32; + psa_mac_operation_t op_ = PSA_MAC_OPERATION_INIT; + mbedtls_svc_key_id_t key_id_ = MBEDTLS_SVC_KEY_ID_INIT; + uint8_t digest_[SHA256_DIGEST_SIZE]{}; +#elif defined(USE_HMAC_SHA256_MBEDTLS) static constexpr size_t SHA256_DIGEST_SIZE = 32; mbedtls_md_context_t ctx_{}; uint8_t digest_[SHA256_DIGEST_SIZE]{}; diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index 8eae0f7a50c..b5cf93e62b8 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -61,7 +61,7 @@ class HMC5883LComponent : public PollingComponent, public i2c::I2CDevice { NONE = 0, COMMUNICATION_FAILED, ID_REGISTERS, - } error_code_; + } error_code_{NONE}; HighFrequencyLoopRequester high_freq_; }; diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.h b/esphome/components/honeywellabp2_i2c/honeywellabp2.h index 274de847ac7..d29ebb855dd 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.h +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.h @@ -45,8 +45,8 @@ class HONEYWELLABP2Sensor : public PollingComponent, public i2c::I2CDevice { const float max_count_b_ = 11744051.2; // (70% of 2^24 counts or 0xB33333) const float min_count_b_ = 5033164.8; // (30% of 2^24 counts or 0x4CCCCC) - float max_count_; - float min_count_; + float max_count_{max_count_a_}; + float min_count_{min_count_a_}; bool measurement_running_ = false; uint8_t raw_data_[7]; // holds output data diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index b47daf9157c..7f29885f49d 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -18,8 +18,8 @@ class HTE501Component : public PollingComponent, public i2c::I2CDevice { void update() override; protected: - sensor::Sensor *temperature_sensor_; - sensor::Sensor *humidity_sensor_; + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; enum ErrorCode { NONE = 0, COMMUNICATION_FAILED, CRC_CHECK_FAILED } error_code_{NONE}; }; diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 81337ebdf6e..416432cfc44 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -279,13 +279,22 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend( @automation.register_action( - "http_request.get", HttpRequestSendAction, HTTP_REQUEST_GET_ACTION_SCHEMA + "http_request.get", + HttpRequestSendAction, + HTTP_REQUEST_GET_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "http_request.post", HttpRequestSendAction, HTTP_REQUEST_POST_ACTION_SCHEMA + "http_request.post", + HttpRequestSendAction, + HTTP_REQUEST_POST_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "http_request.send", HttpRequestSendAction, HTTP_REQUEST_SEND_ACTION_SCHEMA + "http_request.send", + HttpRequestSendAction, + HTTP_REQUEST_SEND_ACTION_SCHEMA, + synchronous=True, ) async def http_request_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index d2c574d8c61..fb59e51943f 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -70,6 +70,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( "ota.http_request.flash", OtaHttpRequestComponentFlashAction, OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA, + synchronous=True, ) async def ota_http_request_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index a578670e375..92c088a22fa 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -93,6 +93,7 @@ async def to_code(config): }, key=CONF_LEVEL, ), + synchronous=True, ) async def set_heater_level_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -112,6 +113,7 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): }, key=CONF_STATUS, ), + synchronous=True, ) async def set_heater_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index f1e6ef42438..4f62ce7a94a 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -652,6 +652,7 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_BRIGHTNESS, ), + synchronous=True, ) async def hub75_set_brightness_to_code( config: ConfigType, diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index de3f2be6740..1684f479ba3 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -93,11 +93,31 @@ def _bus_declare_type(value): raise NotImplementedError +def _rp2040_i2c_controller(pin): + """Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin. + + See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"): + https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf + See RP2350 datasheet Table 7 (section 9.4, "Function Select"): + https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf + """ + return (pin // 2) % 2 + + def validate_config(config): if CORE.is_esp32: return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) )(config) + if CORE.is_rp2040: + sda_controller = _rp2040_i2c_controller(config[CONF_SDA]) + scl_controller = _rp2040_i2c_controller(config[CONF_SCL]) + if sda_controller != scl_controller: + raise cv.Invalid( + f"SDA pin GPIO{config[CONF_SDA]} is on I2C{sda_controller} but " + f"SCL pin GPIO{config[CONF_SCL]} is on I2C{scl_controller}. " + f"Both pins must be on the same I2C controller." + ) return config @@ -146,6 +166,23 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") + if CORE.is_rp2040: + if len(full_config) > 2: + raise cv.Invalid( + "The maximum number of I2C interfaces for RP2040/RP2350 is 2" + ) + if len(full_config) > 1: + controllers = [ + _rp2040_i2c_controller(conf[CONF_SDA]) for conf in full_config + ] + if len(set(controllers)) != len(controllers): + raise cv.Invalid( + "Multiple I2C buses are configured to use the same I2C controller. " + "Each bus must use pins on a different controller. " + "The I2C controller is determined by (gpio / 2) % 2: " + "even pin pairs (0-1, 4-5, 8-9, ...) use I2C0, " + "odd pin pairs (2-3, 6-7, 10-11, ...) use I2C1." + ) if CORE.is_esp32 and get_esp32_variant() in ESP32_I2C_CAPABILITIES: variant = get_esp32_variant() max_num = ESP32_I2C_CAPABILITIES[variant]["NUM"] diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 5120eb4c007..47a06abe9ec 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -20,12 +20,14 @@ void ArduinoI2CBus::setup() { #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) #elif defined(USE_RP2040) - static bool first = true; - if (first) { + // Select Wire instance based on pin assignment, not definition order. + // I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1 + // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf + // RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf + if ((this->sda_pin_ / 2) % 2 == 0) { wire_ = &Wire; - first = false; } else { - wire_ = &Wire1; // NOLINT(cppcoreguidelines-owning-memory) + wire_ = &Wire1; } #endif diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index dd23673db55..bf583b9f818 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -46,6 +46,12 @@ def _validate_esp32_variant(config): if config[CONF_ADC_TYPE] == "external": if config[CONF_PDM] and variant not in PDM_VARIANTS: raise cv.Invalid(f"{variant} does not support PDM") + if ( + variant == esp32.VARIANT_ESP32 + and config.get(CONF_BITS_PER_SAMPLE) == 8 + and config.get(CONF_CHANNEL) in (CONF_LEFT, CONF_RIGHT) + ): + raise cv.Invalid("8-bit mono mode is not supported on ESP32") return config if config[CONF_ADC_TYPE] == "internal": if variant not in INTERNAL_ADC_VARIANTS: diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index cdebc214e2c..eb4506071e6 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -281,7 +281,7 @@ bool I2SAudioMicrophone::start_driver_() { } /* Before reading data, start the RX channel first */ - i2s_channel_enable(this->rx_handle_); + err = i2s_channel_enable(this->rx_handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "Enabling failed: %s", esp_err_to_name(err)); return false; @@ -454,13 +454,14 @@ size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_w } this->status_clear_warning(); #if defined(USE_ESP32_VARIANT_ESP32) and not defined(USE_I2S_LEGACY) - // For ESP32 8/16 bit standard mono mode samples need to be switched. - if (this->slot_mode_ == I2S_SLOT_MODE_MONO && this->slot_bit_width_ <= 16 && !this->pdm_) { - size_t samples_read = bytes_read / sizeof(int16_t); - for (int i = 0; i < samples_read; i += 2) { - int16_t tmp = buf[i]; - buf[i] = buf[i + 1]; - buf[i + 1] = tmp; + // For ESP32 16-bit standard mono mode, adjacent samples need to be swapped. + if (this->slot_mode_ == I2S_SLOT_MODE_MONO && this->slot_bit_width_ == I2S_SLOT_BIT_WIDTH_16BIT && !this->pdm_) { + int16_t *samples = reinterpret_cast(buf); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; } } #endif diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 2e009a1de18..b84cf7de3b6 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -100,11 +100,16 @@ def _set_stream_limits(config): def _validate_esp32_variant(config): - if config[CONF_DAC_TYPE] != "internal": - return config variant = esp32.get_esp32_variant() - if variant not in INTERNAL_DAC_VARIANTS: - raise cv.Invalid(f"{variant} does not have an internal DAC") + if config[CONF_DAC_TYPE] == "internal": + if variant not in INTERNAL_DAC_VARIANTS: + raise cv.Invalid(f"{variant} does not have an internal DAC") + elif ( + variant == esp32.VARIANT_ESP32 + and config.get(CONF_BITS_PER_SAMPLE) == 8 + and config.get(CONF_CHANNEL) in (CONF_MONO, CONF_LEFT, CONF_RIGHT) + ): + raise cv.Invalid("8-bit mono mode is not supported on ESP32") return config diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index c934d12d652..a996702f8bb 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -372,15 +372,15 @@ void I2SAudioSpeaker::speaker_task(void *params) { } #ifdef USE_ESP32_VARIANT_ESP32 - // For ESP32 8/16 bit mono mode samples need to be switched. + // For ESP32 16-bit mono mode, adjacent samples need to be swapped. if (this_speaker->current_stream_info_.get_channels() == 1 && - this_speaker->current_stream_info_.get_bits_per_sample() <= 16) { - size_t len = bytes_read / sizeof(int16_t); - int16_t *tmp_buf = (int16_t *) new_data; - for (size_t i = 0; i < len; i += 2) { - int16_t tmp = tmp_buf[i]; - tmp_buf[i] = tmp_buf[i + 1]; - tmp_buf[i + 1] = tmp; + this_speaker->current_stream_info_.get_bits_per_sample() == 16) { + int16_t *samples = reinterpret_cast(new_data); + size_t sample_count = bytes_read / sizeof(int16_t); + for (size_t i = 0; i + 1 < sample_count; i += 2) { + int16_t tmp = samples[i]; + samples[i] = samples[i + 1]; + samples[i + 1] = tmp; } } #endif diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 9f510eef74f..2d08562e542 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -572,9 +572,8 @@ bool INA2XX::write_unsigned_16_(uint8_t reg, uint16_t val) { } bool INA2XX::read_unsigned_(uint8_t reg, uint8_t reg_size, uint64_t &data_out) { - static uint8_t rx_buf[5] = {0}; // max buffer size - - if (reg_size > 5) { + uint8_t rx_buf[5]{}; + if (reg_size > sizeof(rx_buf)) { return false; } diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 26766385565..d0aae4201e4 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -111,6 +111,7 @@ async def to_code(config): cv.Required(CONF_ID): cv.use_id(IntegrationSensor), } ), + synchronous=True, ) async def sensor_integration_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -127,6 +128,7 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), + synchronous=True, ) async def sensor_integration_set_value_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 29de6512559..9f2557243c8 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -110,9 +110,17 @@ void KamstrupKMPComponent::send_message_(const uint8_t *msg, int msg_len) { for (int i = 0; i < buffer_len; i++) { if (buffer[i] == 0x06 || buffer[i] == 0x0d || buffer[i] == 0x1b || buffer[i] == 0x40 || buffer[i] == 0x80) { + if (tx_msg_len + 2 >= static_cast(sizeof(tx_msg))) { + ESP_LOGE(TAG, "TX message overflow"); + return; + } tx_msg[tx_msg_len++] = 0x1b; tx_msg[tx_msg_len++] = buffer[i] ^ 0xff; } else { + if (tx_msg_len + 1 >= static_cast(sizeof(tx_msg))) { + ESP_LOGE(TAG, "TX message overflow"); + return; + } tx_msg[tx_msg_len++] = buffer[i]; } } @@ -216,8 +224,8 @@ void KamstrupKMPComponent::parse_command_message_(uint16_t command, const uint8_ uint8_t unit_idx = msg[4]; uint8_t mantissa_range = msg[5]; - if (mantissa_range > 4) { - ESP_LOGE(TAG, "Received invalid message (mantissa size too large %d, expected 4)", mantissa_range); + if (mantissa_range > 4 || msg_len < 7 + mantissa_range) { + ESP_LOGE(TAG, "Received invalid message (mantissa size %d, msg_len %d)", mantissa_range, msg_len); return; } diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index badb28c32ca..1f4519df2d0 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -142,6 +142,7 @@ async def to_code(config): cv.GenerateID(): cv.use_id(KeyCollector), } ), + synchronous=True, ) async def enable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -157,6 +158,7 @@ async def enable_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(KeyCollector), } ), + synchronous=True, ) async def disable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index b492bbcd149..360e56330af 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -97,7 +97,10 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( @automation.register_action( - "bluetooth_password.set", BluetoothPasswordSetAction, BLUETOOTH_PASSWORD_SET_SCHEMA + "bluetooth_password.set", + BluetoothPasswordSetAction, + BLUETOOTH_PASSWORD_SET_SCHEMA, + synchronous=True, ) async def bluetooth_password_set_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index ef0915d0bc0..37578dd8daf 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -413,24 +413,29 @@ void LD2412Component::handle_periodic_data_() { this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance); } if (engineering_mode) { - /* - Moving distance range: 18th byte - Still distance range: 19th byte - Moving energy: 20~28th bytes - */ - for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + // Engineering mode needs at least LIGHT_SENSOR + 1 bytes + if (this->buffer_pos_ < LIGHT_SENSOR + 1) { + ESP_LOGW(TAG, "Engineering mode packet too short: %u", this->buffer_pos_); + } else { + /* + Moving distance range: 18th byte + Still distance range: 19th byte + Moving energy: 20~28th bytes + */ + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]) + } + /* + Still energy: 29~37th bytes + */ + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) + } + /* + Light sensor value + */ + SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) } - /* - Still energy: 29~37th bytes - */ - for (uint8_t i = 0; i < TOTAL_GATES; i++) { - SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]) - } - /* - Light sensor: 38th bytes - */ - SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]) } else { for (auto &gate_move_sensor : this->gate_move_sensors_) { SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index eb17cc7de70..f9701cbdf66 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -133,7 +133,7 @@ static constexpr uint8_t DATA_FRAME_FOOTER[2] = {0x55, 0xCC}; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; -static inline uint16_t convert_seconds_to_ms(uint16_t value) { return value * 1000; }; +static inline uint32_t convert_seconds_to_ms(uint16_t value) { return (uint32_t) value * 1000; }; static inline void convert_int_values_to_hex(const int *values, uint8_t *bytes) { for (uint8_t i = 0; i < 4; i++) { diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 39b0ebd9da0..9409dfc21df 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -168,7 +168,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t presence_millis_ = 0; uint32_t still_presence_millis_ = 0; uint32_t moving_presence_millis_ = 0; - uint16_t timeout_ = 5; + uint32_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 21e06822575..a3d1e4d3922 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -5,6 +5,9 @@ #include #include +#include +#include +#include #define CLOCK_FREQUENCY 80e6f @@ -16,10 +19,10 @@ static const uint8_t SETUP_ATTEMPT_COUNT_MAX = 5; -namespace esphome { -namespace ledc { +namespace esphome::ledc { static const char *const TAG = "ledc.output"; +static bool ledc_peripheral_reset_done = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const int MAX_RES_BITS = LEDC_TIMER_BIT_MAX - 1; #if SOC_LEDC_SUPPORT_HS_MODE @@ -32,6 +35,28 @@ inline ledc_mode_t get_speed_mode(uint8_t channel) { return channel < 8 ? LEDC_H inline ledc_mode_t get_speed_mode(uint8_t) { return LEDC_LOW_SPEED_MODE; } #endif +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) +// Classic ESP32 (currently the only target without SOC_LEDC_SUPPORT_FADE_STOP) can block in +// ledc_ll_set_duty_start() while duty_start is set. We check the same conf1.duty_start bit here +// to defer updates and avoid entering IDF's unbounded wait loop. +// +// This intentionally depends on the classic ESP32 LEDC register layout used by IDF's own LL HAL. +// If another target without SOC_LEDC_SUPPORT_FADE_STOP is introduced, revisit this helper. +static_assert( +#if defined(CONFIG_IDF_TARGET_ESP32) + true, +#else + false, +#endif + "LEDC duty_start pending check assumes classic ESP32 register layout; " + "re-evaluate for this target"); + +static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { + auto *hw = LEDC_LL_GET_HW(); + return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; +} +#endif + float ledc_max_frequency_for_bit_depth(uint8_t bit_depth) { return static_cast(CLOCK_FREQUENCY) / static_cast(1 << bit_depth); } @@ -76,6 +101,9 @@ esp_err_t configure_timer_frequency(ledc_mode_t speed_mode, ledc_timer_t timer_n init_result = ledc_timer_config(&timer_conf); if (init_result != ESP_OK) { ESP_LOGW(TAG, "Unable to initialize timer with frequency %.1f and bit depth of %u", frequency, bit_depth); + if (bit_depth <= 1) { + break; + } // try again with a lower bit depth timer_conf.duty_resolution = static_cast(--bit_depth); } @@ -102,21 +130,47 @@ void LEDCOutput::write_state(float state) { const uint32_t max_duty = (uint32_t(1) << this->bit_depth_) - 1; const float duty_rounded = roundf(state * max_duty); auto duty = static_cast(duty_rounded); + if (duty == this->last_duty_) { + return; + } + ESP_LOGV(TAG, "Setting duty: %" PRIu32 " on channel %u", duty, this->channel_); auto speed_mode = get_speed_mode(this->channel_); auto chan_num = static_cast(this->channel_ % 8); int hpoint = ledc_angle_to_htop(this->phase_angle_, this->bit_depth_); if (duty == max_duty) { ledc_stop(speed_mode, chan_num, 1); + this->last_duty_ = duty; } else if (duty == 0) { ledc_stop(speed_mode, chan_num, 0); + this->last_duty_ = duty; } else { +#if !defined(SOC_LEDC_SUPPORT_FADE_STOP) + if (ledc_duty_update_pending(speed_mode, chan_num)) { + ESP_LOGV(TAG, "Skipping LEDC duty update on channel %u while previous duty_start is still set", this->channel_); + return; + } +#endif ledc_set_duty_with_hpoint(speed_mode, chan_num, duty, hpoint); ledc_update_duty(speed_mode, chan_num); + this->last_duty_ = duty; } } void LEDCOutput::setup() { + if (!ledc_peripheral_reset_done) { + ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + PERIPH_RCC_ATOMIC() { + ledc_ll_enable_reset_reg(true); + ledc_ll_enable_reset_reg(false); + } +#else + periph_module_reset(PERIPH_LEDC_MODULE); +#endif + ledc_peripheral_reset_done = true; + } + auto speed_mode = get_speed_mode(this->channel_); auto timer_num = static_cast((this->channel_ % 8) / 2); auto chan_num = static_cast(this->channel_ % 8); @@ -204,12 +258,12 @@ void LEDCOutput::update_frequency(float frequency) { this->status_clear_error(); // re-apply duty + this->last_duty_ = UINT32_MAX; this->write_state(this->duty_); } uint8_t next_ledc_channel = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace ledc -} // namespace esphome +} // namespace esphome::ledc #endif diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index b24e3cfdb23..bf5cdb93055 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -4,11 +4,11 @@ #include "esphome/core/hal.h" #include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" +#include #ifdef USE_ESP32 -namespace esphome { -namespace ledc { +namespace esphome::ledc { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern uint8_t next_ledc_channel; @@ -39,6 +39,7 @@ class LEDCOutput : public output::FloatOutput, public Component { float phase_angle_{0.0f}; float frequency_{}; float duty_{0.0f}; + uint32_t last_duty_{UINT32_MAX}; bool initialized_ = false; }; @@ -56,7 +57,6 @@ template class SetFrequencyAction : public Action { LEDCOutput *parent_; }; -} // namespace ledc -} // namespace esphome +} // namespace esphome::ledc #endif diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index 7a45b9dc3f6..62ff5ad30ac 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -77,6 +77,7 @@ async def to_code(config): cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), + synchronous=True, ) async def ledc_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/libretiny/helpers.cpp b/esphome/components/libretiny/helpers.cpp index 37ae0fb455a..ffbd181c54c 100644 --- a/esphome/components/libretiny/helpers.cpp +++ b/esphome/components/libretiny/helpers.cpp @@ -15,20 +15,12 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } -Mutex::Mutex() { handle_ = xSemaphoreCreateMutex(); } -Mutex::~Mutex() {} -void Mutex::lock() { xSemaphoreTake(this->handle_, portMAX_DELAY); } -bool Mutex::try_lock() { return xSemaphoreTake(this->handle_, 0) == pdTRUE; } -void Mutex::unlock() { xSemaphoreGive(this->handle_); } - // only affects the executing core // so should not be used as a mutex lock, only to get accurate timing IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); } -// LibreTiny doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// LibreTiny LwIPLock is defined inline as a no-op in helpers.h void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) WiFi.macAddress(mac); diff --git a/esphome/components/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 28556514d89..e812b6a8f25 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -38,6 +38,7 @@ async def to_code(config): cv.Required(CONF_FREQUENCY): cv.templatable(cv.int_), } ), + synchronous=True, ) async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/light/addressable_light_effect.h b/esphome/components/light/addressable_light_effect.h index 461ddbc085a..283b037acad 100644 --- a/esphome/components/light/addressable_light_effect.h +++ b/esphome/components/light/addressable_light_effect.h @@ -324,6 +324,8 @@ class AddressableFireworksEffect : public AddressableLightEffect { target *= 170; view = target; } + if (it.size() < 2) + return; int last = it.size() - 1; it[0].set(it[0].get() + (it[1].get() * 128)); for (int i = 1; i < last; i++) { diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 08fd26a9379..55273003b95 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -278,7 +278,10 @@ LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA = cv.Schema( @automation.register_action( - "light.addressable_set", AddressableSet, LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA + "light.addressable_set", + AddressableSet, + LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA, + synchronous=True, ) async def light_addressable_set_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 14cd0e92f69..0b2d391fd6e 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -214,7 +214,14 @@ LightColorValues LightCall::validate_() { if (this->has_brightness() && this->brightness_ == 0.0f) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE); - this->brightness_ = 1.0f; + if (color_mode & ColorCapability::BRIGHTNESS) { + // Reset brightness so the light has nonzero brightness when turned back on. + this->brightness_ = 1.0f; + } else { + // Light doesn't support brightness; clear the flag to avoid a spurious + // "brightness not supported" warning during capability validation. + this->clear_flag_(FLAG_HAS_BRIGHTNESS); + } } // Set color brightness to 100% if currently zero and a color is set. @@ -506,7 +513,7 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() { LightCall &LightCall::set_effect(const char *effect, size_t len) { if (len == 4 && strncasecmp(effect, "none", 4) == 0) { - this->set_effect(0); + this->set_effect(uint32_t{0}); return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 0926ab6108e..0eb1785239c 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -130,6 +130,8 @@ class LightCall { LightCall &set_effect(optional effect); /// Set the effect of the light by its name. LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } + /// Set the effect of the light by its name (const char * overload to resolve ambiguity). + LightCall &set_effect(const char *effect) { return this->set_effect(effect, strlen(effect)); } /// Set the effect of the light by its name and length (zero-copy from API). LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 802b341601b..acbbbb4de97 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -55,6 +55,7 @@ LIGHTWAVE_SEND_SCHEMA = cv.Any( "lightwaverf.send_raw", LightwaveRawAction, LIGHTWAVE_SEND_SCHEMA, + synchronous=True, ) async def send_raw_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index e37092756fb..fe4db23ae3a 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -129,9 +129,15 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA) -@automation.register_action("lock.lock", LockAction, LOCK_ACTION_SCHEMA) -@automation.register_action("lock.open", OpenAction, LOCK_ACTION_SCHEMA) +@automation.register_action( + "lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "lock.lock", LockAction, LOCK_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True +) async def lock_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 026b8aaf246..675f9a2ca4b 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -337,6 +337,20 @@ async def to_code(config): ) if CORE.is_esp32: cg.add(log.create_pthread_key()) + # set_uart_selection() must be called before pre_setup() because + # pre_setup() switches on uart_ to decide which hardware to initialize + # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the + # default UART_SELECTION_UART0 and the wrong hardware gets initialized. + if CONF_HARDWARE_UART in config: + cg.add( + log.set_uart_selection( + HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] + ) + ) + # pre_setup() must be called before init_log_buffer() because + # init_log_buffer() calls disable_loop() which may log at VV level, + # and global_logger must be set before any logging occurs. + cg.add(log.pre_setup()) if CORE.is_esp32 or CORE.is_libretiny or CORE.is_nrf52: task_log_buffer_size = config[CONF_TASK_LOG_BUFFER_SIZE] if task_log_buffer_size > 0: @@ -350,13 +364,6 @@ async def to_code(config): cg.add(log.init_log_buffer(64)) # Fixed 64 slots for host cg.add(log.set_log_level(initial_level)) - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) - cg.add(log.pre_setup()) # Enable runtime tag levels if logs are configured or explicitly enabled logs_config = config[CONF_LOGS] @@ -545,6 +552,7 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): }, key=CONF_LEVEL, ), + synchronous=True, ) async def logger_set_level_to_code(config, action_id, template_arg, args): level = LOG_LEVELS[config[CONF_LEVEL]] diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index d6ad77ff4fa..f5bf7822899 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -1,6 +1,7 @@ #ifdef USE_ESP32 #include "logger.h" +#include "esphome/components/esp32/crash_handler.h" #include #include @@ -117,6 +118,9 @@ void Logger::pre_setup() { esp_log_set_vprintf(esp_idf_log_vprintf_); ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_ESP32_CRASH_HANDLER + esp32::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index 1f435031f61..b7225c2a258 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -1,5 +1,9 @@ #ifdef USE_RP2040 #include "logger.h" +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "esphome/components/rp2040/crash_handler.h" +#endif #include "esphome/core/log.h" namespace esphome::logger { @@ -25,6 +29,9 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_log(); +#endif } void HOT Logger::write_msg_(const char *msg, uint16_t len) { diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index f9c1474c85a..ff335fe34c6 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -137,7 +137,6 @@ void LTRAlsPsComponent::update() { void LTRAlsPsComponent::loop() { ErrorCode err = i2c::ERROR_OK; - static uint8_t tries{0}; switch (this->state_) { case State::DELAYED_SETUP: @@ -166,20 +165,20 @@ void LTRAlsPsComponent::loop() { case State::WAITING_FOR_DATA: if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) { - tries = 0; + this->read_data_tries_ = 0; ESP_LOGV(TAG, "Reading sensor data having gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time)); this->read_sensor_data_(this->als_readings_); this->state_ = State::DATA_COLLECTED; this->apply_lux_calculation_(this->als_readings_); - } else if (tries >= MAX_TRIES) { + } else if (this->read_data_tries_ >= MAX_TRIES) { ESP_LOGW(TAG, "Can't get data after several tries."); - tries = 0; + this->read_data_tries_ = 0; this->status_set_warning(); this->state_ = State::IDLE; return; } else { - tries++; + this->read_data_tries_++; } break; @@ -221,21 +220,21 @@ void LTRAlsPsComponent::loop() { } void LTRAlsPsComponent::check_and_trigger_ps_() { - static uint32_t last_high_trigger_time{0}; - static uint32_t last_low_trigger_time{0}; uint16_t ps_data = this->read_ps_data_(); uint32_t now = millis(); if (ps_data != this->ps_readings_) { this->ps_readings_ = ps_data; // Higher values - object is closer to sensor - if (ps_data > this->ps_threshold_high_ && now - last_high_trigger_time >= this->ps_cooldown_time_s_ * 1000) { - last_high_trigger_time = now; + if (ps_data > this->ps_threshold_high_ && + now - this->last_ps_high_trigger_time_ >= this->ps_cooldown_time_s_ * 1000) { + this->last_ps_high_trigger_time_ = now; ESP_LOGV(TAG, "Proximity high threshold triggered. Value = %d, Trigger level = %d", ps_data, this->ps_threshold_high_); this->on_ps_high_trigger_callback_.call(); - } else if (ps_data < this->ps_threshold_low_ && now - last_low_trigger_time >= this->ps_cooldown_time_s_ * 1000) { - last_low_trigger_time = now; + } else if (ps_data < this->ps_threshold_low_ && + now - this->last_ps_low_trigger_time_ >= this->ps_cooldown_time_s_ * 1000) { + this->last_ps_low_trigger_time_ = now; ESP_LOGV(TAG, "Proximity low threshold triggered. Value = %d, Trigger level = %d", ps_data, this->ps_threshold_low_); this->on_ps_low_trigger_callback_.call(); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.h b/esphome/components/ltr_als_ps/ltr_als_ps.h index c6052300de8..3ab2cea074f 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.h +++ b/esphome/components/ltr_als_ps/ltr_als_ps.h @@ -126,10 +126,13 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { MeasurementRepeatRate repeat_rate_{MeasurementRepeatRate::REPEAT_RATE_500MS}; float glass_attenuation_factor_{1.0}; + uint32_t last_ps_high_trigger_time_{0}; + uint32_t last_ps_low_trigger_time_{0}; uint16_t ps_cooldown_time_s_{5}; - PsGain ps_gain_{PsGain::PS_GAIN_16}; uint16_t ps_threshold_high_{0xffff}; uint16_t ps_threshold_low_{0x0000}; + uint8_t read_data_tries_{0}; + PsGain ps_gain_{PsGain::PS_GAIN_16}; // // Sensors for publishing data diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index b589e42f3b7..f9adca9c337 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -182,6 +182,7 @@ async def disp_update(disp, config: dict): ), LVGL_SCHEMA, ), + synchronous=True, ) async def obj_invalidate_to_code(config, action_id, template_arg, args): if CONF_LVGL_ID in config: @@ -202,6 +203,7 @@ async def obj_invalidate_to_code(config, action_id, template_arg, args): DISP_BG_SCHEMA.extend(LVGL_SCHEMA).add_extra( cv.has_at_least_one_key(CONF_DISP_BG_COLOR, CONF_DISP_BG_IMAGE) ), + synchronous=True, ) async def lvgl_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config, CONF_LVGL_ID) @@ -222,6 +224,7 @@ async def lvgl_update_to_code(config, action_id, template_arg, args): cv.Optional(CONF_SHOW_SNOW, default=False): lv_bool, } ), + synchronous=True, ) async def pause_action_to_code(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) @@ -237,6 +240,7 @@ async def pause_action_to_code(config, action_id, template_arg, args): "lvgl.resume", LvglAction, LVGL_SCHEMA, + synchronous=True, ) async def resume_action_to_code(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) @@ -247,7 +251,9 @@ async def resume_action_to_code(config, action_id, template_arg, args): return var -@automation.register_action("lvgl.widget.disable", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.disable", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_disable_to_code(config, action_id, template_arg, args): async def do_disable(widget: Widget): widget.add_state(LV_STATE.DISABLED) @@ -257,7 +263,9 @@ async def obj_disable_to_code(config, action_id, template_arg, args): ) -@automation.register_action("lvgl.widget.enable", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.enable", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_enable_to_code(config, action_id, template_arg, args): async def do_enable(widget: Widget): widget.clear_state(LV_STATE.DISABLED) @@ -267,7 +275,9 @@ async def obj_enable_to_code(config, action_id, template_arg, args): ) -@automation.register_action("lvgl.widget.hide", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.hide", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_hide_to_code(config, action_id, template_arg, args): async def do_hide(widget: Widget): widget.add_flag("LV_OBJ_FLAG_HIDDEN") @@ -276,7 +286,9 @@ async def obj_hide_to_code(config, action_id, template_arg, args): return await action_to_code(widgets, do_hide, action_id, template_arg, args) -@automation.register_action("lvgl.widget.show", ObjUpdateAction, LIST_ACTION_SCHEMA) +@automation.register_action( + "lvgl.widget.show", ObjUpdateAction, LIST_ACTION_SCHEMA, synchronous=True +) async def obj_show_to_code(config, action_id, template_arg, args): async def do_show(widget: Widget): widget.clear_flag("LV_OBJ_FLAG_HIDDEN") @@ -318,6 +330,7 @@ def focused_id(value): key=CONF_ID, ), ), + synchronous=True, ) async def widget_focus(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -357,7 +370,10 @@ async def widget_focus(config, action_id, template_arg, args): @automation.register_action( - "lvgl.widget.update", ObjUpdateAction, base_update_schema(lv_obj_base_t, PARTS) + "lvgl.widget.update", + ObjUpdateAction, + base_update_schema(lv_obj_base_t, PARTS), + synchronous=True, ) async def obj_update_to_code(config, action_id, template_arg, args): async def do_update(widget: Widget): @@ -389,6 +405,7 @@ def validate_refresh_config(config): ), validate_refresh_config, ), + synchronous=True, ) async def obj_refresh_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 66cb25b864b..5400054bb13 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -163,8 +163,11 @@ void LvglComponent::show_page(size_t index, lv_scr_load_anim_t anim, uint32_t ti void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) { if (this->pages_.empty() || (this->current_page_ == this->pages_.size() - 1 && !this->page_wrap_)) return; + size_t start = this->current_page_; do { this->current_page_ = (this->current_page_ + 1) % this->pages_.size(); + if (this->current_page_ == start) + return; // all pages have skip=true (guaranteed not to happen by YAML validation) } while (this->pages_[this->current_page_]->skip); // skip empty pages() this->show_page(this->current_page_, anim, time); } @@ -172,8 +175,11 @@ void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) { void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) { if (this->pages_.empty() || (this->current_page_ == 0 && !this->page_wrap_)) return; + size_t start = this->current_page_; do { this->current_page_ = (this->current_page_ + this->pages_.size() - 1) % this->pages_.size(); + if (this->current_page_ == start) + return; // all pages have skip=true (guaranteed not to happen by YAML validation) } while (this->pages_[this->current_page_]->skip); // skip empty pages() this->show_page(this->current_page_, anim, time); } diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 3969c9f3887..b9801b41332 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -59,6 +59,7 @@ async def styles_to_code(config): cv.Required(CONF_ID): cv.use_id(lv_style_t), } ), + synchronous=True, ) async def style_update_to_code(config, action_id, template_arg, args): await wait_for_widgets() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 9c92ca7e986..09d40bb7efe 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -164,6 +164,7 @@ class WidgetType: f"lvgl.{self.name}.update", ObjUpdateAction, base_update_schema(self, self.parts).extend(self.modify_schema), + synchronous=True, )(update_to_code) @property diff --git a/esphome/components/lvgl/widgets/animimg.py b/esphome/components/lvgl/widgets/animimg.py index b824d28fb84..8e2db5ff350 100644 --- a/esphome/components/lvgl/widgets/animimg.py +++ b/esphome/components/lvgl/widgets/animimg.py @@ -83,6 +83,7 @@ animimg_spec = AnimimgType() }, key=CONF_ID, ), + synchronous=True, ) async def animimg_start(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -102,6 +103,7 @@ async def animimg_start(config, action_id, template_arg, args): }, key=CONF_ID, ), + synchronous=True, ) async def animimg_stop(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index fe421aa4770..f94f12b69b2 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -245,6 +245,7 @@ buttonmatrix_spec = ButtonMatrixType() cv.Optional(CONF_SELECTED): lv_bool, } ), + synchronous=True, ) async def button_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config[CONF_ID]) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index ead352aa77d..50cc8b0af62 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -97,6 +97,7 @@ canvas_spec = CanvasType() cv.Optional(CONF_OPA, default="COVER"): opacity, }, ), + synchronous=True, ) async def canvas_fill(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -120,6 +121,7 @@ async def canvas_fill(config, action_id, template_arg, args): cv.Required(CONF_POINTS): cv.ensure_list(point_schema), }, ), + synchronous=True, ) async def canvas_set_pixel(config, action_id, template_arg, args): widget = await get_widgets(config) @@ -229,6 +231,7 @@ RECT_PROPS = { **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, } ), + synchronous=True, ) async def canvas_draw_rect(config, action_id, template_arg, args): width = await pixels.process(config[CONF_WIDTH]) @@ -268,6 +271,7 @@ TEXT_PROPS = { **{cv.Optional(prop): STYLE_PROPS[f"text_{prop}"] for prop in TEXT_PROPS}, }, ), + synchronous=True, ) async def canvas_draw_text(config, action_id, template_arg, args): text = await lv_text.process(config[CONF_TEXT]) @@ -302,6 +306,7 @@ IMG_PROPS = { **{cv.Optional(prop): validator for prop, validator in IMG_PROPS.items()}, } ), + synchronous=True, ) async def canvas_draw_image(config, action_id, template_arg, args): src = await lv_image.process(config[CONF_SRC]) @@ -341,6 +346,7 @@ LINE_PROPS = { **{cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()}, } ), + synchronous=True, ) async def canvas_draw_line(config, action_id, template_arg, args): points = [ @@ -369,6 +375,7 @@ async def canvas_draw_line(config, action_id, template_arg, args): **{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS}, }, ), + synchronous=True, ) async def canvas_draw_polygon(config, action_id, template_arg, args): points = [ @@ -408,6 +415,7 @@ ARC_PROPS = { **{cv.Optional(prop): validator for prop, validator in ARC_PROPS.items()}, } ), + synchronous=True, ) async def canvas_draw_arc(config, action_id, template_arg, args): radius = await size.process(config[CONF_RADIUS]) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index aefda0e71a3..b7e3af9a788 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -297,6 +297,7 @@ meter_spec = MeterType() cv.Optional(CONF_OPA): opacity, } ), + synchronous=True, ) async def indicator_update_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/page.py b/esphome/components/lvgl/widgets/page.py index 23c162e010e..7e75ab6a2da 100644 --- a/esphome/components/lvgl/widgets/page.py +++ b/esphome/components/lvgl/widgets/page.py @@ -85,6 +85,7 @@ page_spec = PageType() "lvgl.page.next", LvglAction, SHOW_SCHEMA, + synchronous=True, ) async def page_next_to_code(config, action_id, template_arg, args): animation = await LV_ANIM.process(config[CONF_ANIMATION]) @@ -125,6 +126,7 @@ async def page_is_showing_to_code(config, condition_id, template_arg, args): "lvgl.page.previous", LvglAction, SHOW_SCHEMA, + synchronous=True, ) async def page_previous_to_code(config, action_id, template_arg, args): animation = await LV_ANIM.process(config[CONF_ANIMATION]) @@ -148,6 +150,7 @@ async def page_previous_to_code(config, action_id, template_arg, args): ), key=CONF_ID, ), + synchronous=True, ) async def page_show_to_code(config, action_id, template_arg, args): widget = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/lvgl/widgets/spinbox.py b/esphome/components/lvgl/widgets/spinbox.py index c6f25e9587c..58e3435c5c7 100644 --- a/esphome/components/lvgl/widgets/spinbox.py +++ b/esphome/components/lvgl/widgets/spinbox.py @@ -147,6 +147,7 @@ spinbox_spec = SpinboxType() }, key=CONF_ID, ), + synchronous=True, ) async def spinbox_increment(config, action_id, template_arg, args): widgets = await get_widgets(config) @@ -166,6 +167,7 @@ async def spinbox_increment(config, action_id, template_arg, args): }, key=CONF_ID, ), + synchronous=True, ) async def spinbox_decrement(config, action_id, template_arg, args): widgets = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index e8931bab7ca..cd7cf7b4716 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -109,6 +109,7 @@ tabview_spec = TabviewType() cv.Required(CONF_INDEX): lv_int, }, ).add_extra(cv.has_at_least_one_key(CONF_INDEX, CONF_TAB_ID)), + synchronous=True, ) async def tabview_select(config, action_id, template_arg, args): widget = await get_widgets(config) diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 5e3a95f017b..430a386d2e2 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -112,6 +112,7 @@ def tile_select_validate(config): cv.Optional(CONF_TILE_ID): cv.use_id(lv_tile_t), }, ).add_extra(tile_select_validate), + synchronous=True, ) async def tileview_select(config, action_id, template_arg, args): widgets = await get_widgets(config) diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index febbe794e47..cc46ba98d63 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -27,8 +27,6 @@ void MatrixKeypad::setup() { } void MatrixKeypad::loop() { - static uint32_t active_start = 0; - static int active_key = -1; uint32_t now = App.get_loop_component_start_time(); int key = -1; bool error = false; @@ -54,8 +52,8 @@ void MatrixKeypad::loop() { if (error) return; - if (key != active_key) { - if ((active_key != -1) && (this->pressed_key_ == active_key)) { + if (key != this->active_key_) { + if ((this->active_key_ != -1) && (this->pressed_key_ == this->active_key_)) { row = this->pressed_key_ / this->columns_.size(); col = this->pressed_key_ % this->columns_.size(); ESP_LOGD(TAG, "key @ row %d, col %d released", row, col); @@ -70,13 +68,13 @@ void MatrixKeypad::loop() { this->pressed_key_ = -1; } - active_key = key; + this->active_key_ = key; if (key == -1) return; - active_start = now; + this->active_start_ = now; } - if ((this->pressed_key_ == key) || (now - active_start < this->debounce_time_)) + if ((this->pressed_key_ == key) || (now - this->active_start_ < this->debounce_time_)) return; row = key / this->columns_.size(); diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 258ab4fadc8..8963612d0c7 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -44,6 +44,8 @@ class MatrixKeypad : public key_provider::KeyProvider, public Component { bool has_diodes_{false}; bool has_pulldowns_{false}; int pressed_key_ = -1; + uint32_t active_start_{0}; + int active_key_{-1}; std::vector listeners_{}; std::vector key_triggers_; diff --git a/esphome/components/max17043/sensor.py b/esphome/components/max17043/sensor.py index 3da0f953b09..ebb045dfcea 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -71,7 +71,9 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA) +@automation.register_action( + "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True +) async def max17043_sleep_mode_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 0d2ff527c7d..be6390fc17f 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -112,6 +112,7 @@ async def max6956_pin_to_code(config): }, key=CONF_BRIGHTNESS_GLOBAL, ), + synchronous=True, ) async def max6956_set_brightness_global_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -133,6 +134,7 @@ async def max6956_set_brightness_global_to_code(config, action_id, template_arg, }, key=CONF_BRIGHTNESS_MODE, ), + synchronous=True, ) async def max6956_set_brightness_mode_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/max7219digit/display.py b/esphome/components/max7219digit/display.py index a251eaccea5..eb751b995d1 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -133,10 +133,16 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "max7219digit.invert_off", DisplayInvertAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.invert_off", + DisplayInvertAction, + MAX7219_OFF_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "max7219digit.invert_on", DisplayInvertAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.invert_on", + DisplayInvertAction, + MAX7219_ON_ACTION_SCHEMA, + synchronous=True, ) async def max7219digit_invert_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -146,10 +152,16 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): @automation.register_action( - "max7219digit.turn_off", DisplayVisibilityAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.turn_off", + DisplayVisibilityAction, + MAX7219_OFF_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "max7219digit.turn_on", DisplayVisibilityAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.turn_on", + DisplayVisibilityAction, + MAX7219_ON_ACTION_SCHEMA, + synchronous=True, ) async def max7219digit_visible_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -159,10 +171,16 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): @automation.register_action( - "max7219digit.reverse_off", DisplayReverseAction, MAX7219_OFF_ACTION_SCHEMA + "max7219digit.reverse_off", + DisplayReverseAction, + MAX7219_OFF_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "max7219digit.reverse_on", DisplayReverseAction, MAX7219_ON_ACTION_SCHEMA + "max7219digit.reverse_on", + DisplayReverseAction, + MAX7219_ON_ACTION_SCHEMA, + synchronous=True, ) async def max7219digit_reverse_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -183,7 +201,10 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "max7219digit.intensity", DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA + "max7219digit.intensity", + DisplayIntensityAction, + MAX7219_INTENSITY_SCHEMA, + synchronous=True, ) async def max7219digit_intensity_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/max9611/sensor.py b/esphome/components/max9611/sensor.py index 8405a3f75af..b3a73d8c10c 100644 --- a/esphome/components/max9611/sensor.py +++ b/esphome/components/max9611/sensor.py @@ -35,7 +35,9 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(MAX9611Component), - cv.Required(CONF_SHUNT_RESISTANCE): cv.resistance, + cv.Required(CONF_SHUNT_RESISTANCE): cv.All( + cv.resistance, cv.Range(min=1e-6) + ), cv.Required(CONF_GAIN): cv.enum(MAX9611_GAIN, upper=True), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 1593c376cda..92228be62c9 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -47,12 +47,12 @@ void MCP23X08Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterr case mcp23xxx_base::MCP23XXX_RISING: this->update_reg(pin, true, gpinten); this->update_reg(pin, true, intcon); - this->update_reg(pin, true, defval); + this->update_reg(pin, false, defval); break; case mcp23xxx_base::MCP23XXX_FALLING: this->update_reg(pin, true, gpinten); this->update_reg(pin, true, intcon); - this->update_reg(pin, false, defval); + this->update_reg(pin, true, defval); break; case mcp23xxx_base::MCP23XXX_NO_INTERRUPT: this->update_reg(pin, false, gpinten); diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index b1f1f260b4e..6f95ee98fdb 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -59,12 +59,12 @@ void MCP23X17Base::pin_interrupt_mode(uint8_t pin, mcp23xxx_base::MCP23XXXInterr case mcp23xxx_base::MCP23XXX_RISING: this->update_reg(pin, true, gpinten); this->update_reg(pin, true, intcon); - this->update_reg(pin, true, defval); + this->update_reg(pin, false, defval); break; case mcp23xxx_base::MCP23XXX_FALLING: this->update_reg(pin, true, gpinten); this->update_reg(pin, true, intcon); - this->update_reg(pin, false, defval); + this->update_reg(pin, true, defval); break; case mcp23xxx_base::MCP23XXX_NO_INTERRUPT: this->update_reg(pin, false, gpinten); diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index 77bfaf92246..c2db9228c8b 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -506,6 +506,7 @@ canbus::Error MCP2515::set_bitrate_(canbus::CanSpeed can_speed, CanClock can_clo cfg3 = MCP_12MHZ_40KBPS_CFG3; break; case (canbus::CAN_50KBPS): // 50Kbps + cfg1 = MCP_12MHZ_50KBPS_CFG1; cfg2 = MCP_12MHZ_50KBPS_CFG2; cfg3 = MCP_12MHZ_50KBPS_CFG3; break; diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index dc7e7019aa0..48d90377df8 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -79,8 +79,8 @@ void Mcp4461Component::dump_config() { // reworked to be a one-line intentionally, as output would not be in order if (i < 4) { ESP_LOGCONFIG(TAG, " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, A: %s, B: %s, W: %s", i, - this->reg_[i].state, ONOFF(this->reg_[i].terminal_hw), ONOFF(this->reg_[i].terminal_a), - ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w), ONOFF(this->reg_[i].enabled)); + this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), + ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w)); } else { ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state); } @@ -315,9 +315,9 @@ void Mcp4461Component::disable_wiper_(Mcp4461WiperIdx wiper) { return; } ESP_LOGV(TAG, "Disabling wiper %u", wiper_idx); - this->reg_[wiper_idx].enabled = true; + this->reg_[wiper_idx].enabled = false; if (wiper_idx < 4) { - this->reg_[wiper_idx].terminal_hw = true; + this->reg_[wiper_idx].terminal_hw = false; this->reg_[wiper_idx].update_terminal = true; } } @@ -490,7 +490,7 @@ void Mcp4461Component::enable_terminal_(Mcp4461WiperIdx wiper, char terminal) { ESP_LOGW(TAG, "Unknown terminal %c specified", terminal); return; } - this->reg_[wiper_idx].update_terminal = false; + this->reg_[wiper_idx].update_terminal = true; } void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) { @@ -517,7 +517,7 @@ void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) { ESP_LOGW(TAG, "Unknown terminal %c specified", terminal); return; } - this->reg_[wiper_idx].update_terminal = false; + this->reg_[wiper_idx].update_terminal = true; } uint16_t Mcp4461Component::get_eeprom_value(Mcp4461EepromLocation location) { diff --git a/esphome/components/mcp4728/mcp4728.h b/esphome/components/mcp4728/mcp4728.h index f2262f4a358..d6574080810 100644 --- a/esphome/components/mcp4728/mcp4728.h +++ b/esphome/components/mcp4728/mcp4728.h @@ -58,7 +58,7 @@ class MCP4728Component : public Component, public i2c::I2CDevice { void select_gain_(MCP4728ChannelIdx channel, MCP4728Gain gain); private: - DACInputData reg_[4]; + DACInputData reg_[4]{}; bool store_in_eeprom_ = false; bool update_ = false; }; diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 13c8ccf2884..47cad4bf71b 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -129,6 +129,10 @@ class MDNSComponent final : public Component { #endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; +#endif +#ifdef USE_RP2040 + bool was_connected_{false}; + bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 05d991c1fad..88f707afd37 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -7,7 +7,12 @@ #include "esphome/core/log.h" #include "mdns_component.h" +// Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. +// Save and restore our definition around the include to avoid a redefinition warning. +#pragma push_macro("IRAM_ATTR") +#undef IRAM_ATTR #include +#pragma pop_macro("IRAM_ATTR") namespace esphome::mdns { @@ -36,12 +41,32 @@ static void register_rp2040(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_rp2040); - // Schedule MDNS.update() via set_interval() instead of overriding loop(). - // This removes the component from the per-iteration loop list entirely, - // eliminating virtual dispatch overhead on every main loop cycle. - // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + // RP2040's LEAmDNS library registers a LwipIntf::stateUpCB() callback to restart + // mDNS when the network interface reconnects. However, stateUpCB() is stubbed out + // in arduino-pico's LwipIntfCB.cpp because the original ESP8266 implementation used + // schedule_function() which doesn't exist in arduino-pico, and the callback can't + // safely run directly since netif status callbacks fire from IRQ context + // (PICO_CYW43_ARCH_THREADSAFE_BACKGROUND) while _restart() allocates UDP sockets. + // + // Workaround: defer MDNS.begin() and service registration until the network is + // connected (has an IP), then call notifyAPChange() on subsequent reconnects to + // restart mDNS probing and announcing — all from main loop context so it's + // thread-safe. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { + bool connected = network::is_connected(); + if (connected && !this->was_connected_) { + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + } + this->was_connected_ = connected; + if (this->initialized_) { + MDNS.update(); + } + }); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 051e386eaf7..a5baca29940 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -177,6 +177,7 @@ MEDIA_PLAYER_CONDITION_SCHEMA = automation.maybe_simple_id( }, key=CONF_MEDIA_URL, ), + synchronous=True, ) async def media_player_play_media_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -206,7 +207,10 @@ def _register_command_actions(): class_name, automation.Action, cg.Parented.template(MediaPlayer) ) automation.register_action( - f"media_player.{action_name}", action_class, MEDIA_PLAYER_ACTION_SCHEMA + f"media_player.{action_name}", + action_class, + MEDIA_PLAYER_ACTION_SCHEMA, + synchronous=True, )(handler) @@ -242,6 +246,7 @@ _register_state_conditions() }, key=CONF_VOLUME, ), + synchronous=True, ) async def media_player_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 2841afde7ac..b7d0ad1998e 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -112,13 +112,22 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "mhz19.calibrate_zero", MHZ19CalibrateZeroAction, NO_ARGS_ACTION_SCHEMA + "mhz19.calibrate_zero", + MHZ19CalibrateZeroAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "mhz19.abc_enable", MHZ19ABCEnableAction, NO_ARGS_ACTION_SCHEMA + "mhz19.abc_enable", + MHZ19ABCEnableAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "mhz19.abc_disable", MHZ19ABCDisableAction, NO_ARGS_ACTION_SCHEMA + "mhz19.abc_disable", + MHZ19ABCDisableAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def mhz19_no_args_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -137,7 +146,10 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "mhz19.detection_range_set", MHZ19DetectionRangeSetAction, RANGE_ACTION_SCHEMA + "mhz19.detection_range_set", + MHZ19DetectionRangeSetAction, + RANGE_ACTION_SCHEMA, + synchronous=True, ) async def mhz19_detection_range_set_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 74696584da9..372eb4c3b00 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -529,8 +529,15 @@ async def to_code(config): MICRO_WAKE_WORD_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(MicroWakeWord)}) -@register_action("micro_wake_word.start", StartAction, MICRO_WAKE_WORD_ACTION_SCHEMA) -@register_action("micro_wake_word.stop", StopAction, MICRO_WAKE_WORD_ACTION_SCHEMA) +@register_action( + "micro_wake_word.start", + StartAction, + MICRO_WAKE_WORD_ACTION_SCHEMA, + synchronous=True, +) +@register_action( + "micro_wake_word.stop", StopAction, MICRO_WAKE_WORD_ACTION_SCHEMA, synchronous=True +) @register_condition( "micro_wake_word.is_running", IsRunningCondition, MICRO_WAKE_WORD_ACTION_SCHEMA ) @@ -551,11 +558,13 @@ MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( "micro_wake_word.enable_model", EnableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, + synchronous=True, ) @register_action( "micro_wake_word.disable_model", DisableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, + synchronous=True, ) @register_condition( "micro_wake_word.model_is_enabled", diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index d6ef93cf301..b4623522292 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -1,14 +1,34 @@ +from dataclasses import dataclass + from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority CODEOWNERS = ["@jorre05", "@edenhaus"] DEPENDENCIES = ["uart"] DOMAIN = "micronova" + + +@dataclass +class MicronovaData: + """Track micronova component state during code generation.""" + + listener_count: int = 0 + has_writer: bool = False + + +def _get_data() -> MicronovaData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = MicronovaData() + return CORE.data[DOMAIN] + + CONF_MICRONOVA_ID = f"{DOMAIN}_id" CONF_ENABLE_RX_PIN = "enable_rx_pin" CONF_MEMORY_LOCATION = "memory_location" @@ -66,16 +86,42 @@ def MICRONOVA_ADDRESS_SCHEMA( return schema +def register_micronova_writer() -> None: + """Register a component that can write to the stove (button, switch, number).""" + _get_data().has_writer = True + + async def to_code_micronova_listener(mv, var, config): + _get_data().listener_count += 1 await cg.register_component(var, config) - cg.add(mv.register_micronova_listener(var)) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) cg.add(var.set_memory_address(config[CONF_MEMORY_ADDRESS])) + # Register listener as last step as we need all properties set before registering + cg.add(mv.register_micronova_listener(var)) async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) + enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) + var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) await cg.register_component(var, config) await uart.register_uart_device(var, config) - enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) - cg.add(var.set_enable_rx_pin(enable_rx_pin)) + CORE.add_job(_final_step) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _final_step() -> None: + """Add defines for listener and writer counts after all are registered.""" + data = _get_data() + if data.listener_count == 0 and not data.has_writer: + raise cv.Invalid( + "No micronova entities configured. Add at least one micronova entity." + ) + if data.listener_count > 255: + raise cv.Invalid( + f"Too many micronova reading entities ({data.listener_count}). Maximum is 255." + ) + if data.listener_count > 0: + cg.add_define("MICRONOVA_LISTENER_COUNT", data.listener_count) + + if data.has_writer: + cg.add_define("USE_MICRONOVA_WRITER") diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 6adf8d96fec..1ef359ea6cf 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -9,6 +9,7 @@ from .. import ( MICRONOVA_ADDRESS_SCHEMA, MicroNova, micronova_ns, + register_micronova_writer, ) MicroNovaButton = micronova_ns.class_("MicroNovaButton", button.Button, cg.Component) @@ -36,6 +37,7 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if custom_button_config := config.get(CONF_CUSTOM_BUTTON): + register_micronova_writer() bt = await button.new_button(custom_button_config, mv) cg.add(bt.set_memory_location(custom_button_config[CONF_MEMORY_LOCATION])) cg.add(bt.set_memory_address(custom_button_config[CONF_MEMORY_ADDRESS])) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index 3f49d4b5b3b..13c0e1f1176 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -2,9 +2,15 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.button"; + +void MicroNovaButton::dump_config() { + LOG_BUTTON("", "Micronova button", this); + this->dump_base_config(); +} + void MicroNovaButton::press_action() { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); - this->micronova_->request_update_listeners(); + this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, this->memory_data_); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 951ae8bba38..0258dbb53c2 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -6,19 +6,18 @@ namespace esphome::micronova { -class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener { +class MicroNovaButton : public Component, public button::Button, public MicroNovaBaseListener { public: - MicroNovaButton(MicroNova *m) : MicroNovaButtonListener(m) {} - void dump_config() override { - LOG_BUTTON("", "Micronova button", this); - this->dump_base_config(); - } + MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} + void dump_config() override; void set_memory_data(uint8_t f) { this->memory_data_ = f; } uint8_t get_memory_data() { return this->memory_data_; } protected: void press_action() override; + + uint8_t memory_data_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 22daef4fe6c..c2e3ef86405 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,8 +3,12 @@ namespace esphome::micronova { -static const int STOVE_REPLY_DELAY = 60; -static const uint8_t WRITE_BIT = 1 << 7; // 0x80 +static const char *const TAG = "micronova"; +static constexpr uint8_t STOVE_REPLY_SIZE = 2; +static constexpr uint32_t STOVE_REPLY_TIMEOUT = 200; // ms +static constexpr uint8_t WRITE_BIT = 1 << 7; // 0x80 + +bool MicroNovaCommand::is_write() const { return this->memory_location & WRITE_BIT; } void MicroNovaBaseListener::dump_base_config() { ESP_LOGCONFIG(TAG, @@ -18,139 +22,193 @@ void MicroNovaListener::dump_base_config() { LOG_UPDATE_INTERVAL(this); } +void MicroNovaListener::request_value_from_stove_() { + this->micronova_->queue_read_request(this->memory_location_, this->memory_address_); +} + void MicroNova::setup() { - if (this->enable_rx_pin_ != nullptr) { - this->enable_rx_pin_->setup(); - this->enable_rx_pin_->pin_mode(gpio::FLAG_OUTPUT); - this->enable_rx_pin_->digital_write(false); - } - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = 0; - this->current_transmission_.memory_address = 0; - this->current_transmission_.reply_pending = false; - this->current_transmission_.initiating_listener = nullptr; + this->enable_rx_pin_->setup(); + this->enable_rx_pin_->pin_mode(gpio::FLAG_OUTPUT); + this->enable_rx_pin_->digital_write(false); } void MicroNova::dump_config() { ESP_LOGCONFIG(TAG, "MicroNova:"); - if (this->enable_rx_pin_ != nullptr) { - LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); - } + LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); } -void MicroNova::request_update_listeners() { - ESP_LOGD(TAG, "Schedule listener update"); - for (auto &mv_listener : this->micronova_listeners_) { - mv_listener->set_needs_update(true); +#ifdef MICRONOVA_LISTENER_COUNT +void MicroNova::register_micronova_listener(MicroNovaListener *listener) { + this->listeners_.push_back(listener); + // Request initial value + this->queue_read_request(listener->get_memory_location(), listener->get_memory_address()); +} + +void MicroNova::request_update_listeners_() { + ESP_LOGD(TAG, "Requesting update from all listeners"); + for (auto *listener : this->listeners_) { + this->queue_read_request(listener->get_memory_location(), listener->get_memory_address()); } } +#endif void MicroNova::loop() { - // Only read one sensor that needs update per loop - // If STOVE_REPLY_DELAY time has passed since last loop() - // check for a reply from the stove - if ((this->current_transmission_.reply_pending) && - (millis() - this->current_transmission_.request_transmission_time > STOVE_REPLY_DELAY)) { - int stove_reply_value = this->read_stove_reply(); - if (this->current_transmission_.initiating_listener != nullptr) { - this->current_transmission_.initiating_listener->process_value_from_stove(stove_reply_value); - this->current_transmission_.initiating_listener = nullptr; - } - this->current_transmission_.reply_pending = false; - return; - } else if (!this->current_transmission_.reply_pending) { - for (auto &mv_listener : this->micronova_listeners_) { - if (mv_listener->get_needs_update()) { - mv_listener->set_needs_update(false); - this->current_transmission_.initiating_listener = mv_listener; - mv_listener->request_value_from_stove(); - return; + // Check if we're processing a command and waiting for reply + if (this->reply_pending_) { + // Check if all reply bytes have arrived + if (this->available() >= STOVE_REPLY_SIZE) { +#ifdef MICRONOVA_LISTENER_COUNT + int stove_reply_value = this->read_stove_reply_(); + if (this->current_command_.is_write()) { + if (stove_reply_value == -1) { + ESP_LOGW(TAG, "Write to [0x%02X:0x%02X] may have failed (checksum mismatch in reply)", + this->current_command_.memory_location & ~WRITE_BIT, this->current_command_.memory_address); + } + } else { + // For READ commands, notify all listeners registered for this address + uint8_t loc = this->current_command_.memory_location; + uint8_t addr = this->current_command_.memory_address; + for (auto *listener : this->listeners_) { + if (listener->get_memory_location() == loc && listener->get_memory_address() == addr) { + listener->process_value_from_stove(stove_reply_value); + } + } } +#else + this->read_stove_reply_(); +#endif + this->reply_pending_ = false; + } else if (millis() - this->transmission_time_ > STOVE_REPLY_TIMEOUT) { + // Timeout - no reply received (buffer cleared before next command) + ESP_LOGW(TAG, "Timeout waiting for reply from [0x%02X:0x%02X], available: %d", + this->current_command_.memory_location, this->current_command_.memory_address, this->available()); + this->reply_pending_ = false; } + return; } + + // No reply pending - process next command (writes have priority over reads) +#ifdef USE_MICRONOVA_WRITER + if (!this->write_queue_.empty()) { + this->current_command_ = this->write_queue_.front(); + this->write_queue_.pop(); + this->send_current_command_(); + return; + } +#endif +#ifdef MICRONOVA_LISTENER_COUNT + if (!this->read_queue_.empty()) { + this->current_command_ = this->read_queue_.front(); + this->read_queue_.pop(); + this->send_current_command_(); + } +#endif } -void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaListener *listener) { - uint8_t write_data[2] = {0, 0}; +#ifdef MICRONOVA_LISTENER_COUNT +void MicroNova::queue_read_request(uint8_t location, uint8_t address) { + // Check if this read is already queued + for (const auto &queued : this->read_queue_) { + if (queued.memory_location == location && queued.memory_address == address) { + ESP_LOGV(TAG, "Read [%02X,%02X] already queued, skipping", location, address); + return; + } + } + + MicroNovaCommand cmd; + cmd.memory_location = location; + cmd.memory_address = address; + cmd.data = 0; + + if (!this->read_queue_.push(cmd)) { + ESP_LOGW(TAG, "Read queue full, dropping read [%02X,%02X]", location, address); + return; + } + ESP_LOGV(TAG, "Queued read [%02X,%02X] (queue size: %u)", location, address, this->read_queue_.size()); +} +#endif + +void MicroNova::send_current_command_() { uint8_t trash_rx; - if (this->reply_pending_mutex_.try_lock()) { - // clear rx buffer. - // Stove hickups may cause late replies in the rx - while (this->available()) { - this->read_byte(&trash_rx); - ESP_LOGW(TAG, "Reading excess byte 0x%02X", trash_rx); - } - - write_data[0] = location; - write_data[1] = address; - ESP_LOGV(TAG, "Request from stove [%02X,%02X]", write_data[0], write_data[1]); - - this->enable_rx_pin_->digital_write(true); - this->write_array(write_data, 2); - this->flush(); - this->enable_rx_pin_->digital_write(false); - - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = location; - this->current_transmission_.memory_address = address; - this->current_transmission_.reply_pending = true; - this->current_transmission_.initiating_listener = listener; - } else { - ESP_LOGE(TAG, "Reply is pending, skipping read request"); + // Clear rx buffer - stove hiccups may cause late replies in the rx + while (this->available()) { + this->read_byte(&trash_rx); + ESP_LOGW(TAG, "Reading excess byte 0x%02X", trash_rx); } + + uint8_t write_data[4] = {this->current_command_.memory_location, this->current_command_.memory_address, 0, 0}; + size_t write_len; + + if (this->current_command_.is_write()) { + write_len = 4; + write_data[2] = this->current_command_.data; + // calculate checksum + write_data[3] = write_data[0] + write_data[1] + write_data[2]; + ESP_LOGV(TAG, "Sending write request [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], + write_data[3]); + } else { + write_len = 2; + ESP_LOGV(TAG, "Sending read request [%02X,%02X]", write_data[0], write_data[1]); + } + + this->enable_rx_pin_->digital_write(true); + this->write_array(write_data, write_len); + this->flush(); + this->enable_rx_pin_->digital_write(false); + + this->transmission_time_ = millis(); + this->reply_pending_ = true; } -int MicroNova::read_stove_reply() { +int MicroNova::read_stove_reply_() { uint8_t reply_data[2] = {0, 0}; - uint8_t checksum = 0; - // assert enable_rx_pin is false this->read_array(reply_data, 2); - this->reply_pending_mutex_.unlock(); ESP_LOGV(TAG, "Reply from stove [%02X,%02X]", reply_data[0], reply_data[1]); - checksum = ((uint16_t) this->current_transmission_.memory_location + - (uint16_t) this->current_transmission_.memory_address + (uint16_t) reply_data[1]) & - 0xFF; + uint8_t checksum = this->current_command_.memory_location + this->current_command_.memory_address + reply_data[1]; if (reply_data[0] != checksum) { - ESP_LOGE(TAG, "Checksum missmatch! From [0x%02X:0x%02X] received [0x%02X,0x%02X]. Expected 0x%02X, got 0x%02X", - this->current_transmission_.memory_location, this->current_transmission_.memory_address, reply_data[0], + ESP_LOGE(TAG, "Checksum mismatch! From [0x%02X:0x%02X] received [0x%02X,0x%02X]. Expected 0x%02X, got 0x%02X", + this->current_command_.memory_location, this->current_command_.memory_address, reply_data[0], reply_data[1], checksum, reply_data[0]); return -1; } return ((int) reply_data[1]); } -void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { - uint8_t write_data[4] = {0, 0, 0, 0}; - uint16_t checksum = 0; +#ifdef USE_MICRONOVA_WRITER +bool MicroNova::queue_write_command(uint8_t location, uint8_t address, uint8_t data) { + MicroNovaCommand cmd; + cmd.memory_location = location | WRITE_BIT; + cmd.memory_address = address; + cmd.data = data; - if (this->reply_pending_mutex_.try_lock()) { - uint8_t write_location = location | WRITE_BIT; - write_data[0] = write_location; - write_data[1] = address; - write_data[2] = data; - - checksum = ((uint16_t) write_data[0] + (uint16_t) write_data[1] + (uint16_t) write_data[2]) & 0xFF; - write_data[3] = checksum; - - ESP_LOGV(TAG, "Write 4 bytes [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], write_data[3]); - - this->enable_rx_pin_->digital_write(true); - this->write_array(write_data, 4); - this->flush(); - this->enable_rx_pin_->digital_write(false); - - this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = write_location; - this->current_transmission_.memory_address = address; - this->current_transmission_.reply_pending = true; - this->current_transmission_.initiating_listener = nullptr; - } else { - ESP_LOGE(TAG, "Reply is pending, skipping write"); + // Check if a write to the same address is already queued - update data in-place + for (auto &queued : this->write_queue_) { + if (queued.memory_location == cmd.memory_location && queued.memory_address == cmd.memory_address) { + if (queued.data != cmd.data) { + ESP_LOGD(TAG, "Updating queued write [%02X,%02X] data 0x%02X -> 0x%02X", location, address, queued.data, data); + queued.data = cmd.data; + } else { + ESP_LOGV(TAG, "Write [%02X,%02X] with data 0x%02X already queued, skipping", location, address, data); + } + return true; + } } + + if (!this->write_queue_.push(cmd)) { + ESP_LOGW(TAG, "Write queue full, dropping command"); + return false; + } + ESP_LOGD(TAG, "Queued write [%02X,%02X] (queue size: %u)", location, address, this->write_queue_.size()); +#ifdef MICRONOVA_LISTENER_COUNT + // Automatically queue sensor updates after write commands + this->request_update_listeners_(); +#endif + return true; } +#endif } // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index a70f355ead4..58cca30b836 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -6,11 +6,19 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include - namespace esphome::micronova { -static const char *const TAG = "micronova"; +static constexpr uint8_t WRITE_QUEUE_SIZE = 10; + +/// Represents a command to be sent to the stove +/// Write commands have the high bit (0x80) set in memory_location +struct MicroNovaCommand { + uint8_t memory_location; + uint8_t memory_address; + uint8_t data; ///< Only used for write commands + + bool is_write() const; +}; class MicroNova; @@ -18,11 +26,8 @@ class MicroNova; // Interface classes. class MicroNovaBaseListener { public: - MicroNovaBaseListener() {} MicroNovaBaseListener(MicroNova *m) { this->micronova_ = m; } - void set_micronova_object(MicroNova *m) { this->micronova_ = m; } - void set_memory_location(uint8_t l) { this->memory_location_ = l; } uint8_t get_memory_location() { return this->memory_location_; } @@ -32,70 +37,76 @@ class MicroNovaBaseListener { void dump_base_config(); protected: - MicroNova *micronova_{nullptr}; + MicroNova *micronova_; uint8_t memory_location_ = 0; uint8_t memory_address_ = 0; }; class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent { public: - MicroNovaListener() {} MicroNovaListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void request_value_from_stove() = 0; + + void update() override { this->request_value_from_stove_(); } + virtual void process_value_from_stove(int value_from_stove) = 0; - void set_needs_update(bool u) { this->needs_update_ = u; } - bool get_needs_update() { return this->needs_update_; } - - void update() override { this->set_needs_update(true); } - void dump_base_config(); protected: - bool needs_update_ = false; -}; - -class MicroNovaButtonListener : public MicroNovaBaseListener { - public: - MicroNovaButtonListener(MicroNova *m) : MicroNovaBaseListener(m) {} - - protected: - uint8_t memory_data_ = 0; + void request_value_from_stove_(); }; ///////////////////////////////////////////////////////////////////// // Main component class class MicroNova : public Component, public uart::UARTDevice { public: - MicroNova() {} + MicroNova(GPIOPin *enable_rx_pin) : enable_rx_pin_(enable_rx_pin) {} void setup() override; void loop() override; void dump_config() override; - void register_micronova_listener(MicroNovaListener *l) { this->micronova_listeners_.push_back(l); } - void request_update_listeners(); - void request_address(uint8_t location, uint8_t address, MicroNovaListener *listener); - void write_address(uint8_t location, uint8_t address, uint8_t data); - int read_stove_reply(); +#ifdef MICRONOVA_LISTENER_COUNT + void register_micronova_listener(MicroNovaListener *listener); - void set_enable_rx_pin(GPIOPin *enable_rx_pin) { this->enable_rx_pin_ = enable_rx_pin; } + /// Queue a read request to the stove (low priority - added at back) + /// All listeners registered for this address will be notified with the result + /// @param location Memory location on the stove + /// @param address Memory address on the stove + void queue_read_request(uint8_t location, uint8_t address); +#endif + +#ifdef USE_MICRONOVA_WRITER + /// Queue a write command to the stove (processed before reads) + /// @param location Memory location on the stove + /// @param address Memory address on the stove + /// @param data Data to write + /// @return true if command was queued, false if queue was full + bool queue_write_command(uint8_t location, uint8_t address, uint8_t data); +#endif protected: - GPIOPin *enable_rx_pin_{nullptr}; + void send_current_command_(); + int read_stove_reply_(); +#ifdef MICRONOVA_LISTENER_COUNT + void request_update_listeners_(); +#endif - struct MicroNovaSerialTransmission { - uint32_t request_transmission_time; - uint8_t memory_location; - uint8_t memory_address; - bool reply_pending; - MicroNovaListener *initiating_listener; - }; + GPIOPin *enable_rx_pin_; - Mutex reply_pending_mutex_; - MicroNovaSerialTransmission current_transmission_; +#ifdef USE_MICRONOVA_WRITER + StaticRingBuffer write_queue_; +#endif +#ifdef MICRONOVA_LISTENER_COUNT + StaticRingBuffer read_queue_; +#endif + MicroNovaCommand current_command_{}; + uint32_t transmission_time_{0}; ///< Time when current command was sent + bool reply_pending_{false}; ///< True if we are waiting for a reply from the stove - std::vector micronova_listeners_{}; +#ifdef MICRONOVA_LISTENER_COUNT + StaticVector listeners_; +#endif }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index ef6cc0f7d7c..bcc972c5a9d 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -9,6 +9,7 @@ from .. import ( MicroNova, MicroNovaListener, micronova_ns, + register_micronova_writer, to_code_micronova_listener, ) @@ -59,22 +60,24 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE): + register_micronova_writer() numb = await number.new_number( thermostat_temperature_config, + mv, min_value=0, max_value=40, step=thermostat_temperature_config.get(CONF_STEP), ) await to_code_micronova_listener(mv, numb, thermostat_temperature_config) - cg.add(numb.set_micronova_object(mv)) cg.add(numb.set_use_step_scaling(True)) if power_level_config := config.get(CONF_POWER_LEVEL): + register_micronova_writer() numb = await number.new_number( power_level_config, + mv, min_value=1, max_value=5, step=1, ) await to_code_micronova_listener(mv, numb, power_level_config) - cg.add(numb.set_micronova_object(mv)) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 80279474689..02a6de1c844 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.number"; + +void MicroNovaNumber::dump_config() { + LOG_NUMBER("", "Micronova number", this); + this->dump_base_config(); +} + void MicroNovaNumber::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state(NAN); @@ -22,8 +29,7 @@ void MicroNovaNumber::control(float value) { } else { new_number = static_cast(value); } - this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); - this->micronova_->request_update_listeners(); + this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, new_number); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 3fc5838a4fc..73666b632bf 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -7,16 +7,9 @@ namespace esphome::micronova { class MicroNovaNumber : public number::Number, public MicroNovaListener { public: - MicroNovaNumber() {} MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_NUMBER("", "Micronova number", this); - this->dump_base_config(); - } + void dump_config() override; void control(float value) override; - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } void process_value_from_stove(int value_from_stove) override; void set_use_step_scaling(bool v) { this->use_step_scaling_ = v; } diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index d845e0ab3cb..8d528145cd2 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.sensor"; + +void MicroNovaSensor::dump_config() { + LOG_SENSOR("", "Micronova sensor", this); + this->dump_base_config(); +} + void MicroNovaSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state(NAN); diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index a2f232c7dc5..f3b06d140e4 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -8,14 +8,8 @@ namespace esphome::micronova { class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { public: MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_SENSOR("", "Micronova sensor", this); - this->dump_base_config(); - } + void dump_config() override; - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } void process_value_from_stove(int value_from_stove) override; void set_divisor(uint8_t d) { this->divisor_ = d; } diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index c937a4cac9e..d9722b5d488 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -9,6 +9,7 @@ from .. import ( MicroNova, MicroNovaListener, micronova_ns, + register_micronova_writer, to_code_micronova_listener, ) @@ -48,6 +49,7 @@ async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_config := config.get(CONF_STOVE): + register_micronova_writer() sw = await switch.new_switch(stove_config, mv) await to_code_micronova_listener(mv, sw, stove_config) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 9b9ad610183..d01cc572543 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -2,33 +2,42 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.switch"; + +void MicroNovaSwitch::dump_config() { + LOG_SWITCH("", "Micronova switch", this); + this->dump_base_config(); +} + void MicroNovaSwitch::write_state(bool state) { if (state) { // Only send power-on when current state is Off if (this->raw_state_ == 0) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); - this->publish_state(true); + if (this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, this->memory_data_on_)) { + this->publish_state(true); + } } else { ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); } } else { // don't send power-off when status is Off or Final cleaning if (this->raw_state_ != 0 && this->raw_state_ != 6) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); - this->publish_state(false); + if (this->micronova_->queue_write_command(this->memory_location_, this->memory_address_, + this->memory_data_off_)) { + this->publish_state(false); + } } else { ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); } } - this->set_needs_update(true); } void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { - this->raw_state_ = value_from_stove; if (value_from_stove == -1) { ESP_LOGE(TAG, "Error reading stove state"); return; } + this->raw_state_ = value_from_stove; // set the stove switch to on for any value but 0 bool state = value_from_stove != 0; diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index 96c2c14e9ef..fee3c739769 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -9,13 +9,7 @@ namespace esphome::micronova { class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_SWITCH("", "Micronova switch", this); - this->dump_base_config(); - } - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } + void dump_config() override; void process_value_from_stove(int value_from_stove) override; void set_memory_data_on(uint8_t f) { this->memory_data_on_ = f; } diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index 2217ed6d6f1..50a0d34b3a7 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -2,6 +2,13 @@ namespace esphome::micronova { +static const char *const TAG = "micronova.text_sensor"; + +void MicroNovaTextSensor::dump_config() { + LOG_TEXT_SENSOR("", "Micronova text sensor", this); + this->dump_base_config(); +} + void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { this->publish_state("unknown"); diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 290f0ca45a7..6918a372e86 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -20,13 +20,7 @@ static const char *const STOVE_STATES[11] = {"Off", class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} - void dump_config() override { - LOG_TEXT_SENSOR("", "Micronova text sensor", this); - this->dump_base_config(); - } - void request_value_from_stove() override { - this->micronova_->request_address(this->memory_location_, this->memory_address_, this); - } + void dump_config() override; void process_value_from_stove(int value_from_stove) override; }; diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index ce314844134..6b5ee8c3e19 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -190,19 +190,25 @@ async def microphone_action(config, action_id, template_arg, args): automation.register_action( - "microphone.capture", CaptureAction, MICROPHONE_ACTION_SCHEMA + "microphone.capture", + CaptureAction, + MICROPHONE_ACTION_SCHEMA, + synchronous=True, )(microphone_action) automation.register_action( - "microphone.stop_capture", StopCaptureAction, MICROPHONE_ACTION_SCHEMA + "microphone.stop_capture", + StopCaptureAction, + MICROPHONE_ACTION_SCHEMA, + synchronous=True, )(microphone_action) -automation.register_action("microphone.mute", MuteAction, MICROPHONE_ACTION_SCHEMA)( - microphone_action -) -automation.register_action("microphone.unmute", UnmuteAction, MICROPHONE_ACTION_SCHEMA)( - microphone_action -) +automation.register_action( + "microphone.mute", MuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True +)(microphone_action) +automation.register_action( + "microphone.unmute", UnmuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True +)(microphone_action) automation.register_condition( "microphone.is_capturing", IsCapturingCondition, MICROPHONE_ACTION_SCHEMA diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index 60413b32d78..ce63a7d062b 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -59,6 +59,12 @@ void MICS4514Component::update() { return; } + if (this->red_calibration_ == 0 || this->ox_calibration_ == 0) { + ESP_LOGW(TAG, "Calibration values are zero, retrying"); + this->status_set_warning(); + this->initial_ = true; + return; + } float red_f = (float) (power - red) / this->red_calibration_; float ox_f = (float) (power - ox) / this->ox_calibration_; diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index 060cbd996bb..660d185b491 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -28,12 +28,14 @@ class UARTStream : public Stream { int available() override { return this->uart_->available(); } int read() override { uint8_t data; - this->uart_->read_byte(&data); + if (!this->uart_->read_byte(&data)) + return -1; return data; } int peek() override { uint8_t data; - this->uart_->peek_byte(&data); + if (!this->uart_->peek_byte(&data)) + return -1; return data; } size_t write(uint8_t data) override { diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index 8a3d4f22ba5..c954b450330 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -53,7 +53,9 @@ def templatize(value): def register_action(name, type_, schema): validator = templatize(schema).extend(MIDEA_ACTION_BASE_SCHEMA) - registerer = automation.register_action(f"midea_ac.{name}", type_, validator) + registerer = automation.register_action( + f"midea_ac.{name}", type_, validator, synchronous=True + ) def decorator(func): async def new_func(config, action_id, template_arg, args): diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 815b9d75a1d..e8e9ca2bfbf 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -54,6 +54,17 @@ void MIPI_DSI::setup() { this->smark_failed(LOG_STR("new_panel_io_dbi failed"), err); return; } + // clang-format off +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + auto color_format = LCD_COLOR_FMT_RGB565; + if (this->color_depth_ == display::COLOR_BITNESS_888) { + color_format = LCD_COLOR_FMT_RGB888; + } + esp_lcd_dpi_panel_config_t dpi_config = {.virtual_channel = 0, + .dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT, + .dpi_clock_freq_mhz = this->pclk_frequency_, + .in_color_format = color_format, +#else auto pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565; if (this->color_depth_ == display::COLOR_BITNESS_888) { pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB888; @@ -62,6 +73,7 @@ void MIPI_DSI::setup() { .dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT, .dpi_clock_freq_mhz = this->pclk_frequency_, .pixel_format = pixel_format, +#endif .num_fbs = 1, // number of frame buffers to allocate .video_timing = { @@ -75,13 +87,23 @@ void MIPI_DSI::setup() { .vsync_front_porch = this->vsync_front_porch_, }, .flags = { +#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0) .use_dma2d = true, +#endif }}; + // clang-format on err = esp_lcd_new_panel_dpi(this->bus_handle_, &dpi_config, &this->handle_); if (err != ESP_OK) { this->smark_failed(LOG_STR("esp_lcd_new_panel_dpi failed"), err); return; } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + err = esp_lcd_dpi_panel_enable_dma2d(this->handle_); + if (err != ESP_OK) { + this->smark_failed(LOG_STR("esp_lcd_dpi_panel_enable_dma2d failed"), err); + return; + } +#endif if (this->reset_pin_ != nullptr) { this->reset_pin_->setup(); this->reset_pin_->digital_write(true); diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index a3025d71210..63b419cc98e 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -162,6 +162,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def ducking_set_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 8e1278206f4..9d11abb3277 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -11,14 +11,14 @@ #include #include -namespace esphome { -namespace mixer_speaker { +namespace esphome::mixer_speaker { static const UBaseType_t MIXER_TASK_PRIORITY = 10; static const uint32_t STOPPING_TIMEOUT_MS = 5000; static const uint32_t TRANSFER_BUFFER_DURATION_MS = 50; static const uint32_t TASK_DELAY_MS = 25; +static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200; static const size_t TASK_STACK_SIZE = 4096; @@ -471,6 +471,7 @@ void MixerSpeaker::loop() { this->task_.deallocate(); ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); + this->all_stopped_since_ms_ = 0; } if (this->task_.is_created()) { @@ -483,8 +484,18 @@ void MixerSpeaker::loop() { } if (all_stopped) { - // Send stop command signal to the mixer task since no source speakers are active - xEventGroupSetBits(this->event_group_, MIXER_TASK_COMMAND_STOP); + if (this->all_stopped_since_ms_ == 0) { + this->all_stopped_since_ms_ = millis(); + } else if ((millis() - this->all_stopped_since_ms_) >= MIXER_AUTO_STOP_DEBOUNCE_MS) { + // Send stop command only after a short debounce to avoid stop/start thrash during rapid seeks. + xEventGroupSetBits(this->event_group_, MIXER_TASK_COMMAND_STOP); + } + } else { + this->all_stopped_since_ms_ = 0; + // New activity detected; clear any stale auto-stop request before it can stop the running task. + if (event_group_bits & MIXER_TASK_COMMAND_STOP) { + xEventGroupClearBits(this->event_group_, MIXER_TASK_COMMAND_STOP); + } } } else { // Task is fully stopped and cleaned up, check if we can disable loop @@ -515,6 +526,9 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { this->enable_loop_soon_any_context(); // ensure loop processes command + // Starting a new stream supersedes any previously queued stop request. + xEventGroupClearBits(this->event_group_, MIXER_TASK_COMMAND_STOP); + uint32_t event_bits = xEventGroupGetBits(this->event_group_); if (!(event_bits & MIXER_TASK_COMMAND_START)) { // Set MIXER_TASK_COMMAND_START bit if not already set, and then immediately wake for low latency @@ -755,7 +769,6 @@ void MixerSpeaker::audio_mixer_task(void *params) { vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } -} // namespace mixer_speaker -} // namespace esphome +} // namespace esphome::mixer_speaker #endif diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 0e0b33c39bc..29876ea262f 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -14,8 +14,7 @@ #include -namespace esphome { -namespace mixer_speaker { +namespace esphome::mixer_speaker { /* Classes for mixing several source speaker audio streams and writing it to another speaker component. * - Volume controls are passed through to the output speaker @@ -200,9 +199,9 @@ class MixerSpeaker : public Component { optional audio_stream_info_; std::atomic frames_in_pipeline_{0}; // Frames written to output but not yet played + uint32_t all_stopped_since_ms_{0}; // Debounce transient all-stopped windows before stopping task }; -} // namespace mixer_speaker -} // namespace esphome +} // namespace esphome::mixer_speaker #endif diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index f827e27e042..9a77b78bc19 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -27,7 +27,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice { void set_auto_set_reset(bool auto_set_reset) { auto_set_reset_ = auto_set_reset; } protected: - MMC5603Datarate datarate_; + MMC5603Datarate datarate_{MMC5603_DATARATE_75_0_HZ}; sensor::Sensor *x_sensor_{nullptr}; sensor::Sensor *y_sensor_{nullptr}; sensor::Sensor *z_sensor_{nullptr}; @@ -37,7 +37,7 @@ class MMC5603Component : public PollingComponent, public i2c::I2CDevice { NONE = 0, COMMUNICATION_FAILED, ID_REGISTERS, - } error_code_; + } error_code_{NONE}; }; } // namespace mmc5603 diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 82672217c56..7a61868e6e9 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -125,13 +125,17 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) { // Byte 0: modbus address (match all) if (at == 0) return true; - uint8_t address = raw[0]; - uint8_t function_code = raw[1]; + // Byte 1: function code + if (at == 1) + return true; // Byte 2: Size (with modbus rtu function code 4/3) // See also https://en.wikipedia.org/wiki/Modbus if (at == 2) return true; + uint8_t address = raw[0]; + uint8_t function_code = raw[1]; + uint8_t data_len = raw[2]; uint8_t data_offset = 3; @@ -146,10 +150,6 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) { // chance that this is a complete message ... admittedly there is a small chance is // isn't but that is quite small given the purpose of the CRC in the first place - // Fewer than 2 bytes can't calc CRC - if (at < 2) - return true; - data_len = at - 2; data_offset = 1; diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 7f0eb230e0a..f77f51a20df 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -59,6 +59,10 @@ bool ModbusController::send_next_command_() { // Queue incoming response void ModbusController::on_modbus_data(const std::vector &data) { + if (this->command_queue_.empty()) { + ESP_LOGW(TAG, "Received modbus data but command queue is empty"); + return; + } auto ¤t_command = this->command_queue_.front(); if (current_command != nullptr) { if (this->module_offline_) { @@ -92,6 +96,9 @@ void ModbusController::process_modbus_data_(const ModbusCommandItem *response) { void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_code) { ESP_LOGE(TAG, "Modbus error function code: 0x%X exception: %d ", function_code, exception_code); + if (this->command_queue_.empty()) { + return; + } // Remove pending command waiting for a response auto ¤t_command = this->command_queue_.front(); if (current_command != nullptr) { @@ -175,6 +182,11 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st uint16_t payload_offset; if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (data.size() < 5) { + ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); @@ -188,8 +200,19 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); return; } + if (data.size() < 5 + payload_size) { + ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), + 5 + payload_size); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } payload_offset = 5; } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + if (data.size() < 4) { + ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } number_of_registers = 1; payload_offset = 2; } else { diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index 88bd7b02fdb..a4a31b82608 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -126,18 +126,18 @@ bool MopekaStdCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) // Copy measurements over into my array. { u_int8_t measurements_index = 0; - for (u_int8_t i = 0; i < 3; i++) { - measurements_time[measurements_index] = mopeka_data->val[i].time_0 + 1; - measurements_value[measurements_index] = mopeka_data->val[i].value_0; + for (const auto &val : mopeka_data->val) { + measurements_time[measurements_index] = val.time_0 + 1; + measurements_value[measurements_index] = val.value_0; measurements_index++; - measurements_time[measurements_index] = mopeka_data->val[i].time_1 + 1; - measurements_value[measurements_index] = mopeka_data->val[i].value_1; + measurements_time[measurements_index] = val.time_1 + 1; + measurements_value[measurements_index] = val.value_1; measurements_index++; - measurements_time[measurements_index] = mopeka_data->val[i].time_2 + 1; - measurements_value[measurements_index] = mopeka_data->val[i].value_2; + measurements_time[measurements_index] = val.time_2 + 1; + measurements_value[measurements_index] = val.value_2; measurements_index++; - measurements_time[measurements_index] = mopeka_data->val[i].time_3 + 1; - measurements_value[measurements_index] = mopeka_data->val[i].value_3; + measurements_time[measurements_index] = val.time_3 + 1; + measurements_value[measurements_index] = val.value_3; measurements_index++; } } diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index 45588988c53..c0a02f27f20 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -40,7 +40,7 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru bool slow_update_rate : 1; bool sync_pressed : 1; - mopeka_std_values val[4]; + mopeka_std_values val[3]; } __attribute__((packed)); class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index c25c4720387..d110d7c1602 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -607,6 +607,7 @@ async def mqtt_connected_to_code(config, condition_id, template_arg, args): cv.GenerateID(): cv.use_id(MQTTClientComponent), } ), + synchronous=True, ) async def mqtt_enable_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -621,6 +622,7 @@ async def mqtt_enable_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(MQTTClientComponent), } ), + synchronous=True, ) async def mqtt_disable_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 8a7fb965e96..5642fd5f7b6 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -150,17 +150,16 @@ void MQTTBackendESP32::mqtt_event_handler_(const Event &event) { this->on_publish_.call((int) event.msg_id); break; case MQTT_EVENT_DATA: { - static std::string topic; if (!event.topic.empty()) { // When a single message arrives as multiple chunks, the topic will be empty // on any but the first message, leading to event.topic being an empty string. // To ensure handlers get the correct topic, cache the last seen topic to // simulate always receiving the topic from underlying library - topic = event.topic; + this->cached_topic_ = event.topic; } - ESP_LOGV(TAG, "MQTT_EVENT_DATA %s", topic.c_str()); - this->on_message_.call(topic.c_str(), event.data.data(), event.data.size(), event.current_data_offset, - event.total_data_len); + ESP_LOGV(TAG, "MQTT_EVENT_DATA %s", this->cached_topic_.c_str()); + this->on_message_.call(this->cached_topic_.c_str(), event.data.data(), event.data.size(), + event.current_data_offset, event.total_data_len); } break; case MQTT_EVENT_ERROR: ESP_LOGE(TAG, "MQTT_EVENT_ERROR"); diff --git a/esphome/components/mqtt/mqtt_backend_esp32.h b/esphome/components/mqtt/mqtt_backend_esp32.h index ccc4c4026c7..5c4dc413bdc 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.h +++ b/esphome/components/mqtt/mqtt_backend_esp32.h @@ -265,6 +265,7 @@ class MQTTBackendESP32 final : public MQTTBackend { CallbackManager on_unsubscribe_; CallbackManager on_message_; CallbackManager on_publish_; + std::string cached_topic_; std::queue mqtt_events_; #if defined(USE_MQTT_IDF_ENQUEUE) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 1a03c5329ee..38daf8f8f6f 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -44,8 +44,10 @@ MQTTClientComponent::MQTTClientComponent() { void MQTTClientComponent::setup() { this->mqtt_backend_.set_on_message( [this](const char *topic, const char *payload, size_t len, size_t index, size_t total) { - if (index == 0) + if (index == 0) { + this->payload_buffer_.clear(); this->payload_buffer_.reserve(total); + } // append new payload, may contain incomplete MQTT message this->payload_buffer_.append(payload, len); diff --git a/esphome/components/nau7802/sensor.py b/esphome/components/nau7802/sensor.py index 9192f48f53c..9798c1c2970 100644 --- a/esphome/components/nau7802/sensor.py +++ b/esphome/components/nau7802/sensor.py @@ -117,16 +117,19 @@ NAU7802_CALIBRATE_SCHEMA = maybe_simple_id( "nau7802.calibrate_internal_offset", NAU7802CalbrateInternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, + synchronous=True, ) @automation.register_action( "nau7802.calibrate_external_offset", NAU7802CalbrateExternalOffsetAction, NAU7802_CALIBRATE_SCHEMA, + synchronous=True, ) @automation.register_action( "nau7802.calibrate_gain", NAU7802CalbrateGainAction, NAU7802_CALIBRATE_SCHEMA, + synchronous=True, ) async def nau7802_calibrate_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/nextion/base_component.py b/esphome/components/nextion/base_component.py index 86551cbe238..7705b21b0b4 100644 --- a/esphome/components/nextion/base_component.py +++ b/esphome/components/nextion/base_component.py @@ -27,6 +27,9 @@ CONF_PRECISION = "precision" CONF_SKIP_CONNECTION_HANDSHAKE = "skip_connection_handshake" CONF_START_UP_PAGE = "start_up_page" CONF_STARTUP_OVERRIDE_MS = "startup_override_ms" +CONF_TFT_UPLOAD_HTTP_RETRIES = "tft_upload_http_retries" +CONF_TFT_UPLOAD_HTTP_TIMEOUT = "tft_upload_http_timeout" +CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT = "tft_upload_watchdog_timeout" CONF_TFT_URL = "tft_url" CONF_TOUCH_SLEEP_TIMEOUT = "touch_sleep_timeout" CONF_VARIABLE_NAME = "variable_name" diff --git a/esphome/components/nextion/binary_sensor/__init__.py b/esphome/components/nextion/binary_sensor/__init__.py index 7ef72c6491b..5b5922887ce 100644 --- a/esphome/components/nextion/binary_sensor/__init__.py +++ b/esphome/components/nextion/binary_sensor/__init__.py @@ -70,6 +70,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3bfcc959954..5b2dfc488d2 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -33,15 +33,24 @@ from .base_component import ( CONF_SKIP_CONNECTION_HANDSHAKE, CONF_START_UP_PAGE, CONF_STARTUP_OVERRIDE_MS, + CONF_TFT_UPLOAD_HTTP_RETRIES, + CONF_TFT_UPLOAD_HTTP_TIMEOUT, + CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT, CONF_TFT_URL, CONF_TOUCH_SLEEP_TIMEOUT, CONF_WAKE_UP_PAGE, ) CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] - DEPENDENCIES = ["uart"] -AUTO_LOAD = ["binary_sensor", "switch", "sensor", "text_sensor"] + + +def AUTO_LOAD() -> list[str]: + base = ["binary_sensor", "switch", "sensor", "text_sensor"] + if CORE.is_esp32: + base.append("watchdog") + return base + NextionSetBrightnessAction = nextion_ns.class_( "NextionSetBrightnessAction", automation.Action @@ -55,7 +64,24 @@ BufferOverflowTrigger = nextion_ns.class_( "BufferOverflowTrigger", automation.Trigger.template() ) -CONFIG_SCHEMA = ( + +def _validate_tft_upload(config): + has_tft_url = CONF_TFT_URL in config + for conf_key in ( + CONF_TFT_UPLOAD_HTTP_TIMEOUT, + CONF_TFT_UPLOAD_HTTP_RETRIES, + CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT, + ): + if conf_key in config and not has_tft_url: + raise cv.Invalid(f"{conf_key} requires {CONF_TFT_URL} to be set") + if CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT in config and not CORE.is_esp32: + raise cv.Invalid( + f"{CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT} is only available on ESP32" + ) + return config + + +CONFIG_SCHEMA = cv.All( display.BASIC_DISPLAY_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(Nextion), @@ -115,6 +141,14 @@ CONFIG_SCHEMA = ( ), ), cv.Optional(CONF_START_UP_PAGE): cv.uint8_t, + cv.Optional(CONF_TFT_UPLOAD_HTTP_RETRIES): cv.int_range(min=1, max=255), + cv.Optional(CONF_TFT_UPLOAD_HTTP_TIMEOUT): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=TimePeriod(milliseconds=65535)), + ), + cv.Optional( + CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT + ): cv.positive_time_period_milliseconds, cv.Optional(CONF_TFT_URL): cv.url, cv.Optional(CONF_TOUCH_SLEEP_TIMEOUT): cv.Any( 0, cv.int_range(min=3, max=65535) @@ -123,7 +157,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.polling_component_schema("5s")) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + _validate_tft_upload, ) @@ -137,6 +172,7 @@ CONFIG_SCHEMA = ( }, key=CONF_BRIGHTNESS, ), + synchronous=True, ) async def nextion_set_brightness_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -176,6 +212,29 @@ async def to_code(config): if CONF_TFT_URL in config: cg.add_define("USE_NEXTION_TFT_UPLOAD") cg.add(var.set_tft_url(config[CONF_TFT_URL])) + + # TFT upload HTTP timeout (default: 4.5s) + if CONF_TFT_UPLOAD_HTTP_TIMEOUT in config: + cg.add( + var.set_tft_upload_http_timeout( + config[CONF_TFT_UPLOAD_HTTP_TIMEOUT].total_milliseconds + ) + ) + + # TFT upload HTTP retries (default: 5) + if CONF_TFT_UPLOAD_HTTP_RETRIES in config: + cg.add( + var.set_tft_upload_http_retries(config[CONF_TFT_UPLOAD_HTTP_RETRIES]) + ) + + # TFT upload watchdog timeout (default: 0 = no adjustment) + if CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT in config: + cg.add( + var.set_tft_upload_watchdog_timeout( + config[CONF_TFT_UPLOAD_WATCHDOG_TIMEOUT].total_milliseconds + ) + ) + if CORE.is_esp32: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) esp32.include_builtin_idf_component("esp_http_client") diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index c8c1b6fa412..01ceb3d765e 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -191,6 +191,18 @@ void Nextion::dump_config() { #ifdef USE_NEXTION_MAX_QUEUE_SIZE ESP_LOGCONFIG(TAG, " Max queue size: %zu", this->max_queue_size_); #endif +#ifdef USE_NEXTION_TFT_UPLOAD + ESP_LOGCONFIG(TAG, + " TFT URL: %s\n" + " TFT upload HTTP timeout: %" PRIu16 "ms\n" + " TFT upload HTTP retries: %u", + this->tft_url_.c_str(), this->tft_upload_http_timeout_, this->tft_upload_http_retries_); +#ifdef USE_ESP32 + if (this->tft_upload_watchdog_timeout_ > 0) { + ESP_LOGCONFIG(TAG, " TFT upload WDT timeout: %" PRIu32 "ms", this->tft_upload_watchdog_timeout_); + } +#endif // USE_ESP32 +#endif // USE_NEXTION_TFT_UPLOAD } void Nextion::update() { @@ -634,16 +646,12 @@ void Nextion::process_nextion_commands_() { break; } - if (to_process_length == 0) { - ESP_LOGE(TAG, "Numeric return but no data"); + if (to_process_length < 4) { + ESP_LOGE(TAG, "Numeric return but insufficient data (need 4, got %zu)", to_process_length); break; } - int value = 0; - - for (int i = 0; i < 4; ++i) { - value += to_process[i] << (8 * i); - } + int value = static_cast(encode_uint32(to_process[3], to_process[2], to_process[1], to_process[0])); NextionQueue *nb = this->nextion_queue_.front(); if (!nb || !nb->component) { @@ -739,10 +747,8 @@ void Nextion::process_nextion_commands_() { index = to_process.find('\0'); variable_name = to_process.substr(0, index); // // Get variable name - int value = 0; - for (int i = 0; i < 4; ++i) { - value += to_process[i + index + 1] << (8 * i); - } + int value = static_cast( + encode_uint32(to_process[index + 4], to_process[index + 3], to_process[index + 2], to_process[index + 1])); ESP_LOGN(TAG, "Sensor: %s=%d", variable_name.c_str(), value); diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index c42ddba9b57..7999e3c4e3e 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -1071,6 +1071,33 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe bool send_command_printf(const char *format, ...) __attribute__((format(printf, 2, 3))); #ifdef USE_NEXTION_TFT_UPLOAD + /** + * @brief Set the HTTP timeout for TFT upload requests. + * @param timeout_ms Timeout in milliseconds. Defaults to 4500ms (4.5s). + */ + void set_tft_upload_http_timeout(uint16_t timeout_ms) { this->tft_upload_http_timeout_ = timeout_ms; } + +#ifdef USE_ESP32 + /** + * @brief Set the watchdog timeout during TFT upload. + * + * The system watchdog timeout is temporarily adjusted to this value + * during the entire TFT transfer process and restored to the original + * value after the transfer completes (whether successful or not). + * + * A value of 0 means no watchdog adjustment (default). + * + * @param timeout_ms Watchdog timeout in milliseconds. 0 = no adjustment. + */ + void set_tft_upload_watchdog_timeout(uint32_t timeout_ms) { this->tft_upload_watchdog_timeout_ = timeout_ms; } +#endif // USE_ESP32 + + /** + * @brief Set the number of HTTP retries for TFT upload requests. + * @param retries Number of retries. Defaults to 5. Range: 1-255. + */ + void set_tft_upload_http_retries(uint8_t retries) { this->tft_upload_http_retries_ = retries; } + /** * Set the tft file URL. */ @@ -1439,8 +1466,12 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe int tft_size_ = 0; uint32_t original_baud_rate_ = 0; bool upload_first_chunk_sent_ = false; + uint16_t tft_upload_http_timeout_{4500}; ///< HTTP timeout in ms (default: 4.5s) + uint8_t tft_upload_http_retries_{5}; ///< HTTP retry count (default: 5) #ifdef USE_ESP32 + uint32_t tft_upload_watchdog_timeout_{0}; ///< WDT timeout in ms (0 = no adjustment) + /** * will request 4096 bytes chunks from the web server * and send each to Nextion diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 46a04c1b2e1..6c454ab7459 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -86,6 +86,12 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); upload_first_chunk_sent_ = true; + if (recv_string.empty()) { + ESP_LOGW(TAG, "No response from display during upload"); + allocator.deallocate(buffer, 4096); + buffer = nullptr; + return -1; + } if (recv_string[0] == 0x08 && recv_string.size() == 5) { // handle partial upload request char hex_buf[format_hex_pretty_size(NEXTION_MAX_RESPONSE_LOG_BYTES)]; ESP_LOGD( @@ -166,7 +172,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Define the configuration for the HTTP client ESP_LOGV(TAG, "Init HTTP client, heap: %" PRIu32, EspClass::getFreeHeap()); HTTPClient http_client; - http_client.setTimeout(15000); // Yes 15 seconds.... Helps 8266s along + http_client.setTimeout(this->tft_upload_http_timeout_); bool begin_status = false; #ifdef USE_ESP8266 @@ -192,15 +198,15 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { http_client.collectHeaders(header_names, 1); ESP_LOGD(TAG, "URL: %s", this->tft_url_.c_str()); http_client.setReuse(true); - // try up to 5 times. DNS sometimes needs a second try or so + int tries = 1; int code = http_client.GET(); delay(100); // NOLINT App.feed_wdt(); - while (code != 200 && code != 206 && tries <= 5) { - ESP_LOGW(TAG, "HTTP fail: URL: %s; Error: %s, retry %d/5", this->tft_url_.c_str(), - HTTPClient::errorToString(code).c_str(), tries); + while (code != 200 && code != 206 && tries <= this->tft_upload_http_retries_) { + ESP_LOGW(TAG, "HTTP fail: URL: %s; Error: %s, retry %d/%u", this->tft_url_.c_str(), + HTTPClient::errorToString(code).c_str(), tries, this->tft_upload_http_retries_); delay(250); // NOLINT App.feed_wdt(); diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 43f59a8d4b4..166bbcc86a8 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -7,6 +7,7 @@ #include #include #include "esphome/components/network/util.h" +#include "esphome/components/watchdog/watchdog.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -68,7 +69,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r int partial_read_len = 0; uint8_t retries = 0; // Attempt to read the chunk with retries. - while (retries < 5 && read_len < buffer_size) { + while (retries < this->tft_upload_http_retries_ && read_len < buffer_size) { partial_read_len = esp_http_client_read(http_client, reinterpret_cast(buffer) + read_len, buffer_size - read_len); if (partial_read_len > 0) { @@ -107,6 +108,12 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r static_cast(esp_get_free_heap_size())); #endif upload_first_chunk_sent_ = true; + if (recv_string.empty()) { + ESP_LOGW(TAG, "No response from display during upload"); + allocator.deallocate(buffer, 4096); + buffer = nullptr; + return -1; + } if (recv_string[0] == 0x08 && recv_string.size() == 5) { // handle partial upload request char hex_buf[format_hex_pretty_size(NEXTION_MAX_RESPONSE_LOG_BYTES)]; ESP_LOGD( @@ -167,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return false; } + // Temporarily adjust watchdog timeout for the duration of the TFT upload + watchdog::WatchdogManager wdm(this->tft_upload_watchdog_timeout_); + this->connection_state_.is_updating_ = true; if (exit_reparse) { @@ -190,7 +200,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { .url = this->tft_url_.c_str(), .cert_pem = nullptr, .method = HTTP_METHOD_HEAD, - .timeout_ms = 15000, + .timeout_ms = static_cast(this->tft_upload_http_timeout_), .disable_auto_redirect = false, .max_redirection_count = 10, }; diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 9802762ff35..cab531f1db6 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -110,6 +110,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nextion/switch/__init__.py b/esphome/components/nextion/switch/__init__.py index 1974ff3b9ea..81e6721d0f9 100644 --- a/esphome/components/nextion/switch/__init__.py +++ b/esphome/components/nextion/switch/__init__.py @@ -52,6 +52,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/nextion/text_sensor/__init__.py b/esphome/components/nextion/text_sensor/__init__.py index 8fc0a8ceaf0..168a6724979 100644 --- a/esphome/components/nextion/text_sensor/__init__.py +++ b/esphome/components/nextion/text_sensor/__init__.py @@ -48,6 +48,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def sensor_nextion_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/one_wire/one_wire.cpp b/esphome/components/one_wire/one_wire.cpp index 187f559ca6a..d14c1c92bdd 100644 --- a/esphome/components/one_wire/one_wire.cpp +++ b/esphome/components/one_wire/one_wire.cpp @@ -13,6 +13,11 @@ const std::string &OneWireDevice::get_address_name() { return this->address_name_; } +void OneWireDevice::set_address(uint64_t address) { + this->address_ = address; + this->address_name_.clear(); +} + bool OneWireDevice::send_command_(uint8_t cmd) { if (!this->bus_->select(this->address_)) return false; diff --git a/esphome/components/one_wire/one_wire.h b/esphome/components/one_wire/one_wire.h index f6a956a92c7..324e46cd55d 100644 --- a/esphome/components/one_wire/one_wire.h +++ b/esphome/components/one_wire/one_wire.h @@ -15,7 +15,7 @@ class OneWireDevice { public: /// @brief store the address of the device /// @param address of the device - void set_address(uint64_t address) { this->address_ = address; } + void set_address(uint64_t address); void set_index(uint8_t index) { this->index_ = index; } diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 057244e03d4..35a9de3537e 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -106,9 +106,14 @@ RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action("online_image.set_url", SetUrlAction, SET_URL_SCHEMA) @automation.register_action( - "online_image.release", ReleaseImageAction, RELEASE_IMAGE_SCHEMA + "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +) +@automation.register_action( + "online_image.release", + ReleaseImageAction, + RELEASE_IMAGE_SCHEMA, + synchronous=True, ) async def online_image_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index da866599c9d..22bf6a3056f 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -129,7 +129,7 @@ void OnlineImage::update() { } ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size); - this->start_time_ = ::time(nullptr); + this->start_time_ = millis(); this->enable_loop(); } @@ -155,8 +155,8 @@ void OnlineImage::loop() { // Finalize decoding this->end_decode(); - ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 "s", this->downloader_->get_bytes_read(), - (uint32_t) (::time(nullptr) - this->start_time_)); + ESP_LOGD(TAG, "Image fully downloaded, %zu bytes in %" PRIu32 " ms", this->downloader_->get_bytes_read(), + millis() - this->start_time_); // Save caching headers this->etag_ = this->downloader_->get_response_header(ETAG_HEADER_NAME); diff --git a/esphome/components/online_image/online_image.h b/esphome/components/online_image/online_image.h index c7c80c7c667..12c25645260 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -97,7 +97,7 @@ class OnlineImage : public PollingComponent, */ std::string last_modified_ = ""; - time_t start_time_; + uint32_t start_time_{0}; }; template class OnlineImageSetUrlAction : public Action { diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 1f9a77e4261..93e6249fb3d 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -105,6 +105,7 @@ OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) { this->current_address_ = this->start_address_; this->image_size_ = image_size; + this->bytes_received_ = 0; this->buffer_len_ = 0; this->md5_set_ = false; @@ -140,6 +141,7 @@ OTAResponseTypes ESP8266OTABackend::write(uint8_t *data, size_t len) { size_t to_buffer = std::min(len - written, this->buffer_size_ - this->buffer_len_); memcpy(this->buffer_.get() + this->buffer_len_, data + written, to_buffer); this->buffer_len_ += to_buffer; + this->bytes_received_ += to_buffer; written += to_buffer; // If buffer is full, write to flash @@ -252,8 +254,8 @@ OTAResponseTypes ESP8266OTABackend::end() { } } - // Calculate actual bytes written - size_t actual_size = this->current_address_ - this->start_address_; + // Calculate actual bytes written (exact uploaded size, excluding flash write padding) + size_t actual_size = this->bytes_received_; // Check if any data was written if (actual_size == 0) { @@ -304,6 +306,7 @@ void ESP8266OTABackend::abort() { this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; + this->bytes_received_ = 0; esp8266::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 6213289accb..b364e216a36 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -48,6 +48,7 @@ class ESP8266OTABackend final { uint32_t start_address_{0}; uint32_t current_address_{0}; size_t image_size_{0}; + size_t bytes_received_{0}; md5::MD5Digest md5_{}; uint8_t expected_md5_[16]; // Fixed-size buffer for 128-bit (16-byte) MD5 digest diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index a4c960927ba..a4ce2b2d1a2 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -118,6 +118,7 @@ async def output_set_level_to_code(config, action_id, template_arg, args): cv.Required(CONF_MIN_POWER): cv.templatable(cv.percentage), } ), + synchronous=True, ) async def output_set_min_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -136,6 +137,7 @@ async def output_set_min_power_to_code(config, action_id, template_arg, args): cv.Required(CONF_MAX_POWER): cv.templatable(cv.percentage), } ), + synchronous=True, ) async def output_set_max_power_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index 3b83c857165..46014e0903a 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -11,16 +11,10 @@ void FloatOutput::set_max_power(float max_power) { this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to MIN>=MAX>=1.0 } -float FloatOutput::get_max_power() const { return this->max_power_; } - void FloatOutput::set_min_power(float min_power) { this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0>=MIN>=MAX } -void FloatOutput::set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } - -float FloatOutput::get_min_power() const { return this->min_power_; } - void FloatOutput::set_level(float state) { state = clamp(state, 0.0f, 1.0f); diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 3e2b3ada8d7..5225f88c669 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -48,9 +48,9 @@ class FloatOutput : public BinaryOutput { /** Sets this output to ignore min_power for a 0 state * - * @param zero True if a 0 state should mean 0 and not min_power. + * @param zero_means_zero True if a 0 state should mean 0 and not min_power. */ - void set_zero_means_zero(bool zero_means_zero); + void set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } /** Set the level of this float output, this is called from the front-end. * @@ -70,10 +70,10 @@ class FloatOutput : public BinaryOutput { // (In most use cases you won't need these) /// Get the maximum power output. - float get_max_power() const; + float get_max_power() const { return this->max_power_; } /// Get the minimum power output. - float get_min_power() const; + float get_min_power() const { return this->min_power_; } protected: /// Implement BinarySensor's write_enabled; this should never be called. diff --git a/esphome/components/pcf85063/time.py b/esphome/components/pcf85063/time.py index f3c0c3230f6..8e19178cc99 100644 --- a/esphome/components/pcf85063/time.py +++ b/esphome/components/pcf85063/time.py @@ -29,6 +29,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(PCF85063Component), } ), + synchronous=True, ) async def pcf85063_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -44,6 +45,7 @@ async def pcf85063_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(PCF85063Component), } ), + synchronous=True, ) async def pcf85063_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pcf8563/time.py b/esphome/components/pcf8563/time.py index e3b3b572aa3..0d4de3cb73d 100644 --- a/esphome/components/pcf8563/time.py +++ b/esphome/components/pcf8563/time.py @@ -32,6 +32,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(pcf8563Component), } ), + synchronous=True, ) async def pcf8563_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -47,6 +48,7 @@ async def pcf8563_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(pcf8563Component), } ), + synchronous=True, ) async def pcf8563_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 5fa3166f9df..18e33b80391 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -57,7 +57,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_KD_MULTIPLIER, default=0.0): cv.float_, cv.Optional( CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES, default=1 - ): cv.int_, + ): cv.positive_not_null_int, } ), cv.Required(CONF_CONTROL_PARAMETERS): cv.Schema( @@ -68,8 +68,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STARTING_INTEGRAL_TERM, default=0.0): cv.float_, cv.Optional(CONF_MIN_INTEGRAL, default=-1): cv.float_, cv.Optional(CONF_MAX_INTEGRAL, default=1): cv.float_, - cv.Optional(CONF_DERIVATIVE_AVERAGING_SAMPLES, default=1): cv.int_, - cv.Optional(CONF_OUTPUT_AVERAGING_SAMPLES, default=1): cv.int_, + cv.Optional( + CONF_DERIVATIVE_AVERAGING_SAMPLES, default=1 + ): cv.positive_not_null_int, + cv.Optional( + CONF_OUTPUT_AVERAGING_SAMPLES, default=1 + ): cv.positive_not_null_int, } ), } @@ -102,13 +106,15 @@ async def to_code(config): cg.add(var.set_starting_integral_term(params[CONF_STARTING_INTEGRAL_TERM])) cg.add(var.set_derivative_samples(params[CONF_DERIVATIVE_AVERAGING_SAMPLES])) - cg.add(var.set_output_samples(params[CONF_OUTPUT_AVERAGING_SAMPLES])) + output_samples = params[CONF_OUTPUT_AVERAGING_SAMPLES] + cg.add(var.set_output_samples(output_samples)) if CONF_MIN_INTEGRAL in params: cg.add(var.set_min_integral(params[CONF_MIN_INTEGRAL])) if CONF_MAX_INTEGRAL in params: cg.add(var.set_max_integral(params[CONF_MAX_INTEGRAL])) + deadband_output_samples = 1 if CONF_DEADBAND_PARAMETERS in config: params = config[CONF_DEADBAND_PARAMETERS] cg.add(var.set_threshold_low(params[CONF_THRESHOLD_LOW])) @@ -116,11 +122,11 @@ async def to_code(config): cg.add(var.set_kp_multiplier(params[CONF_KP_MULTIPLIER])) cg.add(var.set_ki_multiplier(params[CONF_KI_MULTIPLIER])) cg.add(var.set_kd_multiplier(params[CONF_KD_MULTIPLIER])) - cg.add( - var.set_deadband_output_samples( - params[CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES] - ) - ) + deadband_output_samples = params[CONF_DEADBAND_OUTPUT_AVERAGING_SAMPLES] + cg.add(var.set_deadband_output_samples(deadband_output_samples)) + + # Single shared output buffer sized to max of both modes + cg.add(var.init_output_buffer(max(output_samples, deadband_output_samples))) cg.add(var.set_default_target_temperature(config[CONF_DEFAULT_TARGET_TEMPERATURE])) @@ -133,6 +139,7 @@ async def to_code(config): cv.Required(CONF_ID): cv.use_id(PIDClimate), } ), + synchronous=True, ) async def pid_reset_integral_term(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -154,6 +161,7 @@ async def pid_reset_integral_term(config, action_id, template_arg, args): ): cv.possibly_negative_percentage, } ), + synchronous=True, ) async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -175,6 +183,7 @@ async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): cv.Optional(CONF_KD, default=0.0): cv.templatable(cv.float_), } ), + synchronous=True, ) async def set_control_parameters(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index dc0a92efed5..3708c29ff1b 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -28,7 +28,11 @@ class PIDClimate : public climate::Climate, public Component { void set_min_integral(float min_integral) { controller_.min_integral_ = min_integral; } void set_max_integral(float max_integral) { controller_.max_integral_ = max_integral; } void set_output_samples(int in) { controller_.output_samples_ = in; } - void set_derivative_samples(int in) { controller_.derivative_samples_ = in; } + void set_derivative_samples(int in) { + controller_.derivative_samples_ = in; + if (in > 1) // No allocation needed when samples=1 (ring_buffer_average_ short-circuits) + controller_.derivative_window_.init(in); + } void set_threshold_low(float in) { controller_.threshold_low_ = in; } void set_threshold_high(float in) { controller_.threshold_high_ = in; } @@ -38,6 +42,10 @@ class PIDClimate : public climate::Climate, public Component { void set_starting_integral_term(float in) { controller_.set_starting_integral_term(in); } void set_deadband_output_samples(int in) { controller_.deadband_output_samples_ = in; } + void init_output_buffer(int size) { + if (size > 1) // No allocation needed when samples=1 (ring_buffer_average_ short-circuits) + controller_.output_window_.init(size); + } float get_output_value() const { return output_value_; } float get_error_value() const { return controller_.error_; } diff --git a/esphome/components/pid/pid_controller.cpp b/esphome/components/pid/pid_controller.cpp index 5d7aecdb052..cab15331cde 100644 --- a/esphome/components/pid/pid_controller.cpp +++ b/esphome/components/pid/pid_controller.cpp @@ -21,9 +21,9 @@ float PIDController::update(float setpoint, float process_value) { // u(t) := p(t) + i(t) + d(t) float output = proportional_term_ + integral_term_ + derivative_term_; - // smooth/sample the output + // smooth/sample the output using shared buffer with mode-appropriate sample count int samples = in_deadband() ? deadband_output_samples_ : output_samples_; - return weighted_average_(output_list_, output, samples); + return ring_buffer_average_(output_window_, output, samples); } bool PIDController::in_deadband() { @@ -83,7 +83,7 @@ void PIDController::calculate_derivative_term_(float setpoint) { previous_setpoint_ = setpoint; // smooth the derivative samples - derivative = weighted_average_(derivative_list_, derivative, derivative_samples_); + derivative = ring_buffer_average_(derivative_window_, derivative, derivative_samples_); derivative_term_ = kd_ * derivative; @@ -93,25 +93,23 @@ void PIDController::calculate_derivative_term_(float setpoint) { } } -float PIDController::weighted_average_(std::deque &list, float new_value, int samples) { - // if only 1 sample needed, clear the list and return - if (samples == 1) { - list.clear(); +float PIDController::ring_buffer_average_(FixedRingBuffer &buf, float new_value, int max_samples) { + // if only 1 sample needed (or invalid), clear the buffer and return + if (max_samples <= 1) { + buf.clear(); return new_value; } - // add the new item to the list - list.push_front(new_value); + // Trim oldest entries to make room (handles mode-switching where buffer + // may have more entries than the current mode needs) + while (buf.size() >= static_cast(max_samples)) + buf.pop(); + buf.push(new_value); - // keep only 'samples' readings, by popping off the back of the list - while (samples > 0 && list.size() > static_cast(samples)) - list.pop_back(); - - // calculate and return the average of all values in the list float sum = 0; - for (auto &elem : list) - sum += elem; - return sum / list.size(); + for (auto val : buf) + sum += val; + return sum / buf.size(); } float PIDController::calculate_relative_time_() { diff --git a/esphome/components/pid/pid_controller.h b/esphome/components/pid/pid_controller.h index e2a7030b571..6848a23965a 100644 --- a/esphome/components/pid/pid_controller.h +++ b/esphome/components/pid/pid_controller.h @@ -1,6 +1,7 @@ #pragma once + #include "esphome/core/hal.h" -#include +#include "esphome/core/helpers.h" #include namespace esphome { @@ -24,10 +25,10 @@ struct PIDController { /// Differential gain K_d. float kd_ = 0; - // smooth the derivative value using a weighted average over X samples - int derivative_samples_ = 8; + // smooth the derivative value using an average over X samples + int derivative_samples_ = 1; - /// smooth the output value using a weighted average over X values + /// smooth the output value using an average over X values int output_samples_ = 1; float threshold_low_ = 0.0f; @@ -50,7 +51,10 @@ struct PIDController { void calculate_proportional_term_(); void calculate_integral_term_(); void calculate_derivative_term_(float setpoint); - float weighted_average_(std::deque &list, float new_value, int samples); + + /// Ring buffer smoothing using FixedRingBuffer (single allocation at setup) + float ring_buffer_average_(FixedRingBuffer &buf, float new_value, int max_samples); + float calculate_relative_time_(); /// Error from previous update used for derivative term @@ -60,12 +64,12 @@ struct PIDController { float accumulated_integral_ = 0; uint32_t last_time_ = 0; - // this is a list of derivative values for smoothing. - std::deque derivative_list_; + // Ring buffer for derivative smoothing + FixedRingBuffer derivative_window_; - // this is a list of output values for smoothing. - std::deque output_list_; + // Ring buffer for output smoothing (shared between normal and deadband modes) + FixedRingBuffer output_window_; -}; // Struct PID Controller +}; // Struct PIDController } // namespace pid } // namespace esphome diff --git a/esphome/components/pid/sensor/__init__.py b/esphome/components/pid/sensor/__init__.py index 4547f4d7082..d26e88e38ae 100644 --- a/esphome/components/pid/sensor/__init__.py +++ b/esphome/components/pid/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_CLIMATE_ID import esphome.config_validation as cv from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT @@ -21,7 +22,6 @@ PID_CLIMATE_SENSOR_TYPES = { "KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD, } -CONF_CLIMATE_ID = "climate_id" CONFIG_SCHEMA = ( sensor.sensor_schema( PIDClimateSensor, diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 829f8f70370..81e99e15a29 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -98,6 +98,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.positive_float), } ), + synchronous=True, ) async def output_pipsolar_set_level_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index eb6d3931e05..c304d206c00 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -192,8 +192,13 @@ bool Pipsolar::send_next_command_() { if (!this->command_queue_[this->command_queue_position_].empty()) { const char *command = this->command_queue_[this->command_queue_position_].c_str(); uint8_t byte_command[16]; - uint8_t length = this->command_queue_[this->command_queue_position_].length(); - for (uint8_t i = 0; i < length; i++) { + size_t length = this->command_queue_[this->command_queue_position_].length(); + if (length > sizeof(byte_command)) { + ESP_LOGE(TAG, "Command too long: %zu", length); + this->command_queue_[this->command_queue_position_].clear(); + return false; + } + for (size_t i = 0; i < length; i++) { byte_command[i] = (uint8_t) this->command_queue_[this->command_queue_position_].at(i); } this->state_ = STATE_COMMAND; diff --git a/esphome/components/pmwcs3/sensor.py b/esphome/components/pmwcs3/sensor.py index 075b9b00b52..bb40f3e4995 100644 --- a/esphome/components/pmwcs3/sensor.py +++ b/esphome/components/pmwcs3/sensor.py @@ -106,11 +106,13 @@ PMWCS3_CALIBRATION_SCHEMA = cv.Schema( "pmwcs3.air_calibration", PMWCS3AirCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, + synchronous=True, ) @automation.register_action( "pmwcs3.water_calibration", PMWCS3WaterCalibrationAction, PMWCS3_CALIBRATION_SCHEMA, + synchronous=True, ) async def pmwcs3_calibration_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) @@ -130,6 +132,7 @@ PMWCS3_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( "pmwcs3.new_i2c_address", PMWCS3NewI2cAddressAction, PMWCS3_NEW_I2C_ADDRESS_SCHEMA, + synchronous=True, ) async def pmwcs3newi2caddress_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pn7150/__init__.py b/esphome/components/pn7150/__init__.py index 131b56e30ea..6af14128818 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -119,11 +119,13 @@ PN7150_SCHEMA = cv.Schema( "tag.set_emulation_message", SetEmulationMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "tag.set_write_message", SetWriteMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) async def pn7150_set_message_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -138,22 +140,43 @@ async def pn7150_set_message_to_code(config, action_id, template_arg, args): @automation.register_action( - "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA -) -@automation.register_action("tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action( - "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_off", + EmulationOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "tag.set_clean_mode", + SetCleanModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_format_mode", + SetFormatModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_read_mode", + SetReadModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_write_mode", + SetWriteModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) async def pn7150_simple_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index 38b3102b374..4ae884595bb 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -34,7 +34,8 @@ uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7150I2C::write_nfcc(nfc::NciMessage &tx) { - if (this->write(tx.encode().data(), tx.encode().size()) == i2c::ERROR_OK) { + auto encoded = tx.encode(); + if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } return nfc::STATUS_FAILED; diff --git a/esphome/components/pn7160/__init__.py b/esphome/components/pn7160/__init__.py index 899ecd595ea..54e4b74796b 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -123,11 +123,13 @@ PN7160_SCHEMA = cv.Schema( "tag.set_emulation_message", SetEmulationMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "tag.set_write_message", SetWriteMessageAction, SET_MESSAGE_ACTION_SCHEMA, + synchronous=True, ) async def pn7160_set_message_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -142,22 +144,43 @@ async def pn7160_set_message_to_code(config, action_id, template_arg, args): @automation.register_action( - "tag.emulation_off", EmulationOffAction, SIMPLE_ACTION_SCHEMA -) -@automation.register_action("tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action("tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA) -@automation.register_action( - "tag.set_clean_mode", SetCleanModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_off", + EmulationOffAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "tag.set_format_mode", SetFormatModeAction, SIMPLE_ACTION_SCHEMA + "tag.emulation_on", EmulationOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_read_mode", SetReadModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_off", PollingOffAction, SIMPLE_ACTION_SCHEMA, synchronous=True ) @automation.register_action( - "tag.set_write_mode", SetWriteModeAction, SIMPLE_ACTION_SCHEMA + "tag.polling_on", PollingOnAction, SIMPLE_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "tag.set_clean_mode", + SetCleanModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_format_mode", + SetFormatModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_read_mode", + SetReadModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "tag.set_write_mode", + SetWriteModeAction, + SIMPLE_ACTION_SCHEMA, + synchronous=True, ) async def pn7160_simple_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index 7c6da9dd068..e33c6c793d0 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -34,7 +34,8 @@ uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { } uint8_t PN7160I2C::write_nfcc(nfc::NciMessage &tx) { - if (this->write(tx.encode().data(), tx.encode().size()) == i2c::ERROR_OK) { + auto encoded = tx.encode(); + if (this->write(encoded.data(), encoded.size()) == i2c::ERROR_OK) { return nfc::STATUS_OK; } return nfc::STATUS_FAILED; diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 39afb407f10..86c17ce9ca0 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -8,11 +8,13 @@ from esphome.components.esp32 import ( CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, VARIANT_ESP32, VARIANT_ESP32C5, + VARIANT_ESP32C61, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, ) import esphome.config_validation as cv from esphome.const import ( @@ -22,8 +24,6 @@ from esphome.const import ( CONF_ID, CONF_MODE, CONF_SPEED, - KEY_CORE, - KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, ) from esphome.core import CORE @@ -53,6 +53,7 @@ CONF_ENABLE_ECC = "enable_ecc" SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32C5: (TYPE_QUAD,), + VARIANT_ESP32C61: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), VARIANT_ESP32P4: (TYPE_HEX,), @@ -62,6 +63,7 @@ SPIRAM_MODES = { SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32C5: (40, 80, 120), + VARIANT_ESP32C61: (40, 80), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), VARIANT_ESP32P4: (20, 100, 200), @@ -178,9 +180,6 @@ async def to_code(config): if config[CONF_MODE] == TYPE_OCTAL: cg.add_platformio_option("board_build.arduino.memory_type", "qio_opi") - add_idf_sdkconfig_option( - f"CONFIG_{get_esp32_variant().upper()}_SPIRAM_SUPPORT", True - ) add_idf_sdkconfig_option("CONFIG_SOC_SPIRAM_SUPPORTED", True) add_idf_sdkconfig_option("CONFIG_SPIRAM", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_USE", True) @@ -195,11 +194,17 @@ async def to_code(config): speed = int(config[CONF_SPEED][:-3]) add_idf_sdkconfig_option(f"CONFIG_SPIRAM_SPEED_{speed}M", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_SPEED", speed) - if config[CONF_MODE] == TYPE_OCTAL and speed == 120: - add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) - if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): + if speed == 120: + variant = get_esp32_variant() + # On chips with MSPI timing tuning, FLASH and PSRAM share the core + # clock so flash frequency must match PSRAM frequency. + # ESP32 and ESP32-S2 don't have this constraint. + if variant not in (VARIANT_ESP32, VARIANT_ESP32S2): + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) + if config[CONF_MODE] == TYPE_OCTAL and idf_version() >= cv.Version(5, 4, 0): add_idf_sdkconfig_option( - "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True + "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", + True, ) if config[CONF_ENABLE_ECC]: add_idf_sdkconfig_option("CONFIG_SPIRAM_ECC_ENABLE", True) diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 01244635679..c09d778eda2 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -155,6 +155,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), + synchronous=True, ) async def set_total_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index ca026eefa42..499b7309c88 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -105,6 +105,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.uint32_t), } ), + synchronous=True, ) async def set_total_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index 3af73b86951..fa1c3961d0f 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -88,6 +88,7 @@ CONFIG_SCHEMA = ( cv.Required(CONF_ID): cv.use_id(PZEMAC), } ), + synchronous=True, ) async def reset_energy_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 383a9dbb2c5..3291be4c341 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -72,6 +72,7 @@ CONFIG_SCHEMA = ( cv.GenerateID(CONF_ID): cv.use_id(PZEMDC), } ), + synchronous=True, ) async def reset_energy_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 9d3e655c571..bf17ac27b8f 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -163,7 +163,10 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( def register_action(name, type_, schema): validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) registerer = automation.register_action( - f"remote_transmitter.transmit_{name}", type_, validator + f"remote_transmitter.transmit_{name}", + type_, + validator, + synchronous=True, ) def decorator(func): diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 96b23bd0f52..596608a4d07 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -44,7 +44,6 @@ void RemoteReceiverComponent::setup() { channel.intr_priority = 0; channel.flags.invert_in = 0; channel.flags.with_dma = this->with_dma_; - channel.flags.io_loop_back = 0; esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_); if (error != ESP_OK) { this->error_code_ = error; diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 371dbb685f7..89019e296e5 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -120,7 +120,10 @@ DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "remote_transmitter.digital_write", DigitalWriteAction, DIGITAL_WRITE_ACTION_SCHEMA + "remote_transmitter.digital_write", + DigitalWriteAction, + DIGITAL_WRITE_ACTION_SCHEMA, + synchronous=True, ) async def digital_write_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 71773e3ddf8..3c9a12d472f 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -120,11 +120,13 @@ void RemoteTransmitterComponent::configure_rmt_() { channel.gpio_num = gpio_num_t(this->pin_->get_pin()); channel.mem_block_symbols = this->rmt_symbols_; channel.trans_queue_depth = 1; - channel.flags.io_loop_back = open_drain; - channel.flags.io_od_mode = open_drain; channel.flags.invert_out = 0; channel.flags.with_dma = this->with_dma_; channel.intr_priority = 0; +#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0) + channel.flags.io_loop_back = open_drain; + channel.flags.io_od_mode = open_drain; +#endif error = rmt_new_tx_channel(&channel, &this->channel_); if (error != ESP_OK) { this->error_code_ = error; @@ -136,6 +138,13 @@ void RemoteTransmitterComponent::configure_rmt_() { this->mark_failed(); return; } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + if (open_drain) { + gpio_num_t gpio = gpio_num_t(this->pin_->get_pin()); + gpio_od_enable(gpio); + gpio_input_enable(gpio); + } +#endif if (this->pin_->get_flags() & gpio::FLAG_PULLUP) { gpio_pullup_en(gpio_num_t(this->pin_->get_pin())); } else { diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index b4770726b4f..934f24b7890 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -114,7 +114,10 @@ RFBRIDGE_SEND_CODE_SCHEMA = cv.Schema( @automation.register_action( - "rf_bridge.send_code", RFBridgeSendCodeAction, RFBRIDGE_SEND_CODE_SCHEMA + "rf_bridge.send_code", + RFBridgeSendCodeAction, + RFBRIDGE_SEND_CODE_SCHEMA, + synchronous=True, ) async def rf_bridge_send_code_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) @@ -133,7 +136,9 @@ async def rf_bridge_send_code_to_code(config, action_id, template_args, args): RFBRIDGE_ID_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(RFBridgeComponent)}) -@automation.register_action("rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA) +@automation.register_action( + "rf_bridge.learn", RFBridgeLearnAction, RFBRIDGE_ID_SCHEMA, synchronous=True +) async def rf_bridge_learnx_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_args, paren) @@ -143,6 +148,7 @@ async def rf_bridge_learnx_to_code(config, action_id, template_args, args): "rf_bridge.start_advanced_sniffing", RFBridgeStartAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, + synchronous=True, ) async def rf_bridge_start_advanced_sniffing_to_code( config, action_id, template_args, args @@ -155,6 +161,7 @@ async def rf_bridge_start_advanced_sniffing_to_code( "rf_bridge.stop_advanced_sniffing", RFBridgeStopAdvancedSniffingAction, RFBRIDGE_ID_SCHEMA, + synchronous=True, ) async def rf_bridge_stop_advanced_sniffing_to_code( config, action_id, template_args, args @@ -167,6 +174,7 @@ async def rf_bridge_stop_advanced_sniffing_to_code( "rf_bridge.start_bucket_sniffing", RFBridgeStartBucketSniffingAction, RFBRIDGE_ID_SCHEMA, + synchronous=True, ) async def rf_bridge_start_bucket_sniffing_to_code( config, action_id, template_args, args @@ -189,6 +197,7 @@ RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA = cv.Schema( "rf_bridge.send_advanced_code", RFBridgeSendAdvancedCodeAction, RFBRIDGE_SEND_ADVANCED_CODE_SCHEMA, + synchronous=True, ) async def rf_bridge_send_advanced_code_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) @@ -211,7 +220,10 @@ RFBRIDGE_SEND_RAW_SCHEMA = cv.Schema( @automation.register_action( - "rf_bridge.send_raw", RFBridgeSendRawAction, RFBRIDGE_SEND_RAW_SCHEMA + "rf_bridge.send_raw", + RFBridgeSendRawAction, + RFBRIDGE_SEND_RAW_SCHEMA, + synchronous=True, ) async def rf_bridge_send_raw_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) @@ -229,7 +241,9 @@ RFBRIDGE_BEEP_SCHEMA = cv.Schema( ) -@automation.register_action("rf_bridge.beep", RFBridgeBeepAction, RFBRIDGE_BEEP_SCHEMA) +@automation.register_action( + "rf_bridge.beep", RFBridgeBeepAction, RFBRIDGE_BEEP_SCHEMA, synchronous=True +) async def rf_bridge_beep_to_code(config, action_id, template_args, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_args, paren) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 645b4a81c5c..be315db55d2 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -139,6 +139,7 @@ async def to_code(config): cv.Required(CONF_VALUE): cv.templatable(cv.int_), } ), + synchronous=True, ) async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 1442a0a7f74..26a1ededaab 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -1,6 +1,8 @@ import logging from pathlib import Path +import re from string import ascii_letters, digits +import subprocess import esphome.codegen as cg import esphome.config_validation as cv @@ -21,7 +23,13 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed -from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns +from .const import ( + CONF_ENABLE_FULL_PRINTF, + KEY_BOARD, + KEY_PIO_FILES, + KEY_RP2040, + rp2040_ns, +) # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -153,6 +161,7 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=8388)), ), + cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, } ), set_core_data, @@ -190,8 +199,21 @@ async def to_code(config): ], ) + # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r + # (~9.2 KB). See printf_stubs.cpp for implementation. + if config.get(CONF_ENABLE_FULL_PRINTF): + cg.add_define("USE_FULL_PRINTF") + else: + for symbol in ("vprintf", "printf", "fprintf"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + cg.add_platformio_option("board_build.core", "earlephilhower") - cg.add_platformio_option("board_build.filesystem_size", "1m") + # In testing mode, use all flash for sketch to allow linking grouped component tests. + # Real RP2040 hardware uses 1MB filesystem + 1MB sketch, but CI tests may combine + # many components that exceed the 1MB sketch partition. + cg.add_platformio_option( + "board_build.filesystem_size", "0m" if CORE.testing_mode else "1m" + ) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] cg.add_define( @@ -200,6 +222,7 @@ async def to_code(config): ) cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define("USE_RP2040_CRASH_HANDLER") def add_pio_file(component: str, key: str, data: str): @@ -254,3 +277,48 @@ def copy_files(): path = CORE.relative_src_path("esphome.h") content = read_file(path).rstrip("\n") write_file_if_changed(path, content + '\n#include "pio_includes.h"\n') + + +# RP2040 crash handler stacktrace decoding +# Matches output from esphome/components/rp2040/crash_handler.cpp +_CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT") +_CRASH_ADDR_RE = re.compile( + r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)" +) + + +def _addr2line(tool: str, elf: Path, addr: str) -> str: + try: + result = subprocess.run( + [tool, "-pfiaC", "-e", str(elf), addr], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except (OSError, subprocess.CalledProcessError): + return f"{addr} (decode failed)" + + +def process_stacktrace(config, line: str, backtrace_state: bool) -> bool: + """Decode RP2040 crash handler output using addr2line.""" + if _CRASH_RE.search(line): + _LOGGER.error("RP2040 crash detected - decoding addresses") + return True + + if backtrace_state: + if match := _CRASH_ADDR_RE.search(line): + from esphome.platformio_api import get_idedata + + idedata = get_idedata(config) + if idedata.addr2line_path: + elf = idedata.firmware_elf_path + if elf.exists(): + decoded = _addr2line(idedata.addr2line_path, elf, match.group(1)) + _LOGGER.error(" %s => %s", match.group(1), decoded) + + # Stop backtrace state after addr2line hint (last line of crash dump) + if "addr2line" in line: + return False + + return backtrace_state diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index ab5f42d7573..7eeddffc762 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -1,5 +1,6 @@ import esphome.codegen as cg +CONF_ENABLE_FULL_PRINTF = "enable_full_printf" KEY_BOARD = "board" KEY_RP2040 = "rp2040" KEY_PIO_FILES = "pio_files" diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp index 63b154d80de..7079cbca155 100644 --- a/esphome/components/rp2040/core.cpp +++ b/esphome/components/rp2040/core.cpp @@ -2,6 +2,9 @@ #include "core.h" #include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER +#include "crash_handler.h" +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -24,6 +27,9 @@ void arch_restart() { } void arch_init() { +#ifdef USE_RP2040_CRASH_HANDLER + rp2040::crash_handler_read_and_clear(); +#endif #if USE_RP2040_WATCHDOG_TIMEOUT > 0 watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); #endif diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2040/crash_handler.cpp new file mode 100644 index 00000000000..f9eb42a0f8a --- /dev/null +++ b/esphome/components/rp2040/crash_handler.cpp @@ -0,0 +1,240 @@ +#ifdef USE_RP2040 + +#include "esphome/core/defines.h" +#ifdef USE_RP2040_CRASH_HANDLER + +#include "crash_handler.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include + +// Cortex-M0+ exception frame offsets (words) +// When a fault occurs, the CPU pushes: R0, R1, R2, R3, R12, LR, PC, xPSR +static constexpr uint32_t EF_LR = 5; +static constexpr uint32_t EF_PC = 6; + +// Version encoded in the magic value: upper 16 bits are sentinel (0xDEAD), +// lower 16 bits are the version number. This avoids using a separate scratch +// register for versioning (we only have 8 total). Future firmware reads the +// sentinel to confirm it's crash data, then the version to know the layout. +static constexpr uint32_t CRASH_MAGIC_SENTINEL = 0xDEAD0000; +static constexpr uint32_t CRASH_DATA_VERSION = 1; +static constexpr uint32_t CRASH_MAGIC_V1 = CRASH_MAGIC_SENTINEL | CRASH_DATA_VERSION; + +// We only have 8 scratch registers (32 bytes) that survive watchdog reboot. +// Use them for the most important data, then scan the stack for code addresses. +// +// Scratch register layout: +// [0] = versioned magic (upper 16 bits = 0xDEAD sentinel, lower 16 bits = version) +// [1] = PC (program counter at fault) +// [2] = LR (link register from exception frame) +// [3] = SP (stack pointer at fault) +// [4..7] = up to 4 additional code addresses found by scanning the stack +// (return addresses from callers, giving a deeper backtrace) + +// Flash is mapped at XIP_BASE (0x10000000). We use a conservative upper bound +// to keep false positives low during stack scanning. Wider ranges would match +// more stale data on the stack that happens to look like code addresses. +#if defined(PICO_RP2350) +static constexpr uint32_t FLASH_SCAN_END = XIP_BASE + 0x400000; // 4MB — RP2350 typical max +#else +static constexpr uint32_t FLASH_SCAN_END = XIP_BASE + 0x200000; // 2MB — RP2040 typical max +#endif + +static inline bool is_code_addr(uint32_t val) { + uint32_t cleared = val & ~1u; // Clear Thumb bit + return cleared >= XIP_BASE && cleared < FLASH_SCAN_END; +} + +static constexpr size_t MAX_BACKTRACE = 4; + +namespace esphome::rp2040 { + +static const char *const TAG = "rp2040.crash"; + +// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). +// The valid field is explicitly cleared in crash_handler_read_and_clear() instead. +static struct CrashData { + bool valid; + uint32_t pc; + uint32_t lr; + uint32_t sp; + uint32_t backtrace[MAX_BACKTRACE]; + uint8_t backtrace_count; +} s_crash_data __attribute__((section(".noinit"))); + +bool crash_handler_has_data() { return s_crash_data.valid; } + +void crash_handler_read_and_clear() { + s_crash_data.valid = false; + uint32_t magic = watchdog_hw->scratch[0]; + if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) { + s_crash_data.valid = true; + s_crash_data.pc = watchdog_hw->scratch[1]; + s_crash_data.lr = watchdog_hw->scratch[2]; + s_crash_data.sp = watchdog_hw->scratch[3]; + s_crash_data.backtrace_count = 0; + for (size_t i = 0; i < MAX_BACKTRACE; i++) { + uint32_t addr = watchdog_hw->scratch[4 + i]; + if (addr == 0) + break; + s_crash_data.backtrace[i] = addr; + s_crash_data.backtrace_count++; + } + } + // Clear scratch registers regardless + for (int i = 0; i < 8; i++) { + watchdog_hw->scratch[i] = 0; + } +} + +// Intentionally uses separate ESP_LOGE calls per line instead of combining into +// one multi-line log message. This ensures each address appears as its own line +// on the serial console (miniterm), making it possible to see partial output if +// the device crashes again during boot, and allowing the CLI's process_stacktrace +// to match and decode each address individually. +void crash_handler_log() { + if (!s_crash_data.valid) + return; + + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_crash_data.pc); + ESP_LOGE(TAG, " LR: 0x%08" PRIX32 " (return address)", s_crash_data.lr); + ESP_LOGE(TAG, " SP: 0x%08" PRIX32, s_crash_data.sp); + for (uint8_t i = 0; i < s_crash_data.backtrace_count; i++) { + ESP_LOGE(TAG, " BT%d: 0x%08" PRIX32 " (stack backtrace)", i, s_crash_data.backtrace[i]); + } + // Build addr2line hint with all captured addresses for easy copy-paste + char hint[160]; + int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32 " 0x%08" PRIX32, + s_crash_data.pc, s_crash_data.lr); + for (uint8_t i = 0; i < s_crash_data.backtrace_count && pos < (int) sizeof(hint) - 12; i++) { + pos += snprintf(hint + pos, sizeof(hint) - pos, " 0x%08" PRIX32, s_crash_data.backtrace[i]); + } + ESP_LOGE(TAG, "%s", hint); +} + +} // namespace esphome::rp2040 + +// --- HardFault handler --- +// Overrides the weak isr_hardfault from arduino-pico's crt0.S. +// On Cortex-M0+, the CPU pushes {R0,R1,R2,R3,R12,LR,PC,xPSR} onto the +// active stack (MSP or PSP). We determine which stack was active, +// extract key registers, store them in watchdog scratch registers +// (which survive watchdog reboot), then trigger a reboot. + +// Check if a pointer falls within SRAM (valid for stack access). +// SRAM_BASE and SRAM_END are chip-specific SDK defines: +// RP2040: 0x20000000 - 0x20042000 (264KB) +// RP2350: 0x20000000 - 0x20082000 (520KB) +static inline bool is_valid_sram_ptr(const uint32_t *ptr) { + auto addr = reinterpret_cast(ptr); + // Exception frame is 8 words (32 bytes), so frame+7 must also be in SRAM. + // Check alignment (must be word-aligned) and that the full frame fits. + return (addr % 4 == 0) && addr >= SRAM_BASE && (addr + 32) <= SRAM_END; +} + +// C handler called from the asm wrapper with the exception frame pointer. +static void __attribute__((used, noreturn)) hard_fault_handler_c(uint32_t *frame, uint32_t /*exc_return*/) { + // watchdog_reboot() overwrites scratch[4]-[7], so we must call it first + // then write ALL our data after. The 10ms timeout gives us plenty of time. + watchdog_reboot(0, 0, 10); + + // Validate frame pointer before dereferencing. If the HardFault was caused + // by a stacking error or corrupted SP, frame may be invalid. Write a minimal + // crash marker so we at least know a crash occurred. + if (!is_valid_sram_ptr(frame)) { + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; + watchdog_hw->scratch[1] = 0; // PC unknown + watchdog_hw->scratch[2] = 0; // LR unknown + watchdog_hw->scratch[3] = reinterpret_cast(frame); // Record the bad SP for diagnosis + for (uint32_t i = 0; i < MAX_BACKTRACE; i++) { + watchdog_hw->scratch[4 + i] = 0; + } + while (true) { + __asm volatile("nop"); + } + } + + // Pre-fault SP: the exception frame is 8 words pushed onto the stack, + // so the SP before the fault was frame + 8 words. If xPSR bit 9 is set, + // the hardware pushed an extra alignment word to maintain 8-byte stack + // alignment (ARMv6-M/ARMv7-M spec), so add 1 more word. + static constexpr uint32_t EF_XPSR = 7; + uint32_t extra_align = (frame[EF_XPSR] & (1u << 9)) ? 1 : 0; + uint32_t *post_frame = frame + 8 + extra_align; + uint32_t pre_fault_sp = reinterpret_cast(post_frame); + + // Write key registers + watchdog_hw->scratch[0] = CRASH_MAGIC_V1; + watchdog_hw->scratch[1] = frame[EF_PC]; + watchdog_hw->scratch[2] = frame[EF_LR]; + watchdog_hw->scratch[3] = pre_fault_sp; + + // Scan stack for code addresses to build a deeper backtrace. + // The exception frame is 8 words (32 bytes) at 'frame', plus an optional + // alignment word. Walk up to 64 words looking for return addresses. + uint32_t *scan_start = post_frame; + // SRAM_END is chip-specific: 0x20042000 (RP2040) or 0x20082000 (RP2350) + uint32_t *stack_top = reinterpret_cast(SRAM_END); + // Scan up to 64 words (256 bytes) — covers typical nested call frames + // without scanning too much stale stack data that could produce false positives. + uint32_t bt_count = 0; + + for (uint32_t *p = scan_start; p < stack_top && p < scan_start + 64 && bt_count < MAX_BACKTRACE; p++) { + uint32_t val = *p; + // Check if this looks like a code address in flash + // Skip if it's the same as PC or LR we already saved + if (is_code_addr(val) && val != frame[EF_PC] && val != frame[EF_LR]) { + watchdog_hw->scratch[4 + bt_count] = val; + bt_count++; + } + } + // Zero remaining slots + for (uint32_t i = bt_count; i < MAX_BACKTRACE; i++) { + watchdog_hw->scratch[4 + i] = 0; + } + + while (true) { + __asm volatile("nop"); + } +} + +// Naked asm wrapper - Cortex-M0+ compatible (no ITE/conditional execution). +// Determines active stack pointer and branches to C handler. +// Uses literal pool (.word) for addresses since M0+ has limited immediate encoding. +// +// Based on the standard Cortex-M0+ HardFault handler pattern described in: +// - ARM Application Note AN209: "Using Cortex-M3/M4/M7 Fault Exceptions" +// (adapted for M0+ which lacks conditional execution instructions) +// - Memfault: "How to debug a HardFault on an ARM Cortex-M MCU" +// https://interrupt.memfault.com/blog/cortex-m-hardfault-debug +// - Raspberry Pi Forums: "Cortex-M0+ Hard Fault handler porting" +// https://www.eevblog.com/forum/microcontrollers/cortex-m0-hard-fault-handler-porting/ +// +// The key M0+ adaptation: replaces ITE/MRSEQ/MRSNE (Cortex-M3+) with +// MOVS+TST+BEQ branch sequence, and uses a literal pool for the C handler address. +extern "C" void __attribute__((naked, used)) isr_hardfault() { + __asm volatile("movs r0, #4 \n" // Prepare bit 2 mask + "mov r1, lr \n" // r1 = EXC_RETURN + "tst r1, r0 \n" // Test bit 2 + "beq 1f \n" // If 0, was using MSP + "mrs r0, psp \n" // Bit 2 set = PSP was active + "b 2f \n" + "1: \n" + "mrs r0, msp \n" // Bit 2 clear = MSP was active + "2: \n" + // r0 = exception frame pointer, r1 = EXC_RETURN (still in r1) + "ldr r2, 3f \n" // Load C handler address from literal pool + "bx r2 \n" // Branch to handler (r0=frame, r1=exc_return) + ".align 2 \n" + "3: .word %c0 \n" // Literal pool: address of C handler + : + : "i"(hard_fault_handler_c)); +} + +#endif // USE_RP2040_CRASH_HANDLER +#endif // USE_RP2040 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2040/crash_handler.h new file mode 100644 index 00000000000..78e8ede08c8 --- /dev/null +++ b/esphome/components/rp2040/crash_handler.h @@ -0,0 +1,23 @@ +#pragma once + +#ifdef USE_RP2040 + +#include "esphome/core/defines.h" + +#ifdef USE_RP2040_CRASH_HANDLER + +namespace esphome::rp2040 { + +/// Read crash data from watchdog scratch registers and clear them. +void crash_handler_read_and_clear(); + +/// Log crash data if a crash was detected on previous boot. +void crash_handler_log(); + +/// Returns true if crash data was found this boot. +bool crash_handler_has_data(); + +} // namespace esphome::rp2040 + +#endif // USE_RP2040_CRASH_HANDLER +#endif // USE_RP2040 diff --git a/esphome/components/rp2040/generate_boards.py b/esphome/components/rp2040/generate_boards.py index a0e3699f37b..7ea02d185e9 100644 --- a/esphome/components/rp2040/generate_boards.py +++ b/esphome/components/rp2040/generate_boards.py @@ -6,6 +6,7 @@ Usage: python esphome/components/rp2040/generate_boards.py import json from pathlib import Path import re +import subprocess import sys from jinja2 import Environment, FileSystemLoader @@ -157,7 +158,7 @@ def generate(arduino_pico_path: Path) -> str: board_pins, boards = load_boards(arduino_pico_path) template = _jinja_env.get_template("boards.jinja2") - return template.render( + content = template.render( cyw43_gpio_offset=CYW43_GPIO_OFFSET, cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1, default_max_pin=DEFAULT_MAX_PIN, @@ -165,6 +166,15 @@ def generate(arduino_pico_path: Path) -> str: boards=sorted(boards.items()), ) + # Format output to match pre-commit ruff formatting + result = subprocess.run( + [sys.executable, "-m", "ruff", "format", "--stdin-filename", "boards.py"], + input=content.encode(), + capture_output=True, + check=True, + ) + return result.stdout.decode() + def main(): if len(sys.argv) < 2: diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2040/helpers.cpp index 30b40a723a3..a9888eb7aeb 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2040/helpers.cpp @@ -7,6 +7,7 @@ #if defined(USE_WIFI) #include +#include // For cyw43_arch_lwip_begin/end (LwIPLock) #endif #include #include @@ -34,19 +35,27 @@ bool random_bytes(uint8_t *data, size_t len) { return true; } -// RP2040 doesn't have mutexes, but that shouldn't be an issue as it's single-core and non-preemptive OS. -Mutex::Mutex() {} -Mutex::~Mutex() {} -void Mutex::lock() {} -bool Mutex::try_lock() { return true; } -void Mutex::unlock() {} +// RP2040 Mutex is defined inline in helpers.h for RP2040/ESP8266 builds. IRAM_ATTR InterruptLock::InterruptLock() { state_ = save_and_disable_interrupts(); } IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); } -// RP2040 doesn't support lwIP core locking, so this is a no-op +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks run from a low-priority user IRQ context, not the +// main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). cyw43_arch_lwip_begin/end acquires the +// async_context recursive mutex to prevent IRQ callbacks from firing during +// critical sections. See esphome#10681. +// +// When CYW43 is not available (non-WiFi RP2040 boards), LwIPLock is +// defined inline as a no-op in helpers.h. +#if defined(USE_WIFI) +LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); } +LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); } +#else LwIPLock::LwIPLock() {} LwIPLock::~LwIPLock() {} +#endif void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) #ifdef USE_WIFI diff --git a/esphome/components/rp2040/printf_stubs.cpp b/esphome/components/rp2040/printf_stubs.cpp new file mode 100644 index 00000000000..c2174a1dece --- /dev/null +++ b/esphome/components/rp2040/printf_stubs.cpp @@ -0,0 +1,74 @@ +/* + * Linker wrap stubs for FILE*-based printf functions. + * + * The RP2040 Arduino framework and libraries may reference printf(), + * vprintf(), and fprintf() which pull in newlib's _vfprintf_r (~8.9 KB). + * ESPHome never uses these — all logging goes through the logger component + * which uses snprintf/vsnprintf, so the libc FILE*-based printf path is + * dead code. + * + * These stubs redirect through vsnprintf() (which is already in the binary) + * and fwrite(), allowing the linker to dead-code eliminate _vfprintf_r. + * + * Saves ~8.9 KB of flash. + */ + +#if defined(USE_RP2040) && !defined(USE_FULL_PRINTF) +#include +#include +#include + +namespace esphome::rp2040 {} + +static constexpr size_t PRINTF_BUFFER_SIZE = 512; + +// These stubs are essentially dead code at runtime — ESPHome uses its own +// logging through snprintf/vsnprintf, not libc printf. +// The buffer overflow check is purely defensive and should never trigger. +static int write_printf_buffer(FILE *stream, char *buf, int len) { + if (len < 0) { + return len; + } + size_t write_len = len; + if (write_len >= PRINTF_BUFFER_SIZE) { + fwrite(buf, 1, PRINTF_BUFFER_SIZE - 1, stream); + // Use fwrite for the message to avoid recursive __wrap_printf call + static const char msg[] = "\nprintf buffer overflow\n"; + fwrite(msg, 1, sizeof(msg) - 1, stream); + abort(); + } + if (fwrite(buf, 1, write_len, stream) < write_len || ferror(stream)) { + return -1; + } + return len; +} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vprintf(const char *fmt, va_list ap) { + char buf[PRINTF_BUFFER_SIZE]; + return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); +} + +int __wrap_printf(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + int len = __wrap_vprintf(fmt, ap); + va_end(ap); + return len; +} + +int __wrap_fprintf(FILE *stream, const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char buf[PRINTF_BUFFER_SIZE]; + int len = write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap)); + va_end(ap); + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_RP2040 && !USE_FULL_PRINTF diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py new file mode 100644 index 00000000000..648f22691c4 --- /dev/null +++ b/esphome/components/rp2040_ble/__init__.py @@ -0,0 +1,31 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["rp2040"] +CODEOWNERS = ["@bdraco"] + +rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") +RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(RP2040BLE), + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable Bluetooth in the arduino-pico build + # This switches linking from liblwip.a to liblwip-bt.a and defines + # ENABLE_CLASSIC, ENABLE_BLE, CYW43_ENABLE_BLUETOOTH + cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH") + + cg.add_define("USE_RP2040_BLE") diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp new file mode 100644 index 00000000000..4125da7ec01 --- /dev/null +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -0,0 +1,124 @@ +#include "rp2040_ble.h" + +#ifdef USE_RP2040_BLE + +#include "esphome/core/log.h" + +namespace esphome::rp2040_ble { + +static const char *const TAG = "rp2040_ble"; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +RP2040BLE *global_ble = nullptr; + +void RP2040BLE::setup() { + global_ble = this; + + if (this->enable_on_boot_) { + this->enable(); + } else { + this->state_ = BLEComponentState::DISABLED; + } +} + +void RP2040BLE::enable() { + if (this->state_ == BLEComponentState::ACTIVE || this->state_ == BLEComponentState::ENABLING) { + return; + } + + ESP_LOGD(TAG, "Enabling BLE..."); + this->state_ = BLEComponentState::ENABLING; + this->active_logged_ = false; + + if (!this->btstack_initialized_) { + // BTstack init functions are not idempotent — only call once + l2cap_init(); + sm_init(); + + this->hci_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + hci_add_event_handler(&this->hci_event_callback_registration_); + + this->sm_event_callback_registration_.callback = &RP2040BLE::packet_handler_; + sm_add_event_handler(&this->sm_event_callback_registration_); + + this->btstack_initialized_ = true; + } + + hci_power_control(HCI_POWER_ON); +} + +void RP2040BLE::disable() { + if (this->state_ == BLEComponentState::DISABLED || this->state_ == BLEComponentState::OFF) { + return; + } + + ESP_LOGD(TAG, "Disabling BLE..."); + this->state_ = BLEComponentState::DISABLING; + + hci_power_control(HCI_POWER_OFF); + + this->state_ = BLEComponentState::DISABLED; + ESP_LOGD(TAG, "BLE disabled"); +} + +void RP2040BLE::loop() { + if (this->state_ == BLEComponentState::ACTIVE && !this->active_logged_) { + this->active_logged_ = true; + ESP_LOGI(TAG, "BLE active"); + } +} + +static const char *state_to_str(BLEComponentState state) { + switch (state) { + case BLEComponentState::OFF: + return "OFF"; + case BLEComponentState::ENABLING: + return "ENABLING"; + case BLEComponentState::ACTIVE: + return "ACTIVE"; + case BLEComponentState::DISABLING: + return "DISABLING"; + case BLEComponentState::DISABLED: + return "DISABLED"; + default: + return "UNKNOWN"; + } +} + +void RP2040BLE::dump_config() { + ESP_LOGCONFIG(TAG, + "RP2040 BLE:\n" + " Enable on boot: %s\n" + " State: %s", + YESNO(this->enable_on_boot_), state_to_str(this->state_)); +} + +float RP2040BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } + +void RP2040BLE::packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (global_ble == nullptr) { + return; + } + + if (type != HCI_EVENT_PACKET) { + return; + } + + uint8_t event_type = hci_event_packet_get_type(packet); + + switch (event_type) { + case BTSTACK_EVENT_STATE: { + uint8_t state = btstack_event_state_get_state(packet); + if (state == HCI_STATE_WORKING && global_ble->state_ == BLEComponentState::ENABLING) { + global_ble->state_ = BLEComponentState::ACTIVE; + } + break; + } + default: + break; + } +} + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h new file mode 100644 index 00000000000..24b3860cc1e --- /dev/null +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" // Must be included before conditional includes + +#ifdef USE_RP2040_BLE + +#include "esphome/core/component.h" + +#include + +namespace esphome::rp2040_ble { + +enum class BLEComponentState : uint8_t { + OFF = 0, + ENABLING, + ACTIVE, + DISABLING, + DISABLED, +}; + +class RP2040BLE : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void enable(); + void disable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + protected: + static void packet_handler_(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + + btstack_packet_callback_registration_t hci_event_callback_registration_{}; + btstack_packet_callback_registration_t sm_event_callback_registration_{}; + + BLEComponentState state_{BLEComponentState::OFF}; + bool enable_on_boot_{true}; + bool btstack_initialized_{false}; + bool active_logged_{false}; +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern RP2040BLE *global_ble; + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index 441a52de7f9..4ea488a6cd1 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -42,6 +42,7 @@ async def to_code(config): cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency), } ), + synchronous=True, ) async def rp2040_set_frequency_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 19412bb4547..35667342006 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -117,6 +117,7 @@ async def to_code(config): }, key=CONF_RTTTL, ), + synchronous=True, ) async def rtttl_play_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -134,6 +135,7 @@ async def rtttl_play_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(Rtttl), } ), + synchronous=True, ) async def rtttl_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 9bf0450993c..01f5aad8109 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -146,16 +146,19 @@ void Rtttl::loop() { } #endif // USE_SPEAKER + // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case + while (this->position_ < this->rtttl_.length()) { + char c = this->rtttl_[this->position_]; + if (c != ',' && c != ' ') + break; + this->position_++; + } + if (this->position_ >= this->rtttl_.length()) { this->finish_(); return; } - // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case - while (this->rtttl_[this->position_] == ',' || this->rtttl_[this->position_] == ' ') { - this->position_++; - } - // First, get note duration, if available uint8_t note_denominator = this->get_integer_(); diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 0773a53d911..7c22bfc9d19 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -74,7 +74,7 @@ class JPEGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_JPEG") - cg.add_library("JPEGDEC", None, "https://github.com/bitbank2/JPEGDEC#ca1e0f2") + cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4") class PNGFormat(Format): diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 1a56484c606..174f924b285 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -12,7 +12,10 @@ static const char *const TAG = "image_decoder.bmp"; int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { size_t index = 0; - if (this->current_index_ == 0 && index == 0 && size > 14) { + if (this->current_index_ == 0) { + if (size <= 14) { + return 0; // Need more data for file header + } /** * BMP file format: * 0-1: Signature (BM) @@ -39,7 +42,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { this->current_index_ = 14; index = 14; } - if (this->current_index_ == 14 && index == 14 && size > this->data_offset_) { + if (this->current_index_ == 14) { + if (size <= this->data_offset_) { + return 0; // Need more data for DIB header and color table + } /** * BMP DIB header: * 14-17: DIB header size @@ -63,8 +69,31 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { switch (this->bits_per_pixel_) { case 1: - this->width_bytes_ = (this->width_ % 8 == 0) ? (this->width_ / 8) : (this->width_ / 8 + 1); + this->width_bytes_ = (this->width_ + 7) / 8; + this->padding_bytes_ = (4 - (this->width_bytes_ % 4)) % 4; break; + case 8: { + this->width_bytes_ = this->width_; + if (this->color_table_entries_ == 0) { + this->color_table_entries_ = 256; + } else if (this->color_table_entries_ > 256) { + ESP_LOGE(TAG, "Too many color table entries: %" PRIu32, this->color_table_entries_); + return DECODE_ERROR_UNSUPPORTED_FORMAT; + } + size_t header_size = encode_uint32(buffer[17], buffer[16], buffer[15], buffer[14]); + size_t offset = 14 + header_size; + + this->color_table_ = std::make_unique(this->color_table_entries_); + + for (size_t i = 0; i < this->color_table_entries_; i++) { + this->color_table_[i] = encode_uint32(buffer[offset + i * 4 + 3], buffer[offset + i * 4 + 2], + buffer[offset + i * 4 + 1], buffer[offset + i * 4]); + } + + this->padding_bytes_ = (4 - (this->width_bytes_ % 4)) % 4; + + break; + } case 24: this->width_bytes_ = this->width_ * 3; if (this->width_bytes_ % 4 != 0) { @@ -90,37 +119,85 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { } switch (this->bits_per_pixel_) { case 1: { + size_t width = static_cast(this->width_); while (index < size) { - uint8_t current_byte = buffer[index]; - for (uint8_t i = 0; i < 8; i++) { - size_t x = (this->paint_index_ % static_cast(this->width_)) + i; - size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / static_cast(this->width_)); - Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF; - this->draw(x, y, 1, 1, c); + size_t x = this->paint_index_ % width; + size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / width); + size_t remaining_in_row = width - x; + uint8_t pixels_in_byte = std::min(remaining_in_row, 8); + bool end_of_row = remaining_in_row <= 8; + size_t needed = 1 + (end_of_row ? this->padding_bytes_ : 0); + if (index + needed > size) { + this->decoded_bytes_ += index; + return index; } - this->paint_index_ += 8; + uint8_t current_byte = buffer[index]; + for (uint8_t i = 0; i < pixels_in_byte; i++) { + Color c = (current_byte & (1 << (7 - i))) ? display::COLOR_ON : display::COLOR_OFF; + this->draw(x + i, y, 1, 1, c); + } + this->paint_index_ += pixels_in_byte; this->current_index_++; index++; + // End of pixel row: skip row padding bytes (4-byte alignment) + if (end_of_row && this->padding_bytes_ > 0) { + index += this->padding_bytes_; + this->current_index_ += this->padding_bytes_; + } + } + break; + } + case 8: { + size_t width = static_cast(this->width_); + size_t last_col = width - 1; + while (index < size) { + size_t x = this->paint_index_ % width; + size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / width); + size_t needed = 1 + ((x == last_col) ? this->padding_bytes_ : 0); + if (index + needed > size) { + this->decoded_bytes_ += index; + return index; + } + + uint8_t color_index = buffer[index]; + if (color_index >= this->color_table_entries_) { + ESP_LOGE(TAG, "Invalid color index: %u", color_index); + return DECODE_ERROR_UNSUPPORTED_FORMAT; + } + + uint32_t rgb = this->color_table_[color_index]; + uint8_t b = rgb & 0xff; + uint8_t g = (rgb >> 8) & 0xff; + uint8_t r = (rgb >> 16) & 0xff; + this->draw(x, y, 1, 1, Color(r, g, b)); + this->paint_index_++; + this->current_index_++; + index++; + if (x == last_col && this->padding_bytes_ > 0) { + index += this->padding_bytes_; + this->current_index_ += this->padding_bytes_; + } } break; } case 24: { + size_t width = static_cast(this->width_); + size_t last_col = width - 1; while (index < size) { - if (index + 2 >= size) { + size_t x = this->paint_index_ % width; + size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / width); + size_t needed = 3 + ((x == last_col) ? this->padding_bytes_ : 0); + if (index + needed > size) { this->decoded_bytes_ += index; return index; } uint8_t b = buffer[index]; uint8_t g = buffer[index + 1]; uint8_t r = buffer[index + 2]; - size_t x = this->paint_index_ % static_cast(this->width_); - size_t y = static_cast(this->height_ - 1) - (this->paint_index_ / static_cast(this->width_)); - Color c = Color(r, g, b); - this->draw(x, y, 1, 1, c); + this->draw(x, y, 1, 1, Color(r, g, b)); this->paint_index_++; this->current_index_ += 3; index += 3; - size_t last_col = static_cast(this->width_) - 1; if (x == last_col && this->padding_bytes_ > 0) { index += this->padding_bytes_; this->current_index_ += this->padding_bytes_; diff --git a/esphome/components/runtime_image/bmp_decoder.h b/esphome/components/runtime_image/bmp_decoder.h index 37db6b49405..a52a5615849 100644 --- a/esphome/components/runtime_image/bmp_decoder.h +++ b/esphome/components/runtime_image/bmp_decoder.h @@ -3,6 +3,9 @@ #include "esphome/core/defines.h" #ifdef USE_RUNTIME_IMAGE_BMP +#include +#include + #include "image_decoder.h" #include "runtime_image.h" @@ -23,6 +26,10 @@ class BmpDecoder : public ImageDecoder { int HOT decode(uint8_t *buffer, size_t size) override; bool is_finished() const override { + if (this->bits_per_pixel_ == 0) { + // header not yet received, so dimensions not yet determined + return false; + } // BMP is finished when we've decoded all pixel data return this->paint_index_ >= static_cast(this->width_ * this->height_); } @@ -36,6 +43,7 @@ class BmpDecoder : public ImageDecoder { uint32_t compression_method_{0}; uint32_t image_data_size_{0}; uint32_t color_table_entries_{0}; + std::unique_ptr color_table_; size_t width_bytes_{0}; size_t data_offset_{0}; uint8_t padding_bytes_{0}; diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index bf088873ce0..07f870b60cc 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -21,11 +21,11 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP const float temperature = temp_sign == 0 ? temp_val : -1 * temp_val; const float humidity = data[0] * 0.5f; - const float pressure = (uint16_t(data[3] << 8) + uint16_t(data[4]) + 50000.0f) / 100.0f; - const float acceleration_x = (int16_t(data[5] << 8) + int16_t(data[6])) / 1000.0f; - const float acceleration_y = (int16_t(data[7] << 8) + int16_t(data[8])) / 1000.0f; - const float acceleration_z = (int16_t(data[9] << 8) + int16_t(data[10])) / 1000.0f; - const float battery_voltage = (uint16_t(data[11] << 8) + uint16_t(data[12])) / 1000.0f; + const float pressure = (encode_uint16(data[3], data[4]) + 50000.0f) / 100.0f; + const float acceleration_x = static_cast(encode_uint16(data[5], data[6])) / 1000.0f; + const float acceleration_y = static_cast(encode_uint16(data[7], data[8])) / 1000.0f; + const float acceleration_z = static_cast(encode_uint16(data[9], data[10])) / 1000.0f; + const float battery_voltage = encode_uint16(data[11], data[12]) / 1000.0f; result.humidity = humidity; result.temperature = temperature; @@ -43,19 +43,19 @@ bool parse_ruuvi_data_byte(const esp32_ble_tracker::adv_data_t &adv_data, RuuviP if (adv_data.size() != 24) return false; - const float temperature = (int16_t(data[0] << 8) + int16_t(data[1])) * 0.005f; - const float humidity = (uint16_t(data[2] << 8) | uint16_t(data[3])) / 400.0f; - const float pressure = ((uint16_t(data[4] << 8) | uint16_t(data[5])) + 50000.0f) / 100.0f; - const float acceleration_x = (int16_t(data[6] << 8) + int16_t(data[7])) / 1000.0f; - const float acceleration_y = (int16_t(data[8] << 8) + int16_t(data[9])) / 1000.0f; - const float acceleration_z = (int16_t(data[10] << 8) + int16_t(data[11])) / 1000.0f; + const float temperature = static_cast(encode_uint16(data[0], data[1])) * 0.005f; + const float humidity = encode_uint16(data[2], data[3]) / 400.0f; + const float pressure = (encode_uint16(data[4], data[5]) + 50000.0f) / 100.0f; + const float acceleration_x = static_cast(encode_uint16(data[6], data[7])) / 1000.0f; + const float acceleration_y = static_cast(encode_uint16(data[8], data[9])) / 1000.0f; + const float acceleration_z = static_cast(encode_uint16(data[10], data[11])) / 1000.0f; - const uint16_t power_info = (uint16_t(data[12] << 8) | data[13]); + const uint16_t power_info = encode_uint16(data[12], data[13]); const float battery_voltage = ((power_info >> 5) + 1600.0f) / 1000.0f; const float tx_power = ((power_info & 0x1F) * 2.0f) - 40.0f; const float movement_counter = float(data[14]); - const float measurement_sequence_number = float(uint16_t(data[15] << 8) | uint16_t(data[16])); + const float measurement_sequence_number = float(encode_uint16(data[15], data[16])); result.temperature = data[0] == 0x7F && data[1] == 0xFF ? NAN : temperature; result.humidity = data[2] == 0xFF && data[3] == 0xFF ? NAN : humidity; diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index ba092a48340..9e6f05ee15c 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -75,7 +75,7 @@ void RX8130Component::read_time() { .second = bcd2dec(date[0] & 0x7f), .minute = bcd2dec(date[1] & 0x7f), .hour = bcd2dec(date[2] & 0x3f), - .day_of_week = bcd2dec(date[3] & 0x7f), + .day_of_week = static_cast((date[3] & 0x7f) ? __builtin_ctz(date[3] & 0x7f) + 1 : 1), .day_of_month = bcd2dec(date[4] & 0x3f), .day_of_year = 1, // ignored by recalc_timestamp_utc(false) .month = bcd2dec(date[5] & 0x1f), @@ -103,7 +103,7 @@ void RX8130Component::write_time() { buff[0] = dec2bcd(now.second); buff[1] = dec2bcd(now.minute); buff[2] = dec2bcd(now.hour); - buff[3] = dec2bcd(now.day_of_week); + buff[3] = 1 << (now.day_of_week - 1); buff[4] = dec2bcd(now.day_of_month); buff[5] = dec2bcd(now.month); buff[6] = dec2bcd(now.year % 100); diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py index cb0402bd323..4f6310358c5 100644 --- a/esphome/components/rx8130/time.py +++ b/esphome/components/rx8130/time.py @@ -27,6 +27,7 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( cv.GenerateID(): cv.use_id(RX8130Component), } ), + synchronous=True, ) async def rx8130_write_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -42,6 +43,7 @@ async def rx8130_write_time_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(RX8130Component), } ), + synchronous=True, ) async def rx8130_read_time_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index f54151b7460..e868985054e 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -62,6 +62,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.use_id(SafeModeComponent), } ), + synchronous=True, ) async def safe_mode_mark_successful_to_code(config, action_id, template_arg, args): parent = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/scd30/sensor.py b/esphome/components/scd30/sensor.py index 194df8ec4f5..f60e913a0cb 100644 --- a/esphome/components/scd30/sensor.py +++ b/esphome/components/scd30/sensor.py @@ -128,6 +128,7 @@ async def to_code(config): }, key=CONF_VALUE, ), + synchronous=True, ) async def scd30_force_recalibration_with_reference_to_code( config, action_id, template_arg, args diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index ec90234ac39..6f14118660a 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -141,6 +141,7 @@ SCD4X_ACTION_SCHEMA = maybe_simple_id( "scd4x.perform_forced_calibration", PerformForcedCalibrationAction, SCD4X_ACTION_SCHEMA, + synchronous=True, ) async def scd4x_frc_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -158,7 +159,10 @@ SCD4X_RESET_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "scd4x.factory_reset", FactoryResetAction, SCD4X_RESET_ACTION_SCHEMA + "scd4x.factory_reset", + FactoryResetAction, + SCD4X_RESET_ACTION_SCHEMA, + synchronous=True, ) async def scd4x_reset_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 369cefad91a..51cae695b76 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -221,6 +221,7 @@ async def script_stop_action_to_code(config, action_id, template_arg, args): "script.wait", ScriptWaitAction, maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), + synchronous=False, ) async def script_wait_action_to_code(config, action_id, template_arg, args): full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 99d519b434d..263603704ac 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -452,7 +452,8 @@ void MR24HPC1Component::r24_frame_parse_open_underlying_information_(uint8_t *da } break; case 0x83: - if (this->custom_presence_of_detection_sensor_ != nullptr) { + if (this->custom_presence_of_detection_sensor_ != nullptr && + data[FRAME_DATA_INDEX] < std::size(S_PRESENCE_OF_DETECTION_RANGE_STR)) { this->custom_presence_of_detection_sensor_->publish_state( S_PRESENCE_OF_DETECTION_RANGE_STR[data[FRAME_DATA_INDEX]]); } @@ -646,7 +647,7 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { #ifdef USE_BINARY_SENSOR case 0x01: case 0x81: - if (this->has_target_binary_sensor_ != nullptr) { + if (this->has_target_binary_sensor_ != nullptr && data[FRAME_DATA_INDEX] < std::size(S_SOMEONE_EXISTS_STR)) { this->has_target_binary_sensor_->publish_state(S_SOMEONE_EXISTS_STR[data[FRAME_DATA_INDEX]]); } break; diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index 8628faac5ae..1b5eaf63679 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -199,7 +199,7 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c } break; case DISTANCE_TYPE_BUFFER: - if (data[0] != 0) { + if (length >= 1 && data[0] != 0) { if (this->distance_sensor_ != nullptr && length >= 8) { uint32_t current_distance_int = encode_uint32(data[7], data[6], data[5], data[4]); float distance_float; diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index c6527a948ef..e24e9b338e6 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -334,6 +334,8 @@ void MR60FDA2Component::process_frame_() { // Send Heartbeat Packet Command void MR60FDA2Component::set_install_height(uint8_t index) { + if (index >= std::size(INSTALL_HEIGHT)) + return; uint8_t send_data[13] = {0x01, 0x00, 0x00, 0x00, 0x04, 0x0E, 0x04, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00}; float_to_bytes(INSTALL_HEIGHT[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); @@ -345,6 +347,8 @@ void MR60FDA2Component::set_install_height(uint8_t index) { } void MR60FDA2Component::set_height_threshold(uint8_t index) { + if (index >= std::size(HEIGHT_THRESHOLD)) + return; uint8_t send_data[13] = {0x01, 0x00, 0x00, 0x00, 0x04, 0x0E, 0x08, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00}; float_to_bytes(HEIGHT_THRESHOLD[index], &send_data[8]); send_data[12] = calculate_checksum(send_data + 8, 4); @@ -356,6 +360,8 @@ void MR60FDA2Component::set_height_threshold(uint8_t index) { } void MR60FDA2Component::set_sensitivity(uint8_t index) { + if (index >= std::size(SENSITIVITY)) + return; uint8_t send_data[13] = {0x01, 0x00, 0x00, 0x00, 0x04, 0x0E, 0x0A, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00}; int_to_bytes(SENSITIVITY[index], &send_data[8]); diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 45fb42c1160..83f5052fc8a 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -41,7 +41,7 @@ SelectCall &SelectCall::with_index(size_t index) { this->operation_ = SELECT_OP_SET; if (index >= this->parent_->size()) { ESP_LOGW(TAG, "'%s' - Index value %zu out of bounds", this->parent_->get_name().c_str(), index); - this->index_ = {}; // Store nullopt for invalid index + this->index_ = nullopt; // Store nullopt for invalid index } else { this->index_ = index; } @@ -52,7 +52,7 @@ optional SelectCall::calculate_target_index_(const char *name) { const auto &options = this->parent_->traits.get_options(); if (options.empty()) { ESP_LOGW(TAG, "'%s' - Select has no options", name); - return {}; + return nullopt; } if (this->operation_ == SELECT_OP_FIRST) { @@ -67,7 +67,7 @@ optional SelectCall::calculate_target_index_(const char *name) { ESP_LOGD(TAG, "'%s' - Setting", name); if (!this->index_.has_value()) { ESP_LOGW(TAG, "'%s' - No option set", name); - return {}; + return nullopt; } return this->index_; } @@ -96,7 +96,7 @@ optional SelectCall::calculate_target_index_(const char *name) { return active_index + 1; } - return {}; // Can't navigate further without cycling + return nullopt; // Can't navigate further without cycling } void SelectCall::perform() { diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index c9abbc69a0b..fbe7b82e929 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -43,7 +43,7 @@ class SelectCall { Select *const parent_; optional index_; SelectOperation operation_{SELECT_OP_NONE}; - bool cycle_; + bool cycle_{false}; }; } // namespace esphome::select diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 538a2f52395..9fe51121f16 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -267,7 +267,10 @@ SEN5X_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "sen5x.start_fan_autoclean", StartFanAction, SEN5X_ACTION_SCHEMA + "sen5x.start_fan_autoclean", + StartFanAction, + SEN5X_ACTION_SCHEMA, + synchronous=True, ) async def sen54_fan_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index baaadd64631..2a6ea64735d 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -2,13 +2,16 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include -#include -#include namespace esphome::sen6x { static const char *const TAG = "sen6x"; +static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts +static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete +static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts +// Single numeric timeout ID — the chain is sequential so only one is active at a time. +static constexpr uint32_t TIMEOUT_POLL = 1; static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -182,179 +185,202 @@ void SEN6XComponent::update() { return; } - uint16_t read_cmd; - uint8_t read_words; - set_read_command_and_words(this->sen6x_type_, read_cmd, read_words); + // Cancel any in-flight polling from a previous update() cycle. + this->cancel_timeout(TIMEOUT_POLL); - const uint8_t poll_retries = 24; - auto poll_ready = std::make_shared>(); - *poll_ready = [this, poll_ready, read_cmd, read_words](uint8_t retries_left) { - const uint8_t attempt = static_cast(poll_retries - retries_left + 1); - ESP_LOGV(TAG, "Data ready polling attempt %u", attempt); + set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_); - if (!this->write_command(SEN6X_CMD_GET_DATA_READY_STATUS)) { + // Polling uses chained timeouts to guarantee each I2C operation completes + // before the next begins. The flow is: + // + // poll_data_ready_() + // -> write_command (data ready status) + // -> timeout I2C_READ_DELAY + // -> read_data (check ready flag) + // -> if not ready: timeout POLL_INTERVAL -> poll_data_ready_() (retry) + // -> if ready: read_measurements_() + // -> write_command (read measurement) + // -> timeout I2C_READ_DELAY + // -> parse_and_publish_measurements_() + // + // All timeouts share a single ID (TIMEOUT_POLL) since only one is active + // at a time. cancel_timeout in update() stops any in-flight chain. + this->poll_retries_remaining_ = POLL_RETRIES; + this->poll_data_ready_(); +} + +void SEN6XComponent::poll_data_ready_() { + if (this->poll_retries_remaining_ == 0) { + this->status_set_warning(); + ESP_LOGD(TAG, "Data not ready"); + return; + } + ESP_LOGV(TAG, "Data ready polling attempt %u", + static_cast(POLL_RETRIES - this->poll_retries_remaining_ + 1)); + this->poll_retries_remaining_--; + + if (!this->write_command(SEN6X_CMD_GET_DATA_READY_STATUS)) { + this->status_set_warning(); + ESP_LOGD(TAG, "write data ready status error (%d)", this->last_error_); + return; + } + + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { + uint16_t raw_read_status; + if (!this->read_data(&raw_read_status, 1)) { this->status_set_warning(); - ESP_LOGD(TAG, "write data ready status error (%d)", this->last_error_); + ESP_LOGD(TAG, "read data ready status error (%d)", this->last_error_); return; } - this->set_timeout(20, [this, poll_ready, retries_left, read_cmd, read_words]() { - uint16_t raw_read_status; - if (!this->read_data(&raw_read_status, 1)) { - this->status_set_warning(); - ESP_LOGD(TAG, "read data ready status error (%d)", this->last_error_); - return; - } + if ((raw_read_status & 0x0001) == 0) { + // Not ready yet; schedule next attempt after POLL_INTERVAL. + this->set_timeout(TIMEOUT_POLL, POLL_INTERVAL, [this]() { this->poll_data_ready_(); }); + return; + } - if ((raw_read_status & 0x0001) == 0) { - if (retries_left == 0) { - this->status_set_warning(); - ESP_LOGD(TAG, "Data not ready"); - return; - } - this->set_timeout(50, [poll_ready, retries_left]() { (*poll_ready)(retries_left - 1); }); - return; - } + this->read_measurements_(); + }); +} - if (!this->write_command(read_cmd)) { - this->status_set_warning(); - ESP_LOGD(TAG, "Read measurement failed (%d)", this->last_error_); - return; - } +void SEN6XComponent::read_measurements_() { + if (!this->write_command(this->read_cmd_)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read measurement failed (%d)", this->last_error_); + return; + } - this->set_timeout(20, [this, read_words]() { - uint16_t measurements[10]; + this->set_timeout(TIMEOUT_POLL, I2C_READ_DELAY, [this]() { this->parse_and_publish_measurements_(); }); +} - if (!this->read_data(measurements, read_words)) { - this->status_set_warning(); - ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); - return; - } - int8_t voc_index = -1; - int8_t nox_index = -1; - int8_t hcho_index = -1; - int8_t co2_index = -1; - bool co2_uint16 = false; - switch (this->sen6x_type_) { - case SEN62: - break; - case SEN63C: - co2_index = 6; - break; - case SEN65: - voc_index = 6; - nox_index = 7; - break; - case SEN66: - voc_index = 6; - nox_index = 7; - co2_index = 8; - co2_uint16 = true; - break; - case SEN68: - voc_index = 6; - nox_index = 7; - hcho_index = 8; - break; - case SEN69C: - voc_index = 6; - nox_index = 7; - hcho_index = 8; - co2_index = 9; - break; - default: - break; - } +void SEN6XComponent::parse_and_publish_measurements_() { + uint16_t measurements[10]; - float pm_1_0 = measurements[0] / 10.0f; - if (measurements[0] == 0xFFFF) - pm_1_0 = NAN; - float pm_2_5 = measurements[1] / 10.0f; - if (measurements[1] == 0xFFFF) - pm_2_5 = NAN; - float pm_4_0 = measurements[2] / 10.0f; - if (measurements[2] == 0xFFFF) - pm_4_0 = NAN; - float pm_10_0 = measurements[3] / 10.0f; - if (measurements[3] == 0xFFFF) - pm_10_0 = NAN; - float humidity = static_cast(measurements[4]) / 100.0f; - if (measurements[4] == 0x7FFF) - humidity = NAN; - float temperature = static_cast(measurements[5]) / 200.0f; - if (measurements[5] == 0x7FFF) - temperature = NAN; + if (!this->read_data(measurements, this->read_words_)) { + this->status_set_warning(); + ESP_LOGD(TAG, "Read data failed (%d)", this->last_error_); + return; + } + int8_t voc_index = -1; + int8_t nox_index = -1; + int8_t hcho_index = -1; + int8_t co2_index = -1; + bool co2_uint16 = false; + switch (this->sen6x_type_) { + case SEN62: + break; + case SEN63C: + co2_index = 6; + break; + case SEN65: + voc_index = 6; + nox_index = 7; + break; + case SEN66: + voc_index = 6; + nox_index = 7; + co2_index = 8; + co2_uint16 = true; + break; + case SEN68: + voc_index = 6; + nox_index = 7; + hcho_index = 8; + break; + case SEN69C: + voc_index = 6; + nox_index = 7; + hcho_index = 8; + co2_index = 9; + break; + default: + break; + } - float voc = NAN; - float nox = NAN; - float hcho = NAN; - float co2 = NAN; + float pm_1_0 = measurements[0] / 10.0f; + if (measurements[0] == 0xFFFF) + pm_1_0 = NAN; + float pm_2_5 = measurements[1] / 10.0f; + if (measurements[1] == 0xFFFF) + pm_2_5 = NAN; + float pm_4_0 = measurements[2] / 10.0f; + if (measurements[2] == 0xFFFF) + pm_4_0 = NAN; + float pm_10_0 = measurements[3] / 10.0f; + if (measurements[3] == 0xFFFF) + pm_10_0 = NAN; + float humidity = static_cast(measurements[4]) / 100.0f; + if (measurements[4] == 0x7FFF) + humidity = NAN; + float temperature = static_cast(measurements[5]) / 200.0f; + if (measurements[5] == 0x7FFF) + temperature = NAN; - if (voc_index >= 0) { - voc = static_cast(measurements[voc_index]) / 10.0f; - if (measurements[voc_index] == 0x7FFF) - voc = NAN; - } - if (nox_index >= 0) { - nox = static_cast(measurements[nox_index]) / 10.0f; - if (measurements[nox_index] == 0x7FFF) - nox = NAN; - } + float voc = NAN; + float nox = NAN; + float hcho = NAN; + float co2 = NAN; - if (hcho_index >= 0) { - const uint16_t hcho_raw = measurements[hcho_index]; - hcho = hcho_raw / 10.0f; - if (hcho_raw == 0xFFFF) - hcho = NAN; - } + if (voc_index >= 0) { + voc = static_cast(measurements[voc_index]) / 10.0f; + if (measurements[voc_index] == 0x7FFF) + voc = NAN; + } + if (nox_index >= 0) { + nox = static_cast(measurements[nox_index]) / 10.0f; + if (measurements[nox_index] == 0x7FFF) + nox = NAN; + } - if (co2_index >= 0) { - if (co2_uint16) { - const uint16_t co2_raw = measurements[co2_index]; - co2 = static_cast(co2_raw); - if (co2_raw == 0xFFFF) - co2 = NAN; - } else { - const int16_t co2_raw = static_cast(measurements[co2_index]); - co2 = static_cast(co2_raw); - if (co2_raw == 0x7FFF) - co2 = NAN; - } - } + if (hcho_index >= 0) { + const uint16_t hcho_raw = measurements[hcho_index]; + hcho = hcho_raw / 10.0f; + if (hcho_raw == 0xFFFF) + hcho = NAN; + } - if (!this->startup_complete_) { - ESP_LOGD(TAG, "Startup delay, ignoring values"); - this->status_clear_warning(); - return; - } + if (co2_index >= 0) { + if (co2_uint16) { + const uint16_t co2_raw = measurements[co2_index]; + co2 = static_cast(co2_raw); + if (co2_raw == 0xFFFF) + co2 = NAN; + } else { + const int16_t co2_raw = static_cast(measurements[co2_index]); + co2 = static_cast(co2_raw); + if (co2_raw == 0x7FFF) + co2 = NAN; + } + } - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(pm_1_0); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(pm_2_5); - if (this->pm_4_0_sensor_ != nullptr) - this->pm_4_0_sensor_->publish_state(pm_4_0); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(pm_10_0); - if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(temperature); - if (this->humidity_sensor_ != nullptr) - this->humidity_sensor_->publish_state(humidity); - if (this->voc_sensor_ != nullptr) - this->voc_sensor_->publish_state(voc); - if (this->nox_sensor_ != nullptr) - this->nox_sensor_->publish_state(nox); - if (this->hcho_sensor_ != nullptr) - this->hcho_sensor_->publish_state(hcho); - if (this->co2_sensor_ != nullptr) - this->co2_sensor_->publish_state(co2); + if (!this->startup_complete_) { + ESP_LOGD(TAG, "Startup delay, ignoring values"); + this->status_clear_warning(); + return; + } - this->status_clear_warning(); - }); - }); - }; + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5); + if (this->pm_4_0_sensor_ != nullptr) + this->pm_4_0_sensor_->publish_state(pm_4_0); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(humidity); + if (this->voc_sensor_ != nullptr) + this->voc_sensor_->publish_state(voc); + if (this->nox_sensor_ != nullptr) + this->nox_sensor_->publish_state(nox); + if (this->hcho_sensor_ != nullptr) + this->hcho_sensor_->publish_state(hcho); + if (this->co2_sensor_ != nullptr) + this->co2_sensor_->publish_state(co2); - (*poll_ready)(poll_retries); + this->status_clear_warning(); } SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 01e89dce1bc..bc44611882f 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -30,13 +30,19 @@ class SEN6XComponent : public PollingComponent, public sensirion_common::Sensiri protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); + void poll_data_ready_(); + void read_measurements_(); + void parse_and_publish_measurements_(); bool initialized_{false}; std::string product_name_; Sen6xType sen6x_type_{UNKNOWN}; std::string serial_number_; + uint16_t read_cmd_{0}; uint8_t firmware_version_major_{0}; uint8_t firmware_version_minor_{0}; + uint8_t poll_retries_remaining_{0}; + uint8_t read_words_{0}; bool startup_complete_{false}; }; diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index 2eb2617e307..c5bef76741a 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -73,20 +73,31 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( "senseair.background_calibration", SenseAirBackgroundCalibrationAction, CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( "senseair.background_calibration_result", SenseAirBackgroundCalibrationResultAction, CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "senseair.abc_enable", SenseAirABCEnableAction, CALIBRATION_ACTION_SCHEMA + "senseair.abc_enable", + SenseAirABCEnableAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "senseair.abc_disable", SenseAirABCDisableAction, CALIBRATION_ACTION_SCHEMA + "senseair.abc_disable", + SenseAirABCDisableAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "senseair.abc_get_period", SenseAirABCGetPeriodAction, CALIBRATION_ACTION_SCHEMA + "senseair.abc_get_period", + SenseAirABCGetPeriodAction, + CALIBRATION_ACTION_SCHEMA, + synchronous=True, ) async def senseair_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index 0279e08b9ff..6e244faf596 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -12,24 +12,25 @@ static const char *const TAG = "sensirion_i2c"; static const size_t BUFFER_STACK_SIZE = 16; bool SensirionI2CDevice::read_data(uint16_t *data, const uint8_t len) { - const size_t num_bytes = len * 3; - uint8_t buf[num_bytes]; + const size_t required_buffer_len = len * 3; + SmallBufferWithHeapFallback buffer(required_buffer_len); + uint8_t *temp = buffer.get(); - this->last_error_ = this->read(buf, num_bytes); + this->last_error_ = this->read(temp, required_buffer_len); if (this->last_error_ != i2c::ERROR_OK) { return false; } - for (uint8_t i = 0; i < len; i++) { - const uint8_t j = 3 * i; + for (size_t i = 0; i < len; i++) { + const size_t j = i * 3; // Use MSB first since Sensirion devices use CRC-8 with MSB first - uint8_t crc = crc8(&buf[j], 2, 0xFF, CRC_POLYNOMIAL, true); - if (crc != buf[j + 2]) { - ESP_LOGE(TAG, "CRC invalid @ %d! 0x%02X != 0x%02X", i, buf[j + 2], crc); + uint8_t crc = crc8(&temp[j], 2, 0xFF, CRC_POLYNOMIAL, true); + if (crc != temp[j + 2]) { + ESP_LOGE(TAG, "CRC invalid @ %zu! 0x%02X != 0x%02X", i, temp[j + 2], crc); this->last_error_ = i2c::ERROR_CRC; return false; } - data[i] = encode_uint16(buf[j], buf[j + 1]); + data[i] = encode_uint16(temp[j], temp[j + 1]); } return true; } diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 4be6ed1b841..64d4dc41778 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -403,9 +403,9 @@ async def filter_out_filter_to_code(config, filter_id): QUANTILE_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), cv.Optional(CONF_QUANTILE, default=0.9): cv.zero_to_one_float, } ), @@ -427,9 +427,9 @@ async def quantile_filter_to_code(config, filter_id): MEDIAN_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -449,9 +449,9 @@ async def median_filter_to_code(config, filter_id): MIN_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -483,9 +483,9 @@ async def min_filter_to_code(config, filter_id): MAX_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=5): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=5): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -509,9 +509,9 @@ async def max_filter_to_code(config, filter_id): SLIDING_AVERAGE_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_WINDOW_SIZE, default=15): cv.positive_not_null_int, - cv.Optional(CONF_SEND_EVERY, default=15): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_WINDOW_SIZE, default=15): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_EVERY, default=15): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, @@ -540,8 +540,8 @@ EXPONENTIAL_AVERAGE_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_ALPHA, default=0.1): cv.positive_float, - cv.Optional(CONF_SEND_EVERY, default=15): cv.positive_not_null_int, - cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.positive_not_null_int, + cv.Optional(CONF_SEND_EVERY, default=15): cv.int_range(min=1, max=65535), + cv.Optional(CONF_SEND_FIRST_AT, default=1): cv.int_range(min=1, max=65535), } ), validate_send_first_at, diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 0fe1effe179..d995ee4111c 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -41,26 +41,14 @@ void Filter::initialize(Sensor *parent, Filter *next) { } // SlidingWindowFilter -SlidingWindowFilter::SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at) - : window_size_(window_size), send_every_(send_every), send_at_(send_every - send_first_at) { - // Allocate ring buffer once at initialization +SlidingWindowFilter::SlidingWindowFilter(uint16_t window_size, uint16_t send_every, uint16_t send_first_at) + : send_every_(send_every), send_at_(send_every - send_first_at) { this->window_.init(window_size); } optional SlidingWindowFilter::new_value(float value) { - // Add value to ring buffer - if (this->window_count_ < this->window_size_) { - // Buffer not yet full - just append - this->window_.push_back(value); - this->window_count_++; - } else { - // Buffer full - overwrite oldest value (ring buffer) - this->window_[this->window_head_] = value; - this->window_head_++; - if (this->window_head_ >= this->window_size_) { - this->window_head_ = 0; - } - } + // Add value to ring buffer (overwrites oldest when full) + this->window_.push_overwrite(value); // Check if we should send a result if (++this->send_at_ >= this->send_every_) { @@ -77,9 +65,8 @@ FixedVector SortedWindowFilter::get_window_values_() { // Copy window without NaN values using FixedVector (no heap allocation) // Returns unsorted values - caller will use std::nth_element for partial sorting as needed FixedVector values; - values.init(this->window_count_); - for (size_t i = 0; i < this->window_count_; i++) { - float v = this->window_[i]; + values.init(this->window_.size()); + for (float v : this->window_) { if (!std::isnan(v)) { values.push_back(v); } @@ -150,8 +137,7 @@ float MaxFilter::compute_result() { return this->find_extremum_window_count_; i++) { - float v = this->window_[i]; + for (float v : this->window_) { if (!std::isnan(v)) { sum += v; valid_count++; @@ -161,7 +147,7 @@ float SlidingWindowMovingAverageFilter::compute_result() { } // ExponentialMovingAverageFilter -ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at) +ExponentialMovingAverageFilter::ExponentialMovingAverageFilter(float alpha, uint16_t send_every, uint16_t send_first_at) : alpha_(alpha), send_every_(send_every), send_at_(send_every - send_first_at) {} optional ExponentialMovingAverageFilter::new_value(float value) { if (!std::isnan(value)) { @@ -183,7 +169,7 @@ optional ExponentialMovingAverageFilter::new_value(float value) { } return {}; } -void ExponentialMovingAverageFilter::set_send_every(size_t send_every) { this->send_every_ = send_every; } +void ExponentialMovingAverageFilter::set_send_every(uint16_t send_every) { this->send_every_ = send_every; } void ExponentialMovingAverageFilter::set_alpha(float alpha) { this->alpha_ = alpha; } // ThrottleAverageFilter @@ -511,7 +497,7 @@ optional ToNTCTemperatureFilter::new_value(float value) { } // StreamingFilter (base class) -StreamingFilter::StreamingFilter(size_t window_size, size_t send_first_at) +StreamingFilter::StreamingFilter(uint16_t window_size, uint16_t send_first_at) : window_size_(window_size), send_first_at_(send_first_at) {} optional StreamingFilter::new_value(float value) { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 8bfcdb37cfb..6a76bd373e3 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -52,7 +52,7 @@ class Filter { */ class SlidingWindowFilter : public Filter { public: - SlidingWindowFilter(size_t window_size, size_t send_every, size_t send_first_at); + SlidingWindowFilter(uint16_t window_size, uint16_t send_every, uint16_t send_first_at); optional new_value(float value) final; @@ -60,14 +60,10 @@ class SlidingWindowFilter : public Filter { /// Called by new_value() to compute the filtered result from the current window virtual float compute_result() = 0; - /// Access the sliding window values (ring buffer implementation) - /// Use: for (size_t i = 0; i < window_count_; i++) { float val = window_[i]; } - FixedVector window_; - size_t window_head_{0}; ///< Index where next value will be written - size_t window_count_{0}; ///< Number of valid values in window (0 to window_size_) - size_t window_size_; ///< Maximum window size - size_t send_every_; ///< Send result every N values - size_t send_at_; ///< Counter for send_every + /// Sliding window ring buffer - automatically overwrites oldest values when full + FixedRingBuffer window_; + uint16_t send_every_; ///< Send result every N values + uint16_t send_at_; ///< Counter for send_every }; /** Base class for Min/Max filters. @@ -84,8 +80,7 @@ class MinMaxFilter : public SlidingWindowFilter { template float find_extremum_() { float result = NAN; Compare comp; - for (size_t i = 0; i < this->window_count_; i++) { - float v = this->window_[i]; + for (float v : this->window_) { if (!std::isnan(v)) { result = std::isnan(result) ? v : (comp(v, result) ? v : result); } @@ -239,18 +234,18 @@ class SlidingWindowMovingAverageFilter : public SlidingWindowFilter { */ class ExponentialMovingAverageFilter : public Filter { public: - ExponentialMovingAverageFilter(float alpha, size_t send_every, size_t send_first_at); + ExponentialMovingAverageFilter(float alpha, uint16_t send_every, uint16_t send_first_at); optional new_value(float value) override; - void set_send_every(size_t send_every); + void set_send_every(uint16_t send_every); void set_alpha(float alpha); protected: float accumulator_{NAN}; float alpha_; - size_t send_every_; - size_t send_at_; + uint16_t send_every_; + uint16_t send_at_; bool first_value_{true}; }; @@ -570,7 +565,7 @@ class ToNTCTemperatureFilter : public Filter { */ class StreamingFilter : public Filter { public: - StreamingFilter(size_t window_size, size_t send_first_at); + StreamingFilter(uint16_t window_size, uint16_t send_first_at); optional new_value(float value) final; @@ -584,9 +579,9 @@ class StreamingFilter : public Filter { /// Called by new_value() to reset internal state after sending a result virtual void reset_batch() = 0; - size_t window_size_; - size_t count_{0}; - size_t send_first_at_; + uint16_t window_size_; + uint16_t count_{0}; + uint16_t send_first_at_; bool first_send_{true}; }; diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py new file mode 100644 index 00000000000..f9b8c375d21 --- /dev/null +++ b/esphome/components/serial_proxy/__init__.py @@ -0,0 +1,104 @@ +""" +Serial Proxy component for ESPHome. + +WARNING: This component is EXPERIMENTAL. The API (both Python configuration +and C++ interfaces) may change at any time without following the normal +breaking changes policy. Use at your own risk. + +Once the API is considered stable, this warning will be removed. + +Provides a proxy to/from a serial interface on the ESPHome device, allowing +Home Assistant to connect to the serial port and send/receive data to/from +an arbitrary serial device. +""" + +from dataclasses import dataclass + +from esphome import pins +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_NAME +from esphome.core import CORE, coroutine_with_priority +from esphome.coroutine import CoroPriority + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["api", "uart"] + +MULTI_CONF = True + +serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") +SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) + +api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") +SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") +SERIAL_PROXY_PORT_TYPES = { + "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, + "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, + "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, +} + +CONF_DTR_PIN = "dtr_pin" +CONF_PORT_TYPE = "port_type" +CONF_RTS_PIN = "rts_pin" + +DOMAIN = "serial_proxy" + + +@dataclass +class SerialProxyData: + count: int = 0 + + +def _get_data() -> SerialProxyData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = SerialProxyData() + return CORE.data[DOMAIN] + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(SerialProxy), + cv.Required(CONF_NAME): cv.string_strict, + cv.Required(CONF_PORT_TYPE): cv.enum(SERIAL_PROXY_PORT_TYPES, upper=True), + cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_serial_proxy_count_define(): + """Emit the SERIAL_PROXY_COUNT define once with the final instance count.""" + count = _get_data().count + if count > 0: + cg.add_define("SERIAL_PROXY_COUNT", count) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add(cg.App.register_serial_proxy(var)) + cg.add(var.set_name(config[CONF_NAME])) + cg.add(var.set_port_type(config[CONF_PORT_TYPE])) + cg.add_define("USE_SERIAL_PROXY") + + # Track instance count for the FINAL priority define + data = _get_data() + if data.count == 0: + # Schedule the count define job only once (on the first instance) + CORE.add_job(_add_serial_proxy_count_define) + data.count += 1 + + if CONF_RTS_PIN in config: + rts_pin = await cg.gpio_pin_expression(config[CONF_RTS_PIN]) + cg.add(var.set_rts_pin(rts_pin)) + + if CONF_DTR_PIN in config: + dtr_pin = await cg.gpio_pin_expression(config[CONF_DTR_PIN]) + cg.add(var.set_dtr_pin(dtr_pin)) diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp new file mode 100644 index 00000000000..00d822b75cb --- /dev/null +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -0,0 +1,203 @@ +#include "serial_proxy.h" + +#ifdef USE_SERIAL_PROXY + +#include "esphome/core/log.h" +#include "esphome/core/util.h" + +#ifdef USE_API +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#endif + +namespace esphome::serial_proxy { + +static const char *const TAG = "serial_proxy"; + +void SerialProxy::setup() { + // Set up modem control pins if configured + if (this->rts_pin_ != nullptr) { + this->rts_pin_->setup(); + this->rts_pin_->digital_write(this->rts_state_); + } + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->setup(); + this->dtr_pin_->digital_write(this->dtr_state_); + } +#ifdef USE_API + // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data + this->outgoing_msg_.instance = this->instance_index_; +#endif + // No subscriber at startup; disable loop until a client subscribes + this->disable_loop(); +} + +void SerialProxy::loop() { +#ifdef USE_API + // Safety check — loop should only run when subscribed, but guard against races + if (this->api_connection_ == nullptr) [[unlikely]] { + this->disable_loop(); + return; + } + + // Detect subscriber disconnect + if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || + !api_is_connected()) { + ESP_LOGW(TAG, "Subscriber disconnected"); + this->api_connection_ = nullptr; + this->disable_loop(); + return; + } + + // Read available data from UART and forward to subscribed client + size_t available = this->available(); + if (available == 0) + return; + + this->read_and_send_(available); +#endif +} + +#ifdef USE_API +void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { + // Read in chunks up to SERIAL_PROXY_MAX_READ_SIZE + uint8_t buffer[SERIAL_PROXY_MAX_READ_SIZE]; + size_t to_read = std::min(available, sizeof(buffer)); + + if (!this->read_array(buffer, to_read)) + return; + + this->outgoing_msg_.set_data(buffer, to_read); + this->api_connection_->send_serial_proxy_data(this->outgoing_msg_); +} +#endif + +void SerialProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "Serial Proxy [%u]:\n" + " Name: %s\n" + " Port Type: %s\n" + " RTS Pin: %s\n" + " DTR Pin: %s", + this->instance_index_, this->name_ != nullptr ? this->name_ : "", + this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? "RS485" + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? "RS232" + : "TTL", + this->rts_pin_ != nullptr ? "configured" : "not configured", + this->dtr_pin_ != nullptr ? "configured" : "not configured"); +} + +void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, + uint8_t data_size) { + ESP_LOGD(TAG, "Configuring serial proxy [%u]: baud=%u, flow_ctrl=%s, parity=%u, stop=%u, data=%u", + this->instance_index_, baudrate, YESNO(flow_control), parity, stop_bits, data_size); + + auto *uart_comp = this->parent_; + if (uart_comp == nullptr) { + ESP_LOGE(TAG, "UART component not available"); + return; + } + + // Validate all parameters before applying any (values come from a remote client) + if (baudrate == 0) { + ESP_LOGW(TAG, "Invalid baud rate: 0"); + return; + } + if (stop_bits < 1 || stop_bits > 2) { + ESP_LOGW(TAG, "Invalid stop bits: %u (must be 1 or 2)", stop_bits); + return; + } + if (data_size < 5 || data_size > 8) { + ESP_LOGW(TAG, "Invalid data bits: %u (must be 5-8)", data_size); + return; + } + if (parity > 2) { + ESP_LOGW(TAG, "Invalid parity: %u (must be 0-2)", parity); + return; + } + + // Apply validated parameters + uart_comp->set_baud_rate(baudrate); + uart_comp->set_stop_bits(stop_bits); + uart_comp->set_data_bits(data_size); + + // Map parity value to UARTParityOptions + static const uart::UARTParityOptions PARITY_MAP[] = { + uart::UART_CONFIG_PARITY_NONE, + uart::UART_CONFIG_PARITY_EVEN, + uart::UART_CONFIG_PARITY_ODD, + }; + uart_comp->set_parity(PARITY_MAP[parity]); + + // load_settings() is available on ESP8266 and ESP32 platforms +#if defined(USE_ESP8266) || defined(USE_ESP32) + uart_comp->load_settings(true); +#endif + + if (flow_control) { + ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported"); + } +} + +void SerialProxy::write_from_client(const uint8_t *data, size_t len) { + if (data == nullptr || len == 0) + return; + this->write_array(data, len); +} + +void SerialProxy::set_modem_pins(uint32_t line_states) { + const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0; + const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; + ESP_LOGV(TAG, "Setting modem pins [%u]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); + + if (this->rts_pin_ != nullptr) { + this->rts_state_ = rts; + this->rts_pin_->digital_write(rts); + } + if (this->dtr_pin_ != nullptr) { + this->dtr_state_ = dtr; + this->dtr_pin_->digital_write(dtr); + } +} + +uint32_t SerialProxy::get_modem_pins() const { + return (this->rts_state_ ? SERIAL_PROXY_LINE_STATE_FLAG_RTS : 0u) | + (this->dtr_state_ ? SERIAL_PROXY_LINE_STATE_FLAG_DTR : 0u); +} + +uart::FlushResult SerialProxy::flush_port() { + ESP_LOGV(TAG, "Flushing serial proxy [%u]", this->instance_index_); + return this->flush(); +} + +#ifdef USE_API +void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { + switch (type) { + case api::enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE: + if (this->api_connection_ != nullptr) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + this->api_connection_ = api_connection; + this->enable_loop(); + ESP_LOGV(TAG, "API connection subscribed to serial proxy [%u]", this->instance_index_); + break; + case api::enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE: + if (this->api_connection_ != api_connection) { + ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%u]", this->instance_index_); + return; + } + this->api_connection_ = nullptr; + this->disable_loop(); + ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%u]", this->instance_index_); + break; + default: + ESP_LOGW(TAG, "Unknown serial proxy request type: %u", static_cast(type)); + break; + } +} +#endif + +} // namespace esphome::serial_proxy + +#endif // USE_SERIAL_PROXY diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h new file mode 100644 index 00000000000..5adfa4fe53b --- /dev/null +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -0,0 +1,137 @@ +#pragma once + +// WARNING: This component is EXPERIMENTAL. The API may change at any time +// without following the normal breaking changes policy. Use at your own risk. +// Once the API is considered stable, this warning will be removed. + +#include "esphome/core/defines.h" + +#ifdef USE_SERIAL_PROXY + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/uart/uart.h" + +// Include api_pb2.h only when the API is enabled. The full include is needed +// to hold SerialProxyDataReceived by value as a pre-allocated member. +// Guarding prevents pulling conflicting Zephyr logging macro names into +// translation units that include this header without USE_API defined. +#ifdef USE_API +#include "esphome/components/api/api_pb2.h" +#endif + +// Forward-declare types needed outside the USE_API guard. +namespace esphome::api { +class APIConnection; +namespace enums { +enum SerialProxyPortType : uint32_t; +enum SerialProxyRequestType : uint32_t; +} // namespace enums +} // namespace esphome::api + +namespace esphome::serial_proxy { + +/// Bit flags for the line_states field exchanged with API clients. +/// Bit positions are stable API — new signals must use the next available bit. +enum SerialProxyLineStateFlag : uint32_t { + SERIAL_PROXY_LINE_STATE_FLAG_RTS = 1 << 0, ///< RTS (Request To Send) + SERIAL_PROXY_LINE_STATE_FLAG_DTR = 1 << 1, ///< DTR (Data Terminal Ready) +}; + +/// Maximum bytes to read from UART in a single loop iteration +inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; + +class SerialProxy : public uart::UARTDevice, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + /// Get the instance index (position in Application's serial_proxies_ vector) + uint32_t get_instance_index() const { return this->instance_index_; } + + /// Set the instance index (called by Application::register_serial_proxy) + void set_instance_index(uint32_t index) { this->instance_index_ = index; } + + /// Set the human-readable port name (from YAML configuration) + void set_name(const char *name) { this->name_ = name; } + + /// Get the human-readable port name + const char *get_name() const { return this->name_; } + + /// Set the port type (from YAML configuration) + void set_port_type(api::enums::SerialProxyPortType port_type) { this->port_type_ = port_type; } + + /// Get the port type + api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } + + /// Configure UART parameters and apply them + /// @param baudrate Baud rate in bits per second + /// @param flow_control True to enable hardware flow control + /// @param parity Parity setting (0=none, 1=even, 2=odd) + /// @param stop_bits Number of stop bits (1 or 2) + /// @param data_size Number of data bits (5-8) + void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size); + + /// Get the currently subscribed API connection (nullptr if none) + api::APIConnection *get_api_connection() { return this->api_connection_; } + + /// Handle a subscribe/unsubscribe request from an API client + void serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type); + + /// Write data received from an API client to the serial device + /// @param data Pointer to data buffer + /// @param len Number of bytes to write + void write_from_client(const uint8_t *data, size_t len); + + /// Set modem pin states from a bitmask of SerialProxyLineStateFlag values + void set_modem_pins(uint32_t line_states); + + /// Get current modem pin states as a bitmask of SerialProxyLineStateFlag values + uint32_t get_modem_pins() const; + + /// Flush the serial port (block until all TX data is sent) + uart::FlushResult flush_port(); + + /// Set the RTS GPIO pin (from YAML configuration) + void set_rts_pin(GPIOPin *pin) { this->rts_pin_ = pin; } + + /// Set the DTR GPIO pin (from YAML configuration) + void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } + + protected: +#ifdef USE_API + /// Read from UART and send to API client (slow path with 256-byte stack buffer) + void read_and_send_(size_t available); +#endif + + /// Instance index for identifying this proxy in API messages + uint32_t instance_index_{0}; + + /// Subscribed API client (only one allowed at a time) + api::APIConnection *api_connection_{nullptr}; + +#ifdef USE_API + /// Pre-allocated outgoing message; instance field is set once in setup() + api::SerialProxyDataReceived outgoing_msg_; +#endif + + /// Human-readable port name (points to a string literal in flash) + const char *name_{nullptr}; + + /// Port type + api::enums::SerialProxyPortType port_type_{}; + + /// Optional GPIO pins for modem control + GPIOPin *rts_pin_{nullptr}; + GPIOPin *dtr_pin_{nullptr}; + + /// Current modem pin states + bool rts_state_{false}; + bool dtr_state_{false}; +}; + +} // namespace esphome::serial_proxy + +#endif // USE_SERIAL_PROXY diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 2fee2840a5c..a23bb53536a 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -62,6 +62,7 @@ async def to_code(config): cv.Required(CONF_LEVEL): cv.templatable(cv.possibly_negative_percentage), } ), + synchronous=True, ) async def servo_write_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -79,6 +80,7 @@ async def servo_write_to_code(config, action_id, template_arg, args): cv.Required(CONF_ID): cv.use_id(Servo), } ), + synchronous=True, ) async def servo_detach_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index cb41e374f82..bbf1ffd4c09 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -124,6 +124,7 @@ void SGP4xComponent::self_test_() { } this->self_test_complete_ = true; + this->nox_conditioning_start_ = millis(); ESP_LOGD(TAG, "Self-test complete"); }); } @@ -161,7 +162,6 @@ void SGP4xComponent::update_gas_indices_() { void SGP4xComponent::measure_raw_() { float humidity = NAN; - static uint32_t nox_conditioning_start = millis(); if (!this->self_test_complete_) { ESP_LOGW(TAG, "Self-test incomplete"); @@ -191,10 +191,11 @@ void SGP4xComponent::measure_raw_() { response_words = 1; } else { // SGP41 sensor must use NOx conditioning command for the first 10 seconds - if (millis() - nox_conditioning_start < 10000) { + if (this->nox_conditioning_start_.has_value() && millis() - *this->nox_conditioning_start_ < 10000) { command = SGP41_CMD_NOX_CONDITIONING; response_words = 1; } else { + this->nox_conditioning_start_.reset(); command = SGP41_CMD_MEASURE_RAW; response_words = 2; } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 89fa627c61c..6b8b598aff3 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -127,8 +127,9 @@ class SGP4xComponent : public PollingComponent, public sensor::Sensor, public se uint16_t measure_time_; uint8_t samples_read_ = 0; uint8_t samples_to_stabilize_ = static_cast(GasIndexAlgorithm_INITIAL_BLACKOUT) * 2; - bool store_baseline_; + + optional nox_conditioning_start_{}; ESPPreferenceObject pref_; uint32_t seconds_since_last_store_; SGP4xBaselines voc_baselines_storage_; diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 23995e6534b..079665c9596 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -8,7 +8,28 @@ namespace esphome::sha256 { -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_SHA256_PSA) + +// ESP-IDF 6.0 ships mbedtls 4.0 which removed the legacy mbedtls_sha256_* API. +// Use the PSA Crypto API instead. PSA crypto is auto-initialized by ESP-IDF +// at startup, so no psa_crypto_init() call is needed. + +SHA256::~SHA256() { psa_hash_abort(&this->op_); } + +void SHA256::init() { + psa_hash_abort(&this->op_); + this->op_ = PSA_HASH_OPERATION_INIT; + psa_hash_setup(&this->op_, PSA_ALG_SHA_256); +} + +void SHA256::add(const uint8_t *data, size_t len) { psa_hash_update(&this->op_, data, len); } + +void SHA256::calculate() { + size_t hash_length; + psa_hash_finish(&this->op_, this->digest_, sizeof(this->digest_), &hash_length); +} + +#elif defined(USE_SHA256_MBEDTLS) // CRITICAL ESP32 HARDWARE SHA ACCELERATION REQUIREMENTS (IDF 5.5.x): // diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index bafb359485c..0f995fcd916 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -10,7 +10,20 @@ #include #include "esphome/core/hash_base.h" -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_ESP32) +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls_sha256_* API. +// Use the PSA Crypto API instead. PSA crypto is auto-initialized by +// ESP-IDF at startup (esp_psa_crypto_init.c, priority 104). +#define USE_SHA256_PSA +#include +#else +#define USE_SHA256_MBEDTLS +#include "mbedtls/sha256.h" +#endif +#elif defined(USE_LIBRETINY) +#define USE_SHA256_MBEDTLS #include "mbedtls/sha256.h" #elif defined(USE_ESP8266) || defined(USE_RP2040) #include @@ -51,7 +64,9 @@ class SHA256 : public esphome::HashBase { size_t get_size() const override { return 32; } protected: -#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#if defined(USE_SHA256_PSA) + psa_hash_operation_t op_ = PSA_HASH_OPERATION_INIT; +#elif defined(USE_SHA256_MBEDTLS) // The mbedtls context for ESP32-S3 hardware SHA requires proper alignment and stack frame constraints. // See class documentation above for critical requirements. mbedtls_sha256_context ctx_{}; diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index c48a3c63c41..ebb74302a9c 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -135,7 +135,10 @@ SIM800L_SEND_SMS_SCHEMA = cv.Schema( @automation.register_action( - "sim800l.send_sms", Sim800LSendSmsAction, SIM800L_SEND_SMS_SCHEMA + "sim800l.send_sms", + Sim800LSendSmsAction, + SIM800L_SEND_SMS_SCHEMA, + synchronous=True, ) async def sim800l_send_sms_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -155,7 +158,9 @@ SIM800L_DIAL_SCHEMA = cv.Schema( ) -@automation.register_action("sim800l.dial", Sim800LDialAction, SIM800L_DIAL_SCHEMA) +@automation.register_action( + "sim800l.dial", Sim800LDialAction, SIM800L_DIAL_SCHEMA, synchronous=True +) async def sim800l_dial_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -168,6 +173,7 @@ async def sim800l_dial_to_code(config, action_id, template_arg, args): "sim800l.connect", Sim800LConnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), + synchronous=True, ) async def sim800l_connect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -183,7 +189,10 @@ SIM800L_SEND_USSD_SCHEMA = cv.Schema( @automation.register_action( - "sim800l.send_ussd", Sim800LSendUssdAction, SIM800L_SEND_USSD_SCHEMA + "sim800l.send_ussd", + Sim800LSendUssdAction, + SIM800L_SEND_USSD_SCHEMA, + synchronous=True, ) async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -197,6 +206,7 @@ async def sim800l_send_ussd_to_code(config, action_id, template_arg, args): "sim800l.disconnect", Sim800LDisconnectAction, cv.Schema({cv.GenerateID(): cv.use_id(Sim800LComponent)}), + synchronous=True, ) async def sim800l_disconnect_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 105cc06edbf..6eb6416447f 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -14,11 +14,26 @@ void SMT100Component::update() { void SMT100Component::loop() { while (this->available() != 0) { if (this->readline_(this->read(), this->readline_buffer_, MAX_LINE_LENGTH) > 0) { - int counts = (int) strtol((strtok(this->readline_buffer_, ",")), nullptr, 10); - float permittivity = (float) strtod((strtok(nullptr, ",")), nullptr); - float moisture = (float) strtod((strtok(nullptr, ",")), nullptr); - float temperature = (float) strtod((strtok(nullptr, ",")), nullptr); - float voltage = (float) strtod((strtok(nullptr, ",")), nullptr); + char *token = strtok(this->readline_buffer_, ","); + if (!token) + continue; + int counts = (int) strtol(token, nullptr, 10); + token = strtok(nullptr, ","); + if (!token) + continue; + float permittivity = (float) strtod(token, nullptr); + token = strtok(nullptr, ","); + if (!token) + continue; + float moisture = (float) strtod(token, nullptr); + token = strtok(nullptr, ","); + if (!token) + continue; + float temperature = (float) strtod(token, nullptr); + token = strtok(nullptr, ","); + if (!token) + continue; + float voltage = (float) strtod(token, nullptr); if (this->counts_sensor_ != nullptr) { counts_sensor_->publish_state(counts); diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 9ebbe72002b..339a699bc97 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -14,7 +14,7 @@ #endif #ifdef USE_LWIP_FAST_SELECT -struct lwip_sock; +#include "esphome/core/lwip_fast_select.h" #endif namespace esphome::socket { @@ -56,6 +56,15 @@ class BSDSocketImpl { return ::getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) { +#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING) + // Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock, + // bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting). + if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) { + LwIPLock lock; + if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast(optval) != 0)) + return 0; + } +#endif return ::setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return ::listen(this->fd_, backlog); } diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 16e4d23d3ba..0eece6480f6 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -51,6 +51,8 @@ #define SO_REUSEADDR 0x0004 /* Allow local address reuse */ #define SO_KEEPALIVE 0x0008 /* keep connections alive */ #define SO_BROADCAST 0x0020 /* permit to send and to receive broadcast messages (see IP_SOF_BROADCAST option) */ +#define SO_RCVTIMEO 0x1006 /* receive timeout */ +#define SO_SNDTIMEO 0x1005 /* send timeout */ #define SOL_SOCKET 0xfff /* options for socket level */ diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 445a57809d2..96328e68c73 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -5,6 +5,7 @@ #include #include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -81,7 +82,9 @@ void socket_delay(uint32_t ms) { s_socket_woke = false; return; } - s_socket_woke = false; + // Don't clear s_socket_woke here — if an IRQ fires between the check above + // and the while loop below, the while condition sees it immediately. Clearing + // here would lose that wake and sleep until the timer fires. s_delay_expired = false; // Set a one-shot timer to wake us after the timeout. // add_alarm_in_ms returns >0 on success, 0 if time already passed, <0 on error. @@ -99,6 +102,7 @@ void socket_delay(uint32_t ms) { // Cancel timer if we woke early (socket data arrived before timeout) if (!s_delay_expired) cancel_alarm(alarm); + s_socket_woke = false; // consume the wake for next call } // No IRAM_ATTR equivalent needed: on RP2040, CYW43 async_context runs LWIP @@ -111,6 +115,24 @@ void socket_wake() { } #endif +// ---- LWIP thread safety ---- +// +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority +// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). They can preempt main-loop code at any point. +// +// Without locking, this causes race conditions between recv_fn and read() on the +// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing +// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. +// +// On ESP8266, lwip callbacks run from the SYS context which cooperates with user +// code (CONT context) — they never preempt each other, so no locking is needed. +// +// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. +#define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT + static const char *const TAG = "socket.lwip"; // set to 1 to enable verbose lwip logging @@ -120,17 +142,52 @@ static const char *const TAG = "socket.lwip"; #define LWIP_LOG(msg, ...) #endif +// Clear arg, recv, and err callbacks, then abort a connected PCB. +// Only valid for full tcp_pcb (not tcp_pcb_listen). +// Must be called before destroying the object that tcp_arg points to — +// tcp_abort() triggers the err callback synchronously, which would +// otherwise call back into a partially-destroyed object. +// tcp_sent/tcp_poll are not cleared because this implementation +// never registers them. +static void pcb_detach_abort(struct tcp_pcb *pcb) { + tcp_arg(pcb, nullptr); + tcp_recv(pcb, nullptr); + tcp_err(pcb, nullptr); + tcp_abort(pcb); +} + +// Clear arg, recv, and err callbacks, then gracefully close a connected PCB. +// Only valid for full tcp_pcb (not tcp_pcb_listen). +// After tcp_close(), the PCB remains alive during the TCP close handshake +// (FIN_WAIT, TIME_WAIT states). Without clearing callbacks first, LWIP +// would call recv/err on a destroyed socket object, corrupting the heap. +// tcp_sent/tcp_poll are not cleared because this implementation +// never registers them. +// Returns ERR_OK on success; on failure the PCB is aborted instead. +static err_t pcb_detach_close(struct tcp_pcb *pcb) { + tcp_arg(pcb, nullptr); + tcp_recv(pcb, nullptr); + tcp_err(pcb, nullptr); + err_t err = tcp_close(pcb); + if (err != ERR_OK) { + tcp_abort(pcb); + } + return err; +} + // ---- LWIPRawCommon methods ---- LWIPRawCommon::~LWIPRawCommon() { + LWIP_LOCK(); if (this->pcb_ != nullptr) { LWIP_LOG("tcp_abort(%p)", this->pcb_); - tcp_abort(this->pcb_); + pcb_detach_abort(this->pcb_); this->pcb_ = nullptr; } } int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -196,24 +253,24 @@ int LWIPRawCommon::bind(const struct sockaddr *name, socklen_t addrlen) { } int LWIPRawCommon::close() { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; } LWIP_LOG("tcp_close(%p)", this->pcb_); - err_t err = tcp_close(this->pcb_); + err_t err = pcb_detach_close(this->pcb_); + this->pcb_ = nullptr; if (err != ERR_OK) { LWIP_LOG(" -> err %d", err); - tcp_abort(this->pcb_); - this->pcb_ = nullptr; errno = err == ERR_MEM ? ENOMEM : EIO; return -1; } - this->pcb_ = nullptr; return 0; } int LWIPRawCommon::shutdown(int how) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -240,6 +297,7 @@ int LWIPRawCommon::shutdown(int how) { } int LWIPRawCommon::getpeername(struct sockaddr *name, socklen_t *addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -252,6 +310,7 @@ int LWIPRawCommon::getpeername(struct sockaddr *name, socklen_t *addrlen) { } int LWIPRawCommon::getsockname(struct sockaddr *name, socklen_t *addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -284,6 +343,7 @@ size_t LWIPRawCommon::getsockname_to(std::span buf) { } int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -303,6 +363,18 @@ int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *o *optlen = 4; return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (*optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + uint32_t ms = this->recv_timeout_cs_ * 10; + auto *tv = reinterpret_cast(optval); + tv->tv_sec = ms / 1000; + tv->tv_usec = (ms % 1000) * 1000; + *optlen = sizeof(struct timeval); + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (*optlen < 4) { errno = EINVAL; @@ -318,6 +390,7 @@ int LWIPRawCommon::getsockopt(int level, int optname, void *optval, socklen_t *o } int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -331,6 +404,21 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle // to prevent warnings return 0; } + if (level == SOL_SOCKET && optname == SO_RCVTIMEO) { + if (optlen < sizeof(struct timeval)) { + errno = EINVAL; + return -1; + } + const auto *tv = reinterpret_cast(optval); + uint32_t ms = tv->tv_sec * 1000 + tv->tv_usec / 1000; + uint32_t cs = (ms + 9) / 10; // round up to nearest centisecond + this->recv_timeout_cs_ = cs > 255 ? 255 : static_cast(cs); + return 0; + } + if (level == SOL_SOCKET && optname == SO_SNDTIMEO) { + // Raw TCP writes are non-blocking (tcp_write), so send timeout is a no-op. + return 0; + } if (level == IPPROTO_TCP && optname == TCP_NODELAY) { if (optlen != 4) { errno = EINVAL; @@ -388,6 +476,7 @@ int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *n // ---- LWIPRawImpl methods ---- LWIPRawImpl::~LWIPRawImpl() { + LWIP_LOCK(); // Free any received pbufs that LWIP transferred ownership of via recv_fn. // tcp_abort() in the base destructor won't free these since LWIP considers // ownership transferred once the recv callback accepts them. @@ -398,16 +487,24 @@ LWIPRawImpl::~LWIPRawImpl() { // Base class destructor handles pcb_ cleanup via tcp_abort } -void LWIPRawImpl::init() { +void LWIPRawImpl::init(struct pbuf *initial_rx, bool initial_rx_closed) { + LWIP_LOCK(); LWIP_LOG("init(%p)", this->pcb_); tcp_arg(this->pcb_, this); tcp_recv(this->pcb_, LWIPRawImpl::s_recv_fn); tcp_err(this->pcb_, LWIPRawImpl::s_err_fn); + if (initial_rx != nullptr) { + this->rx_buf_ = initial_rx; + this->rx_buf_offset_ = 0; + } + this->rx_closed_ = initial_rx_closed; } void LWIPRawImpl::s_err_fn(void *arg, err_t err) { - // "If a connection is aborted because of an error, the application is alerted of this event by - // the err callback." + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // No LWIP_LOCK() needed — lwip core already holds the async_context lock. + // // pcb is already freed when this callback is called // ERR_RST: connection was reset by remote host // ERR_ABRT: aborted through tcp_abort or TCP timer @@ -422,10 +519,15 @@ err_t LWIPRawImpl::s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, er } err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). LWIP_LOG("recv(pb=%p err=%d)", pb, err); if (err != 0) { // "An error code if there has been an error receiving Only return ERR_ABRT if you have // called tcp_abort from within the callback function!" + if (pb != nullptr) { + pbuf_free(pb); + } this->rx_closed_ = true; return ERR_OK; } @@ -447,7 +549,25 @@ err_t LWIPRawImpl::recv_fn(struct pbuf *pb, err_t err) { return ERR_OK; } -ssize_t LWIPRawImpl::read(void *buf, size_t len) { +void LWIPRawImpl::wait_for_data_() { + // Wait for data without holding LWIP_LOCK so recv_fn() can run on RP2040 + // (needs async_context lock). + // + // Loop until data arrives, connection closes, or the full timeout elapses. + // socket_delay() may return early due to other sockets waking the global + // socket_wake() flag, so we re-enter for the remaining time. + uint32_t timeout_ms = this->recv_timeout_cs_ * 10; + uint32_t start = millis(); + while (this->waiting_for_data_()) { + uint32_t elapsed = millis() - start; + if (elapsed >= timeout_ms) + break; + socket_delay(timeout_ms - elapsed); + } +} + +ssize_t LWIPRawImpl::read_locked_(void *buf, size_t len) { + // Caller must hold LWIP_LOCK. Copies available data from rx_buf_ into buf. if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -506,10 +626,26 @@ ssize_t LWIPRawImpl::read(void *buf, size_t len) { return read; } +ssize_t LWIPRawImpl::read(void *buf, size_t len) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + + LWIP_LOCK(); + return this->read_locked_(buf, len); +} + ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { + // See waiting_for_data_() for safety of unlocked reads. + if (this->recv_timeout_cs_ > 0 && this->waiting_for_data_()) { + this->wait_for_data_(); + } + + LWIP_LOCK(); // Hold for entire scatter-gather operation ssize_t ret = 0; for (int i = 0; i < iovcnt; i++) { - ssize_t err = this->read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); + ssize_t err = this->read_locked_(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { if (ret != 0) { // if we already read some don't return an error @@ -525,6 +661,7 @@ ssize_t LWIPRawImpl::readv(const struct iovec *iov, int iovcnt) { } ssize_t LWIPRawImpl::internal_write_(const void *buf, size_t len) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; @@ -557,6 +694,11 @@ ssize_t LWIPRawImpl::internal_write_(const void *buf, size_t len) { } int LWIPRawImpl::internal_output_() { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = ECONNRESET; + return -1; + } LWIP_LOG("tcp_output(%p)", this->pcb_); err_t err = tcp_output(this->pcb_); if (err == ERR_ABRT) { @@ -576,6 +718,7 @@ int LWIPRawImpl::internal_output_() { } ssize_t LWIPRawImpl::write(const void *buf, size_t len) { + LWIP_LOCK(); // Hold for write + optional output ssize_t written = this->internal_write_(buf, len); if (written == -1) return -1; @@ -592,6 +735,7 @@ ssize_t LWIPRawImpl::write(const void *buf, size_t len) { } ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { + LWIP_LOCK(); // Hold for entire scatter-gather operation ssize_t written = 0; for (int i = 0; i < iovcnt; i++) { ssize_t err = this->internal_write_(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); @@ -621,9 +765,27 @@ ssize_t LWIPRawImpl::writev(const struct iovec *iov, int iovcnt) { // ---- LWIPRawListenImpl methods ---- LWIPRawListenImpl::~LWIPRawListenImpl() { + LWIP_LOCK(); + // Abort any queued PCBs that were never accepted by the main loop. + for (uint8_t i = 0; i < this->accepted_socket_count_; i++) { + auto &entry = this->accepted_pcbs_[i]; + if (entry.pcb != nullptr) { + pcb_detach_abort(entry.pcb); + entry.pcb = nullptr; + } + if (entry.rx_buf != nullptr) { + pbuf_free(entry.rx_buf); + entry.rx_buf = nullptr; + } + } + this->accepted_socket_count_ = 0; // Listen PCBs must use tcp_close(), not tcp_abort(). // tcp_abandon() asserts pcb->state != LISTEN and would access // fields that don't exist in the smaller tcp_pcb_listen struct. + // Don't use pcb_detach_close() here — tcp_recv()/tcp_err() also access + // fields that only exist in the full tcp_pcb, not tcp_pcb_listen. + // tcp_close() on a listen PCB is synchronous (frees immediately), + // so there are no async callbacks to worry about. // Close here and null pcb_ so the base destructor skips tcp_abort. if (this->pcb_ != nullptr) { tcp_close(this->pcb_); @@ -632,6 +794,7 @@ LWIPRawListenImpl::~LWIPRawListenImpl() { } void LWIPRawListenImpl::init() { + LWIP_LOCK(); LWIP_LOG("init(%p)", this->pcb_); tcp_arg(this->pcb_, this); tcp_accept(this->pcb_, LWIPRawListenImpl::s_accept_fn); @@ -639,41 +802,98 @@ void LWIPRawListenImpl::init() { } void LWIPRawListenImpl::s_err_fn(void *arg, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). auto *arg_this = reinterpret_cast(arg); ESP_LOGVV(TAG, "socket %p: err(err=%d)", arg_this, err); arg_this->pcb_ = nullptr; } +void LWIPRawListenImpl::s_queued_err_fn(void *arg, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // Called when a queued (not yet accepted) PCB errors — e.g., remote sent RST. + // The PCB is already freed by lwip. Null our pointer so accept() skips it. + (void) err; + auto *entry = reinterpret_cast(arg); + entry->pcb = nullptr; + // Don't free rx_buf here — accept() will clean it up when it sees pcb==nullptr +} + +err_t LWIPRawListenImpl::s_queued_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). + // Temporary recv callback for PCBs queued between accept_fn_ and accept(). + // Without this, lwip's default tcp_recv_null handler would ACK and drop the data, + // causing the API handshake to silently fail (client sends Hello, server never sees it). + (void) pcb; + auto *entry = reinterpret_cast(arg); + if (pb == nullptr || err != ERR_OK) { + // Remote closed or error + if (pb != nullptr) { + pbuf_free(pb); + } + entry->rx_closed = true; + return ERR_OK; + } + // Buffer the data — tcp_recved() is deferred to read() after accept() creates the socket. + if (entry->rx_buf == nullptr) { + entry->rx_buf = pb; + } else { + pbuf_cat(entry->rx_buf, pb); + } + return ERR_OK; +} + err_t LWIPRawListenImpl::s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) { auto *arg_this = reinterpret_cast(arg); return arg_this->accept_fn_(newpcb, err); } std::unique_ptr LWIPRawListenImpl::accept(struct sockaddr *addr, socklen_t *addrlen) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return nullptr; } - if (this->accepted_socket_count_ == 0) { - errno = EWOULDBLOCK; - return nullptr; + // Dequeue front entry, skipping any null entries (PCBs freed by lwip while queued). + // The error callback nulled their pcb pointers; clean up buffered data and discard. + while (this->accepted_socket_count_ > 0) { + QueuedPcb entry = this->accepted_pcbs_[0]; + // Shift remaining entries forward, updating tcp_arg pointers as we go. + // Safe because we hold LWIP_LOCK, so err/recv callbacks can't fire during the update. + for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { + this->accepted_pcbs_[i - 1] = this->accepted_pcbs_[i]; + if (this->accepted_pcbs_[i - 1].pcb != nullptr) { + tcp_arg(this->accepted_pcbs_[i - 1].pcb, &this->accepted_pcbs_[i - 1]); + } + } + this->accepted_pcbs_[this->accepted_socket_count_ - 1] = {}; + this->accepted_socket_count_--; + if (entry.pcb == nullptr) { + // PCB was freed by lwip (RST/timeout) while queued — discard and try next + if (entry.rx_buf != nullptr) { + pbuf_free(entry.rx_buf); + } + continue; + } + LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); + // Create socket wrapper on the main loop (not in accept callback) to avoid + // heap allocation in IRQ context on RP2040. Transfer any data received while queued. + auto sock = make_unique(this->family_, entry.pcb); + sock->init(entry.rx_buf, entry.rx_closed); + if (addr != nullptr) { + sock->getpeername(addr, addrlen); + } + LWIP_LOG("accept(%p)", sock.get()); + return sock; } - // Take from front for FIFO ordering - std::unique_ptr sock = std::move(this->accepted_sockets_[0]); - // Shift remaining sockets forward - for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { - this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]); - } - this->accepted_socket_count_--; - LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); - if (addr != nullptr) { - sock->getpeername(addr, addrlen); - } - LWIP_LOG("accept(%p)", sock.get()); - return sock; + errno = EWOULDBLOCK; + return nullptr; } int LWIPRawListenImpl::listen(int backlog) { + LWIP_LOCK(); if (this->pcb_ == nullptr) { errno = EBADF; return -1; @@ -699,6 +919,8 @@ int LWIPRawListenImpl::listen(int backlog) { } err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { + // LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). + // No heap allocation allowed — malloc is not IRQ-safe (see #14687). LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -715,9 +937,18 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { // Must return ERR_ABRT since we called tcp_abort() return ERR_ABRT; } - auto sock = make_unique(this->family_, newpcb); - sock->init(); - this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); + // Store the raw PCB — LWIPRawImpl creation is deferred to the main-loop accept(). + // This avoids heap allocation in this callback, which is unsafe from IRQ context on RP2040. + uint8_t idx = this->accepted_socket_count_++; + this->accepted_pcbs_[idx] = {newpcb, nullptr, false}; + // Register temporary callbacks so that while the PCB is queued: + // - err: nulls our pointer if the connection errors (RST, timeout) + // - recv: buffers any data that arrives before accept() creates the LWIPRawImpl + // (without this, lwip's default tcp_recv_null would ACK and drop the data) + // tcp_arg points to our queue entry; accept() updates these pointers after shifting. + tcp_arg(newpcb, &this->accepted_pcbs_[idx]); + tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn); + tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn); LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); #if (defined(USE_ESP8266) || defined(USE_RP2040)) // Wake the main loop immediately so it can accept the new connection. @@ -734,6 +965,7 @@ std::unique_ptr socket(int domain, int type, int protocol) { errno = EPROTOTYPE; return nullptr; } + LWIP_LOCK(); auto *pcb = tcp_new(); if (pcb == nullptr) return nullptr; @@ -753,6 +985,7 @@ std::unique_ptr socket_listen(int domain, int type, int protocol) errno = EPROTOTYPE; return nullptr; } + LWIP_LOCK(); auto *pcb = tcp_new(); if (pcb == nullptr) return nullptr; @@ -766,6 +999,8 @@ std::unique_ptr socket_listen_loop_monitored(int domain, int type, return socket_listen(domain, type, protocol); } +#undef LWIP_LOCK + } // namespace esphome::socket #endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index c171e0537f3..3c27d71062f 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -57,6 +57,7 @@ class LWIPRawCommon { // instead use it for determining whether to call lwip_output bool nodelay_ = false; sa_family_t family_ = 0; + uint8_t recv_timeout_cs_ = 0; // SO_RCVTIMEO in centiseconds (0 = no timeout, max 2.55s) }; /// Connected socket implementation for LWIP raw TCP. @@ -66,7 +67,7 @@ class LWIPRawImpl : public LWIPRawCommon { using LWIPRawCommon::LWIPRawCommon; ~LWIPRawImpl(); - void init(); + void init(struct pbuf *initial_rx = nullptr, bool initial_rx_closed = false); // Non-listening sockets return error std::unique_ptr accept(struct sockaddr *, socklen_t *) { @@ -95,18 +96,20 @@ class LWIPRawImpl : public LWIPRawCommon { errno = ENOSYS; return -1; } + // Intentionally unlocked — this is a polling check called every loop iteration. + // A stale read at worst delays processing by one loop tick; the actual I/O in + // read() holds the lwip lock and re-checks properly. See esphome#10681. bool ready() const { return this->rx_buf_ != nullptr || this->rx_closed_ || this->pcb_ == nullptr; } + // No lock needed — only called during setup before callbacks are registered. + // A stale pcb_ read is benign (returns ECONNRESET, which the caller handles). int setblocking(bool blocking) { if (this->pcb_ == nullptr) { errno = ECONNRESET; return -1; } - if (blocking) { - // blocking operation not supported - errno = EINVAL; - return -1; - } + // Raw TCP doesn't use a blocking flag directly. Blocking behavior + // is provided by SO_RCVTIMEO which makes read() wait via socket_delay(). return 0; } int loop() { return 0; } @@ -117,6 +120,14 @@ class LWIPRawImpl : public LWIPRawCommon { static err_t s_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); protected: + // True when the socket could receive data but none has arrived yet. + // Safe to call without LWIP_LOCK — only null-checks pointers and reads a bool, + // all atomic on ARM/Xtensa. A stale value is harmless: the caller either does + // an unnecessary wait (stale true) or skips it (stale false), and the + // authoritative recheck happens under LWIP_LOCK afterward. + bool waiting_for_data_() const { return this->rx_buf_ == nullptr && !this->rx_closed_ && this->pcb_ != nullptr; } + void wait_for_data_(); + ssize_t read_locked_(void *buf, size_t len); ssize_t internal_write_(const void *buf, size_t len); int internal_output_(); @@ -134,6 +145,7 @@ class LWIPRawListenImpl : public LWIPRawCommon { void init(); + // Intentionally unlocked — polling check, see LWIPRawImpl::ready() comment. bool ready() const { return this->accepted_socket_count_ > 0; } std::unique_ptr accept(struct sockaddr *addr, socklen_t *addrlen); @@ -176,23 +188,28 @@ class LWIPRawListenImpl : public LWIPRawCommon { err_t accept_fn_(struct tcp_pcb *newpcb, err_t err); static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err); - // Accept queue - holds incoming connections briefly until the event loop calls accept() - // This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop - // 3 slots is plenty since connections are pulled out quickly by the event loop - // - // Memory analysis: std::array<3> vs original std::queue implementation: - // - std::queue uses std::deque internally which on 32-bit systems needs: - // 24 bytes (deque object) + 32+ bytes (map array) + heap allocations - // Total: ~56+ bytes minimum, plus heap fragmentation - // - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes) - // Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations - // Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation) - // - // By using a separate listening socket class, regular connected sockets save - // 16 bytes (12 bytes array + 1 byte count + 3 bytes padding) of memory overhead on 32-bit systems - static constexpr size_t MAX_ACCEPTED_SOCKETS = 3; - std::array, MAX_ACCEPTED_SOCKETS> accepted_sockets_; - uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue + // Temporary callbacks for queued PCBs (between accept_fn_ and accept()) + static void s_queued_err_fn(void *arg, err_t err); + static err_t s_queued_recv_fn(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err); + + // Accept queue entry — stores a raw tcp_pcb and any data received while queued. + // lwip's default tcp_recv_null handler drops data and ACKs it, so we must register + // a temporary recv callback to buffer any data that arrives between accept_fn_ + // (which stores the PCB) and accept() (which creates the LWIPRawImpl). + struct QueuedPcb { + struct tcp_pcb *pcb{nullptr}; + struct pbuf *rx_buf{nullptr}; // Data received while queued (before accept() picks it up) + bool rx_closed{false}; // Remote sent FIN while queued + }; + + // Accept queue — stores raw tcp_pcb entries instead of heap-allocated LWIPRawImpl objects. + // LWIPRawImpl creation is deferred to the main-loop accept() call. This avoids: + // - Heap allocation in the accept callback (unsafe from IRQ context on RP2040) + // - Dangling LWIPRawImpl if the connection errors before accept() picks it up + // 2 slots is plenty since the main loop drains the queue every iteration. + static constexpr size_t MAX_ACCEPTED_SOCKETS = 2; + std::array accepted_pcbs_{}; + uint8_t accepted_socket_count_ = 0; // Number of entries currently in queue }; } // namespace esphome::socket diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index c5792198635..bfc4da9926a 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -10,7 +10,7 @@ #include "headers.h" #ifdef USE_LWIP_FAST_SELECT -struct lwip_sock; +#include "esphome/core/lwip_fast_select.h" #endif namespace esphome::socket { @@ -52,6 +52,15 @@ class LwIPSocketImpl { return lwip_getsockopt(this->fd_, level, optname, optval, optlen); } int setsockopt(int level, int optname, const void *optval, socklen_t optlen) { +#if defined(USE_LWIP_FAST_SELECT) && defined(CONFIG_LWIP_TCPIP_CORE_LOCKING) + // Fast path for TCP_NODELAY: directly set the pcb flag under the TCPIP core lock, + // bypassing lwip_setsockopt overhead (socket lookups, hook, switch cascade, refcounting). + if (level == IPPROTO_TCP && optname == TCP_NODELAY && optlen == sizeof(int) && optval != nullptr) { + LwIPLock lock; + if (esphome_lwip_set_nodelay(this->cached_sock_, *reinterpret_cast(optval) != 0)) + return 0; + } +#endif return lwip_setsockopt(this->fd_, level, optname, optval, optlen); } int listen(int backlog) { return lwip_listen(this->fd_, backlog); } diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index 8ca0feccc0f..44f31979b46 100644 --- a/esphome/components/sound_level/sensor.py +++ b/esphome/components/sound_level/sensor.py @@ -89,8 +89,12 @@ SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action("sound_level.start", StartAction, SOUND_LEVEL_ACTION_SCHEMA) -@automation.register_action("sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA) +@automation.register_action( + "sound_level.start", StartAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "sound_level.stop", StopAction, SOUND_LEVEL_ACTION_SCHEMA, synchronous=True +) async def sound_level_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index 10ee6d52125..8480eebcdb2 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -78,6 +78,7 @@ async def speaker_action(config, action_id, template_arg, args): }, key=CONF_DATA, ), + synchronous=True, ) async def speaker_play_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -95,12 +96,12 @@ async def speaker_play_action(config, action_id, template_arg, args): return var -automation.register_action("speaker.stop", StopAction, SPEAKER_AUTOMATION_SCHEMA)( - speaker_action -) -automation.register_action("speaker.finish", FinishAction, SPEAKER_AUTOMATION_SCHEMA)( - speaker_action -) +automation.register_action( + "speaker.stop", StopAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True +)(speaker_action) +automation.register_action( + "speaker.finish", FinishAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True +)(speaker_action) automation.register_condition( "speaker.is_playing", IsPlayingCondition, SPEAKER_AUTOMATION_SCHEMA @@ -121,6 +122,7 @@ automation.register_condition( }, key=CONF_VOLUME, ), + synchronous=True, ) async def speaker_volume_set_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -131,9 +133,14 @@ async def speaker_volume_set_action(config, action_id, template_arg, args): @automation.register_action( - "speaker.mute_off", MuteOffAction, SPEAKER_AUTOMATION_SCHEMA + "speaker.mute_off", + MuteOffAction, + SPEAKER_AUTOMATION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "speaker.mute_on", MuteOnAction, SPEAKER_AUTOMATION_SCHEMA, synchronous=True ) -@automation.register_action("speaker.mute_on", MuteOnAction, SPEAKER_AUTOMATION_SCHEMA) async def speaker_mute_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 42ca762858f..92a178fe95e 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -505,6 +505,7 @@ async def to_code(config): }, key=CONF_MEDIA_FILE, ), + synchronous=True, ) async def play_on_device_media_media_action(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 8cea3abcfc9..0822d80254e 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -504,7 +504,7 @@ void AudioPipeline::decode_task(void *params) { if (!started_playback && has_stream_info) { // Verify enough data is available before starting playback std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); - if (temp_ring_buffer->available() >= initial_bytes_to_buffer) { + if (temp_ring_buffer != nullptr && temp_ring_buffer->available() >= initial_bytes_to_buffer) { started_playback = true; } } diff --git a/esphome/components/speaker_source/__init__.py b/esphome/components/speaker_source/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h new file mode 100644 index 00000000000..b436149a03f --- /dev/null +++ b/esphome/components/speaker_source/automation.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/core/automation.h" +#include "speaker_source_media_player.h" + +namespace esphome::speaker_source { + +template class SetPlaylistDelayAction : public Action { + public: + explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} + + TEMPLATABLE_VALUE(uint8_t, pipeline) + TEMPLATABLE_VALUE(uint32_t, delay) + + void play(const Ts &...x) override { + this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...)); + } + + protected: + SpeakerSourceMediaPlayer *parent_; +}; + +} // namespace esphome::speaker_source + +#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py new file mode 100644 index 00000000000..fe50536e8fd --- /dev/null +++ b/esphome/components/speaker_source/media_player.py @@ -0,0 +1,318 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import audio, media_player, media_source, speaker +import esphome.config_validation as cv +from esphome.const import ( + CONF_DELAY, + CONF_FORMAT, + CONF_ID, + CONF_NUM_CHANNELS, + CONF_SAMPLE_RATE, + CONF_SPEAKER, +) +from esphome.core import ID +from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType + +AUTO_LOAD = ["audio"] +DEPENDENCIES = ["media_source", "speaker"] + +CODEOWNERS = ["@kahrendt"] + +CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" +CONF_MEDIA_PIPELINE = "media_pipeline" +CONF_ON_MUTE = "on_mute" +CONF_PIPELINE = "pipeline" +CONF_ON_UNMUTE = "on_unmute" +CONF_ON_VOLUME = "on_volume" +CONF_SOURCES = "sources" +CONF_VOLUME_INCREMENT = "volume_increment" +CONF_VOLUME_INITIAL = "volume_initial" +CONF_VOLUME_MAX = "volume_max" +CONF_VOLUME_MIN = "volume_min" + +speaker_source_ns = cg.esphome_ns.namespace("speaker_source") + +SpeakerSourceMediaPlayer = speaker_source_ns.class_( + "SpeakerSourceMediaPlayer", cg.Component, media_player.MediaPlayer +) + +PipelineContext = speaker_source_ns.struct("PipelineContext") + +Pipeline = speaker_source_ns.enum("Pipeline") +PIPELINE_ENUM = { + "media": Pipeline.MEDIA_PIPELINE, + "announcement": Pipeline.ANNOUNCEMENT_PIPELINE, +} + +# Maps config key -> (C++ Pipeline enum value, format purpose) +_PIPELINE_INFO = { + CONF_MEDIA_PIPELINE: ( + Pipeline.MEDIA_PIPELINE, + media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"], + ), + CONF_ANNOUNCEMENT_PIPELINE: ( + Pipeline.ANNOUNCEMENT_PIPELINE, + media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"], + ), +} + +SetPlaylistDelayAction = speaker_source_ns.class_( + "SetPlaylistDelayAction", automation.Action +) + + +FORMAT_MAPPING = { + "FLAC": "flac", + "MP3": "mp3", + "OPUS": "opus", + "WAV": "wav", +} + + +# Returns a media_player.MediaPlayerSupportedFormat struct with the configured +# format, sample rate, number of channels, purpose, and bytes per sample +def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj): + args = [ + media_player.MediaPlayerSupportedFormat, + ] + + args.append(("format", FORMAT_MAPPING[pipeline[CONF_FORMAT]])) + + args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE])) + args.append(("num_channels", pipeline[CONF_NUM_CHANNELS])) + args.append(("purpose", purpose)) + + # Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails + # if the number of bytes per sample is specified for MP3. + if pipeline[CONF_FORMAT] != "MP3": + args.append(("sample_bytes", 2)) + + return cg.StructInitializer(*args) + + +def _validate_pipeline(config: ConfigType) -> ConfigType: + # Inherit settings from speaker if not manually set + inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config) + inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config) + + # Opus only supports 48 kHz + if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000: + raise cv.Invalid("Opus only supports a sample rate of 48000 Hz") + + audio.final_validate_audio_schema( + "speaker_source media_player", + audio_device=CONF_SPEAKER, + bits_per_sample=16, + channels=config.get(CONF_NUM_CHANNELS), + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +PIPELINE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id( + PipelineContext + ), # Needed to inherit audio settings from the speaker + cv.Required(CONF_SPEAKER): cv.use_id(speaker.Speaker), + cv.Required(CONF_SOURCES): cv.All( + cv.ensure_list(cv.use_id(media_source.MediaSource)), + cv.Length(min=1), + ), + cv.Optional(CONF_FORMAT, default="FLAC"): cv.enum(audio.AUDIO_FILE_TYPE_ENUM), + cv.Optional(CONF_SAMPLE_RATE): cv.int_range(min=1), + cv.Optional(CONF_NUM_CHANNELS): cv.int_range(1, 2), + } +) + + +def _validate_no_shared_resources(config: ConfigType) -> ConfigType: + announcement_config = config.get(CONF_ANNOUNCEMENT_PIPELINE) + media_config = config.get(CONF_MEDIA_PIPELINE) + + # Check for duplicates within each pipeline + for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): + if pipeline_config := config.get(pipeline_key): + source_ids = [s.id for s in pipeline_config[CONF_SOURCES]] + if len(source_ids) != len(set(source_ids)): + raise cv.Invalid( + f"Duplicate media sources in {pipeline_key}. " + "Each media source can only appear once per pipeline." + ) + + # Check for sources shared between pipelines + if announcement_config and media_config: + if announcement_config[CONF_SPEAKER] == media_config[CONF_SPEAKER]: + raise cv.Invalid( + "The announcement and media pipelines cannot use the same speaker. " + "Use the `mixer` speaker component to create two source speakers." + ) + + announcement_source_ids = {s.id for s in announcement_config[CONF_SOURCES]} + media_source_ids = {s.id for s in media_config[CONF_SOURCES]} + shared = announcement_source_ids & media_source_ids + if shared: + raise cv.Invalid( + f"Media sources cannot be shared between pipelines: {', '.join(shared)}. " + "Create separate media source instances for each pipeline." + ) + + return config + + +def _validate_volume_settings(config: ConfigType) -> ConfigType: + # CONF_VOLUME_INITIAL is in the scaled volume domain (0.0-1.0) and doesn't need to be validated + if config[CONF_VOLUME_MIN] > config[CONF_VOLUME_MAX]: + raise cv.Invalid( + f"{CONF_VOLUME_MIN} ({config[CONF_VOLUME_MIN]}) must be less than or equal to {CONF_VOLUME_MAX} ({config[CONF_VOLUME_MAX]})" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, + cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, + cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, + cv.Optional(CONF_VOLUME_MIN, default=0.0): cv.percentage, + cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): PIPELINE_SCHEMA, + cv.Optional(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA, + cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True), + cv.Optional(CONF_ON_UNMUTE): automation.validate_automation(single=True), + cv.Optional(CONF_ON_VOLUME): automation.validate_automation(single=True), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)), + cv.only_on_esp32, + cv.has_at_least_one_key(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE), + _validate_no_shared_resources, + _validate_volume_settings, +) + + +def _final_validate_codecs(config: ConfigType) -> ConfigType: + # "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available. + # When a specific format is set, only that codec is requested. + needed_formats: set[str] = set() + need_all = False + + for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE): + if pipeline := config.get(pipeline_key): + fmt = pipeline[CONF_FORMAT] + if fmt == "NONE": + need_all = True + else: + needed_formats.add(fmt) + + if need_all: + audio.request_flac_support() + audio.request_mp3_support() + audio.request_opus_support() + else: + if "FLAC" in needed_formats: + audio.request_flac_support() + if "MP3" in needed_formats: + audio.request_mp3_support() + if "OPUS" in needed_formats: + audio.request_opus_support() + + return config + + +FINAL_VALIDATE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): _validate_pipeline, + cv.Optional(CONF_MEDIA_PIPELINE): _validate_pipeline, + }, + extra=cv.ALLOW_EXTRA, + ), + _final_validate_codecs, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await media_player.register_media_player(var, config) + + cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT])) + cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL])) + cg.add(var.set_volume_max(config[CONF_VOLUME_MAX])) + cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) + + for pipeline_key, (pipeline_enum, purpose) in _PIPELINE_INFO.items(): + if pipeline_config := config.get(pipeline_key): + for source in pipeline_config[CONF_SOURCES]: + src = await cg.get_variable(source) + cg.add(var.add_media_source(pipeline_enum, src)) + + cg.add( + var.set_speaker( + pipeline_enum, + await cg.get_variable(pipeline_config[CONF_SPEAKER]), + ) + ) + if pipeline_config[CONF_FORMAT] != "NONE": + cg.add( + var.set_format( + pipeline_enum, + _get_supported_format_struct(pipeline_config, purpose), + ) + ) + + if on_mute := config.get(CONF_ON_MUTE): + await automation.build_automation( + var.get_mute_trigger(), + [], + on_mute, + ) + if on_unmute := config.get(CONF_ON_UNMUTE): + await automation.build_automation( + var.get_unmute_trigger(), + [], + on_unmute, + ) + if on_volume := config.get(CONF_ON_VOLUME): + await automation.build_automation( + var.get_volume_trigger(), + [(cg.float_, "x")], + on_volume, + ) + + +SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(SpeakerSourceMediaPlayer), + cv.Required(CONF_PIPELINE): cv.enum(PIPELINE_ENUM, lower=True), + cv.Required(CONF_DELAY): cv.templatable(cv.positive_time_period_milliseconds), + } +) + + +@automation.register_action( + "speaker_source.set_playlist_delay", + SetPlaylistDelayAction, + SET_PLAYLIST_DELAY_ACTION_SCHEMA, + synchronous=True, +) +async def set_playlist_delay_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, parent) + + cg.add(var.set_pipeline(config[CONF_PIPELINE])) + + template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32) + cg.add(var.set_delay(template_)) + + return var diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp new file mode 100644 index 00000000000..9d02ac9c744 --- /dev/null +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -0,0 +1,899 @@ +#include "speaker_source_media_player.h" + +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::speaker_source { + +static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; + +static const char *const TAG = "speaker_source_media_player"; + +// SourceBinding method implementations (defined here because SpeakerSourceMediaPlayer is forward-declared in the +// header) + +// THREAD CONTEXT: Called from media source decode task thread +size_t SourceBinding::write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) { + return this->player->handle_media_output_(this->pipeline, this->source, data, length, timeout_ms, stream_info); +} + +// THREAD CONTEXT: Called from main loop (media source's loop() calls set_state_ which calls report_state) +void SourceBinding::report_state(media_source::MediaSourceState state) { + this->player->handle_media_state_changed_(this->pipeline, this->source, state); +} + +// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop +void SourceBinding::request_volume(float volume) { + this->player->defer([this, volume]() { this->player->handle_volume_request_(volume); }); +} + +// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop +void SourceBinding::request_mute(bool is_muted) { + this->player->defer([this, is_muted]() { this->player->handle_mute_request_(is_muted); }); +} + +// THREAD CONTEXT: Called from media source task thread; uses defer() to marshal to main loop +void SourceBinding::request_play_uri(const std::string &uri) { + this->player->defer([this, uri]() { this->player->handle_play_uri_request_(this->pipeline, uri); }); +} + +// THREAD CONTEXT: Called during code generation setup (main loop) +void SpeakerSourceMediaPlayer::add_media_source(uint8_t pipeline, media_source::MediaSource *media_source) { + auto &binding = + this->pipelines_[pipeline].sources.emplace_back(std::make_unique(this, media_source, pipeline)); + media_source->set_listener(binding.get()); +} + +void SpeakerSourceMediaPlayer::dump_config() { + ESP_LOGCONFIG(TAG, + "Speaker Source Media Player:\n" + " Volume Increment: %.2f\n" + " Volume Min: %.2f\n" + " Volume Max: %.2f", + this->volume_increment_, this->volume_min_, this->volume_max_); +} + +void SpeakerSourceMediaPlayer::setup() { + this->state = media_player::MEDIA_PLAYER_STATE_IDLE; + + this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaPlayerControlCommand)); + + this->pref_ = this->make_entity_preference(); + + VolumeRestoreState volume_restore_state; + if (this->pref_.load(&volume_restore_state)) { + this->set_volume_(volume_restore_state.volume); + this->set_mute_state_(volume_restore_state.is_muted); + } else { + this->set_volume_(this->volume_initial_); + this->set_mute_state_(false); + } + + // Register callbacks to receive playback notifications from speakers + for (size_t i = 0; i < this->pipelines_.size(); i++) { + if (this->pipelines_[i].is_configured()) { + this->pipelines_[i].speaker->add_audio_output_callback([this, i](uint32_t frames, int64_t timestamp) { + this->handle_speaker_playback_callback_(frames, timestamp, i); + }); + } + } +} + +// THREAD CONTEXT: Called from the speaker's playback callback task (not main loop) +void SpeakerSourceMediaPlayer::handle_speaker_playback_callback_(uint32_t frames, int64_t timestamp, uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + // Load once so the null check and use below are consistent + media_source::MediaSource *active_source = ps.active_source.load(std::memory_order_relaxed); + if (active_source == nullptr) { + return; + } + + // CAS loop to safely subtract frames without underflow. If pending_frames is reset to 0 (new source + // starting) between the load and the subtract, compare_exchange_weak will fail and reload the current value. + uint32_t current = ps.pending_frames.load(std::memory_order_relaxed); + uint32_t source_frames; + do { + source_frames = std::min(frames, current); + } while (source_frames > 0 && + !ps.pending_frames.compare_exchange_weak(current, current - source_frames, std::memory_order_relaxed)); + + if (source_frames > 0) { + // Notify the source about the played audio + active_source->notify_audio_played(source_frames, timestamp); + } +} + +// THREAD CONTEXT: Called from main loop via defer() +void SpeakerSourceMediaPlayer::handle_volume_request_(float volume) { + // Update the media player's volume + this->set_volume_(volume); + this->publish_state(); +} + +// THREAD CONTEXT: Called from main loop via defer() +void SpeakerSourceMediaPlayer::handle_mute_request_(bool is_muted) { + // Update the media player's mute state + this->set_mute_state_(is_muted); + this->publish_state(); +} + +// THREAD CONTEXT: Called from main loop via defer() +void SpeakerSourceMediaPlayer::handle_play_uri_request_(uint8_t pipeline, const std::string &uri) { + // Smart source is requesting the player to play a different URI + auto call = this->make_call(); + call.set_media_url(uri); + call.set_announcement(pipeline == ANNOUNCEMENT_PIPELINE); + call.perform(); +} + +// THREAD CONTEXT: Called from main loop (media source's loop() calls set_state_ which calls report_state) +void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, media_source::MediaSource *source, + media_source::MediaSourceState state) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (state == media_source::MediaSourceState::IDLE) { + // Track whether this IDLE was from an orchestrator-initiated stop (e.g., NEXT/PREV/PLAY_URI) + // so we can suppress spurious PLAYLIST_ADVANCE below + bool was_stopping = (ps.stopping_source == source); + + // Source went idle - clear stopping flag if this was the source we asked to stop + if (was_stopping) { + ps.stopping_source = nullptr; + } + + // Clear pending flag if this was the source we asked to play + if (ps.pending_source == source) { + ps.pending_source = nullptr; + } + + // Source went idle - clear it if it's the active source + if (ps.active_source == source) { + ps.last_source = ps.active_source; + ps.active_source = nullptr; + + // Finish the speaker to ensure it's ready for the next playback + ps.speaker->finish(); + + // Only advance the playlist if the track finished naturally (not stopped by the orchestrator) + if (!was_stopping) { + this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline); + } + } + } else if (state == media_source::MediaSourceState::PLAYING) { + // Source started playing - make it the active source if no one else is active + if (ps.active_source == nullptr) { + ps.active_source = source; + ps.last_source = nullptr; + + // Clear pending flag now that the source is active + if (ps.pending_source == source) { + ps.pending_source = nullptr; + } + } + } +} + +// THREAD CONTEXT: Called from media source decode task thread (not main loop). +// Reads ps.active_source (atomic), writes ps.pending_frames (atomic), and calls +// ps.speaker methods (speaker pointer is immutable after setup). +size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, + const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) { + PipelineContext &ps = this->pipelines_[pipeline]; + + // Single read; the if-body only uses ps.speaker (immutable after setup) and the source parameter. + if (ps.active_source == source) { + // This source is active - play the audio + if (ps.speaker->get_audio_stream_info() != stream_info) { + // Setup the speaker to play this stream + ps.speaker->set_audio_stream_info(stream_info); + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; + } + size_t bytes_written = ps.speaker->play(data, length, pdMS_TO_TICKS(timeout_ms)); + if (bytes_written > 0) { + // Track frames sent to speaker for this source + ps.pending_frames.fetch_add(stream_info.bytes_to_frames(bytes_written), std::memory_order_relaxed); + } + return bytes_written; + } + + // Not the active source - wait for state callback to set us as active when we transition to PLAYING + vTaskDelay(pdMS_TO_TICKS(timeout_ms)); + return 0; +} + +// THREAD CONTEXT: Called from main loop (loop) +media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_source_state_( + media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const { + if (source != nullptr) { + switch (source->get_state()) { + case media_source::MediaSourceState::PLAYING: + return media_player::MEDIA_PLAYER_STATE_PLAYING; + case media_source::MediaSourceState::PAUSED: + return media_player::MEDIA_PLAYER_STATE_PAUSED; + case media_source::MediaSourceState::ERROR: + ESP_LOGE(TAG, "Media source error"); + return media_player::MEDIA_PLAYER_STATE_IDLE; + case media_source::MediaSourceState::IDLE: + default: + return media_player::MEDIA_PLAYER_STATE_IDLE; + } + } + + // No active source. Stay PLAYING during playlist transitions + if (playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_PLAYING) { + return media_player::MEDIA_PLAYER_STATE_PLAYING; + } + return media_player::MEDIA_PLAYER_STATE_IDLE; +} + +void SpeakerSourceMediaPlayer::loop() { + // Process queued control commands + this->process_control_queue_(); + + // Update state based on active sources - announcement pipeline takes priority + media_player::MediaPlayerState old_state = this->state; + + PipelineContext &ann_ps = this->pipelines_[ANNOUNCEMENT_PIPELINE]; + PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE]; + + // Check playlist state to detect transitions between items + bool announcement_playlist_active = (ann_ps.playlist_index < ann_ps.playlist.size()) || + (ann_ps.repeat_mode != REPEAT_OFF && !ann_ps.playlist.empty()); + bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) || + (media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty()); + + // Check announcement pipeline first + media_source::MediaSource *announcement_source = ann_ps.active_source; + if (announcement_source != nullptr) { + media_source::MediaSourceState announcement_state = announcement_source->get_state(); + if (announcement_state != media_source::MediaSourceState::IDLE) { + // Announcement is active - announcements take priority and never report PAUSED + switch (announcement_state) { + case media_source::MediaSourceState::PLAYING: + case media_source::MediaSourceState::PAUSED: // Treat paused announcements as announcing + this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING; + break; + case media_source::MediaSourceState::ERROR: + ESP_LOGE(TAG, "Announcement source error"); + // Fall through to media pipeline state + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + break; + default: + break; + } + } else { + // Announcement source is idle, fall through to media pipeline + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + } + } else if (announcement_playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) { + this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING; + } else { + // No active announcement, check media pipeline + this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); + } + + if (this->state != old_state) { + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); + } +} + +media_source::MediaSource *SpeakerSourceMediaPlayer::find_source_for_uri_(const std::string &uri, uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *first_match = nullptr; + for (auto &binding : ps.sources) { + if (binding->source->can_handle(uri)) { + // Prefer an idle source; otherwise remember the first match (will be stopped by try_execute_play_uri_) + if (binding->source->get_state() == media_source::MediaSourceState::IDLE) { + return binding->source; + } + if (first_match == nullptr) { + first_match = binding->source; + } + } + } + return first_match; +} + +bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uint8_t pipeline) { + // Find target source + media_source::MediaSource *target_source = this->find_source_for_uri_(uri, pipeline); + if (target_source == nullptr) { + ESP_LOGW(TAG, "No source for URI"); + ESP_LOGV(TAG, "URI: %s", uri.c_str()); + return true; // Remove from queue (unrecoverable) + } + + PipelineContext &ps = this->pipelines_[pipeline]; + + media_source::MediaSource *active_source = ps.active_source; + + // If active source exists and is not IDLE, stop it and wait + if (active_source != nullptr) { + media_source::MediaSourceState active_state = active_source->get_state(); + if (active_state != media_source::MediaSourceState::IDLE) { + // Only send END command once per source - check if we've already asked this source to stop + if (ps.stopping_source != active_source) { + ESP_LOGV(TAG, "Pipeline %u: stopping active source", pipeline); + ps.stopping_source = active_source; + active_source->handle_command(media_source::MediaSourceCommand::STOP); + ps.speaker->stop(); + } + return false; // Leave in queue, retry next loop + } + } + + // Also check target source directly - handles case where source errored before PLAYING state + media_source::MediaSourceState target_state = target_source->get_state(); + if (target_state != media_source::MediaSourceState::IDLE) { + // Only send STOP command once per source + if (ps.stopping_source != target_source) { + ESP_LOGV(TAG, "Pipeline %u: target source busy, stopping", pipeline); + ps.stopping_source = target_source; + target_source->handle_command(media_source::MediaSourceCommand::STOP); + ps.speaker->stop(); + } + return false; // Leave in queue, retry next loop + } + + // Clear stopping flag since we're past the stopping phase + ps.stopping_source = nullptr; + + // Check if speaker is ready + if (!ps.speaker->is_stopped()) { + return false; // Speaker not ready yet, retry later + } + + // Set pending source so handle_media_state_changed_ can recognize it when the source transitions to PLAYING + ps.pending_source = target_source; + + // Speaker is ready, try to play + if (!target_source->play_uri(uri)) { + ESP_LOGE(TAG, "Pipeline %u: Failed to play URI: %s", pipeline, uri.c_str()); + ps.pending_source = nullptr; + this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline); + } + + // Reset pending frame counter for this pipeline since we're starting a new source + ps.pending_frames.store(0, std::memory_order_relaxed); + + return true; // Remove from queue +} + +// THREAD CONTEXT: Called from main loop (process_control_queue_, queue_play_current_, handle_media_state_changed_) +void SpeakerSourceMediaPlayer::queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline) { + MediaPlayerControlCommand cmd{}; + cmd.type = type; + cmd.pipeline = pipeline; + if (xQueueSend(this->media_control_command_queue_, &cmd, 0) != pdTRUE) { + ESP_LOGE(TAG, "Queue full, command dropped"); + } +} + +// THREAD CONTEXT: Called from main loop via automation commands (direct) +void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms) { + if (pipeline < this->pipelines_.size()) { + this->pipelines_[pipeline].playlist_delay_ms = delay_ms; + } +} + +// THREAD CONTEXT: Called from main loop (process_control_queue_). +// The timeout callback also runs on the main loop. +void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) { + if (delay_ms > 0) { + this->set_timeout(PIPELINE_TIMEOUT_IDS[pipeline], delay_ms, + [this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); }); + } else { + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } +} + +// THREAD CONTEXT: Called from main loop (loop) +void SpeakerSourceMediaPlayer::process_control_queue_() { + MediaPlayerControlCommand control_command{}; + + // Use peek to check command without removing it + if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { + return; + } + + bool command_executed = false; + uint8_t pipeline = control_command.pipeline; + + // Get pipeline state + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *active_source = ps.active_source; + + switch (control_command.type) { + case MediaPlayerControlCommand::PLAY_URI: { + // Always use our local playlist to start playback + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); + ps.playlist.clear(); + ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist + ps.playlist_index = 0; // Reset index + ps.playlist.push_back(*control_command.data.uri); + + // Queue PLAY_CURRENT to initiate playback + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + command_executed = true; + break; + } + + case MediaPlayerControlCommand::ENQUEUE_URI: { + // Always add to our local playlist + ps.playlist.push_back(*control_command.data.uri); + + // If shuffle is active, add the new item to the end of the shuffle order + if (!ps.shuffle_indices.empty()) { + ps.shuffle_indices.push_back(ps.playlist.size() - 1); + } + + // If nothing is playing and no upcoming items are queued, start the new item. + bool nothing_playing = + (active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE); + if (nothing_playing && ps.playlist_index >= ps.playlist.size() - 1) { + ps.playlist_index = ps.playlist.size() - 1; // Point to newly added item + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + command_executed = true; + break; + } + + case MediaPlayerControlCommand::PLAYLIST_ADVANCE: { + // Internal message: a track finished, advance to next + if (ps.repeat_mode != REPEAT_ONE) { + ps.playlist_index++; + } + + // Check if we should continue playback + if (ps.playlist_index < ps.playlist.size()) { + this->queue_play_current_(pipeline, ps.playlist_delay_ms); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = 0; + this->queue_play_current_(pipeline, ps.playlist_delay_ms); + } + command_executed = true; + break; + } + + case MediaPlayerControlCommand::PLAY_CURRENT: { + // Play the item at current playlist index (mapped through shuffle if active) + if (ps.playlist_index < ps.playlist.size()) { + size_t actual_position = this->get_playlist_position_(pipeline); + command_executed = this->try_execute_play_uri_(ps.playlist[actual_position], pipeline); + } else { + command_executed = true; // Index out of bounds or empty playlist + } + break; + } + + case MediaPlayerControlCommand::SEND_COMMAND: { + this->handle_player_command_(control_command.data.command, pipeline); + command_executed = true; + break; + } + } + + // Only remove from queue if successfully executed + if (command_executed) { + xQueueReceive(this->media_control_command_queue_, &control_command, 0); + + // Delete the allocated string for PLAY_URI and ENQUEUE_URI commands + if (control_command.type == MediaPlayerControlCommand::PLAY_URI || + control_command.type == MediaPlayerControlCommand::ENQUEUE_URI) { + delete control_command.data.uri; + } + } +} + +// THREAD CONTEXT: Called from main loop only (via process_control_queue_) +void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerCommand player_command, + uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + media_source::MediaSource *active_source = ps.active_source; + bool has_internal_playlist = (active_source != nullptr) && active_source->has_internal_playlist(); + + // Determine target source: prefer active, fall back to last + media_source::MediaSource *target_source = nullptr; + if (active_source != nullptr) { + target_source = active_source; + } else if (ps.last_source != nullptr) { + target_source = ps.last_source; + } + + switch (player_command) { + case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: { + // Convert TOGGLE to PLAY or PAUSE based on current state + if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + } else if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) { + bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist(); + if (last_has_internal_playlist) { + ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY); + } else { + if (ps.playlist_index >= ps.playlist.size()) { + ps.playlist_index = 0; + } + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PLAY: { + if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) { + bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist(); + if (last_has_internal_playlist) { + ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY); + } else { + if (ps.playlist_index >= ps.playlist.size()) { + ps.playlist_index = 0; + } + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PLAY); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PAUSE: { + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PAUSE); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_STOP: { + if (!has_internal_playlist) { + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); + ps.playlist.clear(); + ps.shuffle_indices.clear(); + ps.playlist_index = 0; + } + if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::STOP); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_NEXT: { + if (!has_internal_playlist) { + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); + if (ps.playlist_index + 1 < ps.playlist.size()) { + ps.playlist_index++; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = 0; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::NEXT); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: { + if (!has_internal_playlist) { + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); + if (ps.playlist_index > 0) { + ps.playlist_index--; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) { + ps.playlist_index = ps.playlist.size() - 1; + this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); + } + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::PREVIOUS); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_ONE; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ONE); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_OFF; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_OFF); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL: + if (!has_internal_playlist) { + ps.repeat_mode = REPEAT_ALL; + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ALL); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: { + if (!has_internal_playlist) { + this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]); + if (ps.playlist_index < ps.playlist.size()) { + size_t actual_position = this->get_playlist_position_(pipeline); + ps.playlist[0] = std::move(ps.playlist[actual_position]); + ps.playlist.resize(1); + ps.playlist_index = 0; + } else { + ps.playlist.clear(); + ps.playlist_index = 0; + } + ps.shuffle_indices.clear(); + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST); + } + break; + } + + case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE: + if (!has_internal_playlist) { + this->shuffle_playlist_(pipeline); + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::SHUFFLE); + } + break; + + case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE: + if (!has_internal_playlist) { + this->unshuffle_playlist_(pipeline); + } else if (target_source != nullptr) { + target_source->handle_command(media_source::MediaSourceCommand::UNSHUFFLE); + } + break; + + default: + // TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL) are no-ops + break; + } +} + +// THREAD CONTEXT: Called from main loop only. Entry points: +// - HA/automation commands (direct) +// - handle_play_uri_request_() via make_call().perform() (deferred from source tasks) +void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call) { + if (!this->is_ready()) { + return; + } + + MediaPlayerControlCommand control_command{}; + + // Determine which pipeline to use based on announcement flag, falling back if the preferred pipeline + // is not configured + auto announcement = call.get_announcement(); + if (announcement.has_value() && announcement.value()) { + if (this->pipelines_[ANNOUNCEMENT_PIPELINE].is_configured()) { + control_command.pipeline = ANNOUNCEMENT_PIPELINE; + } else { + control_command.pipeline = MEDIA_PIPELINE; + } + } else { + if (this->pipelines_[MEDIA_PIPELINE].is_configured()) { + control_command.pipeline = MEDIA_PIPELINE; + } else { + control_command.pipeline = ANNOUNCEMENT_PIPELINE; + } + } + + auto media_url = call.get_media_url(); + if (media_url.has_value()) { + auto command = call.get_command(); + bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE; + + if (enqueue) { + control_command.type = MediaPlayerControlCommand::ENQUEUE_URI; + } else { + control_command.type = MediaPlayerControlCommand::PLAY_URI; + } + // Heap allocation is unavoidable: URIs from Home Assistant are arbitrary-length (media URLs with tokens + // can easily exceed 500 bytes). Deleted in process_control_queue_() after the command is consumed. FreeRTOS queues + // require items to be copyable, so we store a pointer to the string in the queue rather than the string itself. + control_command.data.uri = new std::string(media_url.value()); + if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { + delete control_command.data.uri; + ESP_LOGE(TAG, "Queue full, URI dropped"); + } + return; + } + + auto volume = call.get_volume(); + if (volume.has_value()) { + this->set_volume_(volume.value()); + this->publish_state(); + return; + } + + auto cmd = call.get_command(); + if (cmd.has_value()) { + switch (cmd.value()) { + case media_player::MEDIA_PLAYER_COMMAND_MUTE: + this->set_mute_state_(true); + break; + case media_player::MEDIA_PLAYER_COMMAND_UNMUTE: + this->set_mute_state_(false); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP: + this->set_volume_(std::min(1.0f, this->volume + this->volume_increment_)); + break; + case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN: + this->set_volume_(std::max(0.0f, this->volume - this->volume_increment_)); + break; + default: + // Queue command for processing in loop() + control_command.type = MediaPlayerControlCommand::SEND_COMMAND; + control_command.data.command = cmd.value(); + if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) { + ESP_LOGE(TAG, "Queue full, command dropped"); + } + return; + } + this->publish_state(); + } +} + +media_player::MediaPlayerTraits SpeakerSourceMediaPlayer::get_traits() { + // This media player supports more traits like playlists, repeat, and shuffle, but the ESPHome API currently (March + // 2026) doesn't support those commands, so we only report pause support for now since that's used by the frontend and + // supported by our player. + auto traits = media_player::MediaPlayerTraits(); + traits.set_supports_pause(true); + + for (const auto &ps : this->pipelines_) { + if (ps.format.has_value()) { + traits.get_supported_formats().push_back(ps.format.value()); + } + } + + return traits; +} + +void SpeakerSourceMediaPlayer::save_volume_restore_state_() { + VolumeRestoreState volume_restore_state; + volume_restore_state.volume = this->volume; + volume_restore_state.is_muted = this->is_muted_; + this->pref_.save(&volume_restore_state); +} + +void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { + if (this->is_muted_ == mute_state) { + return; + } + + for (auto &ps : this->pipelines_) { + if (ps.is_configured()) { + ps.speaker->set_mute_state(mute_state); + } + } + + this->is_muted_ = mute_state; + + if (publish) { + this->save_volume_restore_state_(); + } + + // Notify all media sources about the mute state change + for (auto &ps : this->pipelines_) { + for (auto &binding : ps.sources) { + binding->source->notify_mute_changed(mute_state); + } + } + + if (mute_state) { + this->defer([this]() { this->mute_trigger_.trigger(); }); + } else { + this->defer([this]() { this->unmute_trigger_.trigger(); }); + } +} + +void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { + // Remap the volume to fit within the configured limits + float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + + for (auto &ps : this->pipelines_) { + if (ps.is_configured()) { + ps.speaker->set_volume(bounded_volume); + } + } + + if (publish) { + this->volume = volume; + } + + // Notify all media sources about the volume change + for (auto &ps : this->pipelines_) { + for (auto &binding : ps.sources) { + binding->source->notify_volume_changed(volume); + } + } + + // Turn on the mute state if the volume is effectively zero, off otherwise. + // Pass publish=false to avoid saving twice. + if (volume < 0.001) { + this->set_mute_state_(true, false); + } else { + this->set_mute_state_(false, false); + } + + // Save after mute mutation so the restored state has the correct is_muted_ value + if (publish) { + this->save_volume_restore_state_(); + } + + this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); +} + +size_t SpeakerSourceMediaPlayer::get_playlist_position_(uint8_t pipeline) const { + const PipelineContext &ps = this->pipelines_[pipeline]; + + if (ps.shuffle_indices.empty() || ps.playlist_index >= ps.shuffle_indices.size()) { + return ps.playlist_index; + } + return ps.shuffle_indices[ps.playlist_index]; +} + +void SpeakerSourceMediaPlayer::shuffle_playlist_(uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (ps.playlist.size() <= 1) { + ps.shuffle_indices.clear(); + return; + } + + // Capture current actual position BEFORE modifying shuffle_indices + size_t current_actual = this->get_playlist_position_(pipeline); + + // Build indices vector + ps.shuffle_indices.resize(ps.playlist.size()); + for (size_t i = 0; i < ps.playlist.size(); i++) { + ps.shuffle_indices[i] = i; + } + + // Fisher-Yates shuffle using ESPHome's random helper + for (size_t i = ps.shuffle_indices.size() - 1; i > 0; i--) { + size_t j = random_uint32() % (i + 1); + std::swap(ps.shuffle_indices[i], ps.shuffle_indices[j]); + } + + // Move current track to current position (so playback continues seamlessly) + if (ps.playlist_index < ps.shuffle_indices.size()) { + for (size_t i = 0; i < ps.shuffle_indices.size(); i++) { + if (ps.shuffle_indices[i] == current_actual) { + std::swap(ps.shuffle_indices[i], ps.shuffle_indices[ps.playlist_index]); + break; + } + } + } +} + +void SpeakerSourceMediaPlayer::unshuffle_playlist_(uint8_t pipeline) { + PipelineContext &ps = this->pipelines_[pipeline]; + + if (!ps.shuffle_indices.empty() && ps.playlist_index < ps.shuffle_indices.size()) { + ps.playlist_index = ps.shuffle_indices[ps.playlist_index]; + } + ps.shuffle_indices.clear(); +} + +} // namespace esphome::speaker_source + +#endif // USE_ESP32 diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h new file mode 100644 index 00000000000..652390edd22 --- /dev/null +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -0,0 +1,263 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/audio/audio.h" +#include "esphome/components/media_source/media_source.h" +#include "esphome/components/media_player/media_player.h" +#include "esphome/components/speaker/speaker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" + +#include +#include +#include +#include +#include +#include + +namespace esphome::speaker_source { + +// THREADING MODEL: +// This component coordinates media sources that run their own decode tasks with speakers +// that have their own playback callback tasks. Three thread contexts exist: +// +// - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(), +// handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(), +// set_volume_(), set_mute_state_(), control(), get_source_state_(), +// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(), +// process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_() +// +// - Media source task(s): handle_media_output_() via SourceBinding::write_audio(). +// Called from each source's decode task thread when streaming audio data. +// Reads ps.active_source (atomic), writes ps.pending_frames (atomic), and calls +// ps.speaker methods (speaker pointer is immutable after setup). +// +// - Speaker callback task: handle_speaker_playback_callback_() via speaker's +// add_audio_output_callback(). Called when the speaker finishes writing frames to the DAC. +// Reads ps.active_source (atomic), writes ps.pending_frames (atomic), and calls +// active_source->notify_audio_played(). +// +// control() is only called from the main loop (HA/automation commands). +// Source tasks use defer() for all requests (volume, mute, play_uri). +// +// Thread-safe communication: +// - FreeRTOS queue (media_control_command_queue_): control() -> loop() for play/command dispatch +// - defer(): SourceBinding::request_volume/request_mute/request_play_uri -> main loop +// - Atomic fields (active_source, pending_frames): shared between all three thread contexts +// +// Non-atomic pipeline fields (last_source, stopping_source, pending_source, playlist, +// playlist_index, repeat_mode) are only accessed from the main loop thread. + +enum Pipeline : uint8_t { + MEDIA_PIPELINE = 0, + ANNOUNCEMENT_PIPELINE = 1, +}; + +enum RepeatMode : uint8_t { + REPEAT_OFF = 0, + REPEAT_ONE = 1, + REPEAT_ALL = 2, +}; + +// Forward declaration +class SpeakerSourceMediaPlayer; + +/// @brief Per-source listener binding that captures the source pointer at registration time. +/// Each binding implements MediaSourceListener and forwards callbacks to the player with the source identified. +/// Defined before PipelineContext so pipelines can own their bindings directly. +struct SourceBinding : public media_source::MediaSourceListener { + SourceBinding(SpeakerSourceMediaPlayer *player, media_source::MediaSource *source, uint8_t pipeline) + : player(player), source(source), pipeline(pipeline) {} + SpeakerSourceMediaPlayer *player; + media_source::MediaSource *source; + uint8_t pipeline; + + // Implementations are in the .cpp file because SpeakerSourceMediaPlayer is only forward-declared here + size_t write_audio(const uint8_t *data, size_t length, uint32_t timeout_ms, + const audio::AudioStreamInfo &stream_info) override; + void report_state(media_source::MediaSourceState state) override; + void request_volume(float volume) override; + void request_mute(bool is_muted) override; + void request_play_uri(const std::string &uri) override; +}; + +/// @brief Timeout IDs for playlist delay, indexed by Pipeline enum +static constexpr uint32_t PIPELINE_TIMEOUT_IDS[] = {1, 2}; + +struct PipelineContext { + speaker::Speaker *speaker{nullptr}; + optional format; + + std::atomic active_source{nullptr}; + media_source::MediaSource *last_source{nullptr}; + media_source::MediaSource *stopping_source{nullptr}; // Source we've asked to stop, awaiting IDLE + media_source::MediaSource *pending_source{nullptr}; // Source we've asked to play, awaiting PLAYING + + // Each SourceBinding pairs a MediaSource* with its listener implementation. + // Uses unique_ptr so binding addresses are stable and set_listener() can be called in add_media_source(). + // Uses std::vector because the count varies across instances (multiple speaker_source media players may exist). + std::vector> sources; + + // Dynamic allocation is unavoidable here: URIs from Home Assistant are arbitrary-length strings + // (media URLs with tokens can easily exceed 500 bytes), and playlist size is unbounded. + // Pre-allocating fixed buffers would waste significant RAM when idle without covering worst cases. + std::vector playlist; + size_t playlist_index{0}; + RepeatMode repeat_mode{REPEAT_OFF}; + uint32_t playlist_delay_ms{0}; + + // When non-empty, playlist_index indexes into these vectors + // which contain the actual playlist indices in shuffled order + std::vector shuffle_indices; + + // Track frames sent to speaker to correlate with playback callbacks. + // Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback + // callback. + std::atomic pending_frames{0}; + + /// @brief Check if this pipeline is configured (has a speaker assigned) + bool is_configured() const { return this->speaker != nullptr; } +}; + +struct MediaPlayerControlCommand { + enum Type : uint8_t { + PLAY_URI, // Clear playlist, reset index, add URI, queue PLAY_CURRENT + ENQUEUE_URI, // Add URI to playlist, queue PLAY_CURRENT if idle + PLAYLIST_ADVANCE, // Advance index (or wrap for repeat_all), queue PLAY_CURRENT if more items + PLAY_CURRENT, // Play item at current playlist index (can retry if speaker not ready) + SEND_COMMAND, // Send command to active source + }; + Type type; + uint8_t pipeline; // MEDIA_PIPELINE or ANNOUNCEMENT_PIPELINE + + union { + std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI) + media_player::MediaPlayerCommand command; + } data; +}; + +struct VolumeRestoreState { + float volume; + bool is_muted; +}; + +class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPlayer { + friend struct SourceBinding; + + public: + float get_setup_priority() const override { return esphome::setup_priority::PROCESSOR; } + void setup() override; + void loop() override; + void dump_config() override; + + // MediaPlayer implementations + media_player::MediaPlayerTraits get_traits() override; + bool is_muted() const override { return this->is_muted_; } + + // Percentage to increase or decrease the volume for volume up or volume down commands + void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; } + + // Volume used initially on first boot when no volume had been previously saved + void set_volume_initial(float volume_initial) { this->volume_initial_ = volume_initial; } + + void set_volume_max(float volume_max) { this->volume_max_ = volume_max; } + void set_volume_min(float volume_min) { this->volume_min_ = volume_min; } + + /// @brief Adds a media source to a pipeline and registers this player as its listener + void add_media_source(uint8_t pipeline, media_source::MediaSource *media_source); + + void set_speaker(uint8_t pipeline, speaker::Speaker *speaker) { this->pipelines_[pipeline].speaker = speaker; } + void set_format(uint8_t pipeline, const media_player::MediaPlayerSupportedFormat &format) { + this->pipelines_[pipeline].format = format; + } + + Trigger<> *get_mute_trigger() { return &this->mute_trigger_; } + Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; } + Trigger *get_volume_trigger() { return &this->volume_trigger_; } + + void set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms); + + protected: + // Callbacks from source bindings (pipeline index is captured at binding creation time) + size_t handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, const uint8_t *data, size_t length, + uint32_t timeout_ms, const audio::AudioStreamInfo &stream_info); + void handle_media_state_changed_(uint8_t pipeline, media_source::MediaSource *source, + media_source::MediaSourceState state); + void handle_volume_request_(float volume); + void handle_mute_request_(bool is_muted); + void handle_play_uri_request_(uint8_t pipeline, const std::string &uri); + + void handle_speaker_playback_callback_(uint32_t frames, int64_t timestamp, uint8_t pipeline); + + // Receives commands from HA or from the voice assistant component + // Sends commands to the media_control_command_queue_ + void control(const media_player::MediaPlayerCall &call) override; + + /// @brief Updates this->volume and saves volume/mute state to flash for restoration if publish is true. + void set_volume_(float volume, bool publish = true); + + /// @brief Sets the mute state. + /// @param mute_state If true, audio will be muted. If false, audio will be unmuted + /// @param publish If true, saves volume/mute state to flash for restoration + void set_mute_state_(bool mute_state, bool publish = true); + + /// @brief Saves the current volume and mute state to the flash for restoration. + void save_volume_restore_state_(); + + /// @brief Determine media player state from a pipeline's active source + /// @param media_source Active source (may be nullptr) + /// @param playlist_active Whether the pipeline's playlist is in progress + /// @param old_state Previous media player state (used for transition smoothing) + /// @return The appropriate MediaPlayerState + media_player::MediaPlayerState get_source_state_(media_source::MediaSource *media_source, bool playlist_active, + media_player::MediaPlayerState old_state) const; + + void process_control_queue_(); + void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline); + bool try_execute_play_uri_(const std::string &uri, uint8_t pipeline); + media_source::MediaSource *find_source_for_uri_(const std::string &uri, uint8_t pipeline); + void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline); + void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0); + + /// @brief Maps playlist_index through shuffle indices if shuffle is active + size_t get_playlist_position_(uint8_t pipeline) const; + + /// @brief Generates shuffled indices for the playlist, keeping current track at current position + void shuffle_playlist_(uint8_t pipeline); + + /// @brief Clears shuffle indices and adjusts playlist_index to maintain current track + void unshuffle_playlist_(uint8_t pipeline); + + QueueHandle_t media_control_command_queue_; + + // Pipeline context for media (index 0) and announcement (index 1) pipelines. + // See THREADING MODEL at top of namespace for access rules. + std::array pipelines_; + + // Used to save volume/mute state for restoration on reboot + ESPPreferenceObject pref_; + + Trigger<> mute_trigger_; + Trigger<> unmute_trigger_; + Trigger volume_trigger_; + + // The amount to change the volume on volume up/down commands + float volume_increment_; + + // The initial volume used by Setup when no previous volume was saved + float volume_initial_; + + float volume_max_; + float volume_min_; + + bool is_muted_{false}; +}; + +} // namespace esphome::speaker_source + +#endif // USE_ESP32 diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 50c69f94967..6e2ff4ee2e1 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -422,6 +422,7 @@ CONFIG_SCHEMA = cv.All( "sprinkler.set_divider", SetDividerAction, SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, + synchronous=True, ) async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -435,6 +436,7 @@ async def sprinkler_set_divider_to_code(config, action_id, template_arg, args): "sprinkler.set_multiplier", SetMultiplierAction, SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, + synchronous=True, ) async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -448,6 +450,7 @@ async def sprinkler_set_multiplier_to_code(config, action_id, template_arg, args "sprinkler.queue_valve", QueueValveAction, SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, + synchronous=True, ) async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -463,6 +466,7 @@ async def sprinkler_set_queued_valve_to_code(config, action_id, template_arg, ar "sprinkler.set_repeat", SetRepeatAction, SPRINKLER_ACTION_REPEAT_SCHEMA, + synchronous=True, ) async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -476,6 +480,7 @@ async def sprinkler_set_repeat_to_code(config, action_id, template_arg, args): "sprinkler.set_valve_run_duration", SetRunDurationAction, SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, + synchronous=True, ) async def sprinkler_set_valve_run_duration_to_code( config, action_id, template_arg, args @@ -490,7 +495,10 @@ async def sprinkler_set_valve_run_duration_to_code( @automation.register_action( - "sprinkler.start_from_queue", StartFromQueueAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.start_from_queue", + StartFromQueueAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) async def sprinkler_start_from_queue_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -498,7 +506,10 @@ async def sprinkler_start_from_queue_to_code(config, action_id, template_arg, ar @automation.register_action( - "sprinkler.start_full_cycle", StartFullCycleAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.start_full_cycle", + StartFullCycleAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -509,6 +520,7 @@ async def sprinkler_start_full_cycle_to_code(config, action_id, template_arg, ar "sprinkler.start_single_valve", StartSingleValveAction, SPRINKLER_ACTION_SINGLE_VALVE_SCHEMA, + synchronous=True, ) async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -522,21 +534,40 @@ async def sprinkler_start_single_valve_to_code(config, action_id, template_arg, @automation.register_action( - "sprinkler.clear_queued_valves", ClearQueuedValvesAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.clear_queued_valves", + ClearQueuedValvesAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sprinkler.next_valve", NextValveAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.next_valve", + NextValveAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sprinkler.previous_valve", PreviousValveAction, SPRINKLER_ACTION_SCHEMA -) -@automation.register_action("sprinkler.pause", PauseAction, SPRINKLER_ACTION_SCHEMA) -@automation.register_action("sprinkler.resume", ResumeAction, SPRINKLER_ACTION_SCHEMA) -@automation.register_action( - "sprinkler.resume_or_start_full_cycle", ResumeOrStartAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.previous_valve", + PreviousValveAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sprinkler.shutdown", ShutdownAction, SPRINKLER_ACTION_SCHEMA + "sprinkler.pause", PauseAction, SPRINKLER_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "sprinkler.resume", ResumeAction, SPRINKLER_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "sprinkler.resume_or_start_full_cycle", + ResumeOrStartAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "sprinkler.shutdown", + ShutdownAction, + SPRINKLER_ACTION_SCHEMA, + synchronous=True, ) async def sprinkler_simple_action_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index 3c967fc01be..40557f2cbde 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -180,13 +180,22 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( - "sps30.start_fan_autoclean", StartFanAction, SPS30_ACTION_SCHEMA + "sps30.start_fan_autoclean", + StartFanAction, + SPS30_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sps30.start_measurement", StartMeasurementAction, SPS30_ACTION_SCHEMA + "sps30.start_measurement", + StartMeasurementAction, + SPS30_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sps30.stop_measurement", StopMeasurementAction, SPS30_ACTION_SCHEMA + "sps30.stop_measurement", + StopMeasurementAction, + SPS30_ACTION_SCHEMA, + synchronous=True, ) async def sps30_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 221fe39b9d8..ecce4eb4b27 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -36,11 +36,13 @@ void ST7701S::setup() { config.de_gpio_num = this->de_pin_->get_pin(); config.pclk_gpio_num = this->pclk_pin_->get_pin(); esp_err_t err = esp_lcd_new_rgb_panel(&config, &this->handle_); - ESP_ERROR_CHECK(esp_lcd_panel_reset(this->handle_)); - ESP_ERROR_CHECK(esp_lcd_panel_init(this->handle_)); if (err != ESP_OK) { esph_log_e(TAG, "lcd_new_rgb_panel failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; } + ESP_ERROR_CHECK(esp_lcd_panel_reset(this->handle_)); + ESP_ERROR_CHECK(esp_lcd_panel_init(this->handle_)); } void ST7701S::loop() { diff --git a/esphome/components/stepper/__init__.py b/esphome/components/stepper/__init__.py index 62bc71f2d17..27d4fc276d9 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -97,6 +97,7 @@ async def register_stepper(var, config): cv.Required(CONF_TARGET): cv.templatable(cv.int_), } ), + synchronous=True, ) async def stepper_set_target_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -115,6 +116,7 @@ async def stepper_set_target_to_code(config, action_id, template_arg, args): cv.Required(CONF_POSITION): cv.templatable(cv.int_), } ), + synchronous=True, ) async def stepper_report_position_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -133,6 +135,7 @@ async def stepper_report_position_to_code(config, action_id, template_arg, args) cv.Required(CONF_SPEED): cv.templatable(validate_speed), } ), + synchronous=True, ) async def stepper_set_speed_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -151,6 +154,7 @@ async def stepper_set_speed_to_code(config, action_id, template_arg, args): cv.Required(CONF_ACCELERATION): cv.templatable(validate_acceleration), } ), + synchronous=True, ) async def stepper_set_acceleration_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -169,6 +173,7 @@ async def stepper_set_acceleration_to_code(config, action_id, template_arg, args cv.Required(CONF_DECELERATION): cv.templatable(validate_acceleration), } ), + synchronous=True, ) async def stepper_set_deceleration_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 413eb139d65..08f4c0fb882 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -290,19 +290,34 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "sx126x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA + "sx126x.run_image_cal", + RunImageCalAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_tx", + SetModeTxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_rx", + SetModeRxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_sleep", SetModeSleepAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_sleep", + SetModeSleepAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx126x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA + "sx126x.set_mode_standby", + SetModeStandbyAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def no_args_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -320,7 +335,10 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "sx126x.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA + "sx126x.send_packet", + SendPacketAction, + SEND_PACKET_ACTION_SCHEMA, + synchronous=True, ) async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index f3a9cca93f4..7f554fbf841 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -283,19 +283,34 @@ NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( @automation.register_action( - "sx127x.run_image_cal", RunImageCalAction, NO_ARGS_ACTION_SCHEMA + "sx127x.run_image_cal", + RunImageCalAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_tx", SetModeTxAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_tx", + SetModeTxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_rx", SetModeRxAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_rx", + SetModeRxAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_sleep", SetModeSleepAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_sleep", + SetModeSleepAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) @automation.register_action( - "sx127x.set_mode_standby", SetModeStandbyAction, NO_ARGS_ACTION_SCHEMA + "sx127x.set_mode_standby", + SetModeStandbyAction, + NO_ARGS_ACTION_SCHEMA, + synchronous=True, ) async def no_args_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -313,7 +328,10 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( - "sx127x.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA + "sx127x.send_packet", + SendPacketAction, + SEND_PACKET_ACTION_SCHEMA, + synchronous=True, ) async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 9d4208dcca5..e537e1f97cf 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -59,6 +59,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index a4fb0b70210..cfc19c00cdf 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -125,6 +125,7 @@ async def to_code(config): cv.Optional(CONF_TILT): cv.templatable(cv.zero_to_one_float), } ), + synchronous=True, ) async def cover_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/lock/__init__.py b/esphome/components/template/lock/__init__.py index 4c74a521fa2..d8bd9d16c66 100644 --- a/esphome/components/template/lock/__init__.py +++ b/esphome/components/template/lock/__init__.py @@ -90,6 +90,7 @@ async def to_code(config): }, key=CONF_STATE, ), + synchronous=True, ) async def lock_template_publish_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 2c325427e99..b0f48ade465 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -44,6 +44,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.float_), } ), + synchronous=True, ) async def sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index 8ae5a07dc3b..eb6f0f46de7 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -80,6 +80,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def switch_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 7f176db09ef..229a61d9b8e 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -24,23 +24,23 @@ class TemplateTextSaverBase { template class TextSaver : public TemplateTextSaverBase { public: bool save(const std::string &value) override { - int diff = value.compare(this->prev_); - if (diff != 0) { - // If string is bigger than the allocation, do not save it. - // We don't need to waste ram setting prev_value either. - int size = value.size(); - if (size <= SZ) { - // Make it into a length prefixed thing - unsigned char temp[SZ + 1]; - memcpy(temp + 1, value.c_str(), size); - // SZ should be pre checked at the schema level, it can't go past the char range. - temp[0] = ((unsigned char) size); - this->pref_.save(&temp); - this->prev_.assign(value); - return true; - } + if (value == this->prev_) { + return true; // No change, nothing to save } - return false; + // If string is bigger than the allocation, do not save it. + // We don't need to waste ram setting prev_value either. + int size = value.size(); + if (size > SZ) { + return false; + } + // Make it into a length prefixed thing + unsigned char temp[SZ + 1]; + memcpy(temp + 1, value.c_str(), size); + // SZ should be pre checked at the schema level, it can't go past the char range. + temp[0] = ((unsigned char) size); + this->pref_.save(&temp); + this->prev_.assign(value); + return true; } // Make the preference object. Fill the provided location with the saved data diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index 550b27356d1..ddbdd6dadb7 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -43,6 +43,7 @@ async def to_code(config): cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), + synchronous=True, ) async def text_sensor_template_publish_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/template/valve/__init__.py b/esphome/components/template/valve/__init__.py index 526751564de..3e8fd816030 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -112,6 +112,7 @@ async def to_code(config): ), } ), + synchronous=True, ) async def valve_template_publish_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/template/water_heater/__init__.py b/esphome/components/template/water_heater/__init__.py index cb5f2dbe56d..814aa401935 100644 --- a/esphome/components/template/water_heater/__init__.py +++ b/esphome/components/template/water_heater/__init__.py @@ -134,6 +134,7 @@ async def to_code(config: ConfigType) -> None: cv.Optional(CONF_IS_ON): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def water_heater_template_publish_to_code( config: ConfigType, diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 73081d204b4..092df6fdca3 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -26,6 +26,7 @@ water_heater::WaterHeaterTraits TemplateWaterHeater::traits() { if (!this->supported_modes_.empty()) { traits.set_supported_modes(this->supported_modes_); + traits.add_feature_flags(water_heater::WATER_HEATER_SUPPORTS_OPERATION_MODE); } traits.set_supports_current_temperature(true); diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index bd3632802b3..894e7aed52e 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -88,16 +88,16 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { struct timeval timev { .tv_sec = static_cast(epoch), .tv_usec = 0, }; +#ifdef USE_ESP8266 + // ESP8266 settimeofday() requires tz to be nullptr + int ret = settimeofday(&timev, nullptr); +#else struct timezone tz = {0, 0}; int ret = settimeofday(&timev, &tz); - if (ret != 0 && errno == EINVAL) { - // Some ESP8266 frameworks abort when timezone parameter is not NULL - // while ESP32 expects it not to be NULL - ret = settimeofday(&timev, nullptr); - } +#endif if (ret != 0) { - ESP_LOGW(TAG, "setimeofday() failed with code %d", ret); + ESP_LOGW(TAG, "settimeofday() failed with code %d", ret); } #endif auto time = this->now(); diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 90043e969c1..df94ad75346 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -54,7 +54,7 @@ async def to_code(config): if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) - add_idf_component(name="espressif/esp_tinyusb", ref="1.7.6~1") + add_idf_component(name="espressif/esp_tinyusb", ref="2.1.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 19bb545c4b5..7f8fea52647 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -16,10 +16,14 @@ void TinyUSB::setup() { } this->tusb_cfg_ = { - .descriptor = &this->usb_descriptor_, - .string_descriptor = this->string_descriptor_, - .string_descriptor_count = SIZE, - .external_phy = false, + .port = TINYUSB_PORT_FULL_SPEED_0, + .phy = {.skip_setup = false}, + .descriptor = + { + .device = &this->usb_descriptor_, + .string = this->string_descriptor_, + .string_count = SIZE, + }, }; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 49796d9b426..fb35eb21b51 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -73,6 +73,7 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( }, key=CONF_BRIGHTNESS, ), + synchronous=True, ) async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -92,6 +93,7 @@ async def tm1651_set_brightness_to_code(config, action_id, template_arg, args): }, key=CONF_LEVEL, ), + synchronous=True, ) async def tm1651_set_level_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -111,6 +113,7 @@ async def tm1651_set_level_to_code(config, action_id, template_arg, args): }, key=CONF_LEVEL_PERCENT, ), + synchronous=True, ) async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -121,7 +124,10 @@ async def tm1651_set_level_percent_to_code(config, action_id, template_arg, args @automation.register_action( - "tm1651.turn_off", TurnOffAction, BINARY_OUTPUT_ACTION_SCHEMA + "tm1651.turn_off", + TurnOffAction, + BINARY_OUTPUT_ACTION_SCHEMA, + synchronous=True, ) async def output_turn_off_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -129,7 +135,9 @@ async def output_turn_off_to_code(config, action_id, template_arg, args): return var -@automation.register_action("tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA) +@automation.register_action( + "tm1651.turn_on", TurnOnAction, BINARY_OUTPUT_ACTION_SCHEMA, synchronous=True +) async def output_turn_on_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/tormatic/tormatic_protocol.h b/esphome/components/tormatic/tormatic_protocol.h index 057713b8845..26a634b6306 100644 --- a/esphome/components/tormatic/tormatic_protocol.h +++ b/esphome/components/tormatic/tormatic_protocol.h @@ -99,7 +99,7 @@ struct MessageHeader { // payload_size returns the amount of payload bytes to be read from the uart // buffer after reading the header. - uint32_t payload_size() { return this->len - sizeof(this->type); } + uint32_t payload_size() { return this->len > sizeof(this->type) ? this->len - sizeof(this->type) : 0; } } __attribute__((packed)); // StatusType denotes which 'page' of information needs to be retrieved. diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index e0c150537a9..53114cc50fc 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -951,10 +951,10 @@ void ToshibaClimate::transmit_ras_2819t_() { } uint8_t ToshibaClimate::is_valid_rac_pt1411hwru_header_(const uint8_t *message) { - const std::vector header{RAC_PT1411HWRU_MESSAGE_HEADER0, RAC_PT1411HWRU_CS_HEADER, - RAC_PT1411HWRU_SWING_HEADER}; + static constexpr uint8_t HEADERS[] = {RAC_PT1411HWRU_MESSAGE_HEADER0, RAC_PT1411HWRU_CS_HEADER, + RAC_PT1411HWRU_SWING_HEADER}; - for (auto i : header) { + for (auto i : HEADERS) { if ((message[0] == i) && (message[1] == static_cast(~i))) return i; } diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index cb4c38a83ce..bccff1fb26a 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -70,6 +70,10 @@ float TSL2561Sensor::calculate_lx_(uint16_t ch0, uint16_t ch1) { return NAN; } + if (ch0 == 0) { + ESP_LOGVV(TAG, "No light detected"); + return 0.0f; + } float d0 = ch0, d1 = ch1; float ratio = d1 / d0; diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 2cb6eac0509..83649cc2092 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -500,6 +500,7 @@ async def register_uart_device(var, config): }, key=CONF_DATA, ), + synchronous=True, ) async def uart_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index 858f1a02ddf..6f6f1fb96b7 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -105,15 +105,34 @@ void RP2040UartComponent::setup() { } } + // Determine which hardware UART to use. A pin that is not specified + // should not prevent hardware UART selection — one-way UART is valid. + // When both pins are configured, both must be HW-capable and agree on UART number. + // When only one pin is configured (nullptr other), use that pin's HW UART. + // If a pin is configured but not HW-capable (inverted/invalid), fall back to SerialPIO. + int8_t hw_uart = -1; + const bool tx_configured = (this->tx_pin_ != nullptr); + const bool rx_configured = (this->rx_pin_ != nullptr); + + if (tx_configured && rx_configured) { + // Both pins configured — both must map to the same hardware UART + if (tx_hw != -1 && rx_hw != -1 && tx_hw == rx_hw) { + hw_uart = tx_hw; + } + } else if (tx_configured) { + hw_uart = tx_hw; + } else if (rx_configured) { + hw_uart = rx_hw; + } + #ifdef USE_LOGGER - if (tx_hw == rx_hw && logger::global_logger->get_uart() == tx_hw) { - ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", tx_hw); - tx_hw = -1; - rx_hw = -1; + if (hw_uart != -1 && logger::global_logger->get_uart() == hw_uart) { + ESP_LOGD(TAG, "Using SerialPIO as UART%d is taken by the logger", hw_uart); + hw_uart = -1; } #endif - if (tx_hw == -1 || rx_hw == -1 || tx_hw != rx_hw) { + if (hw_uart == -1) { ESP_LOGV(TAG, "Using SerialPIO"); pin_size_t tx = this->tx_pin_ == nullptr ? NOPIN : this->tx_pin_->get_pin(); pin_size_t rx = this->rx_pin_ == nullptr ? NOPIN : this->rx_pin_->get_pin(); @@ -127,13 +146,15 @@ void RP2040UartComponent::setup() { } else { ESP_LOGV(TAG, "Using Hardware Serial"); SerialUART *serial; - if (tx_hw == 0) { + if (hw_uart == 0) { serial = &Serial1; } else { serial = &Serial2; } - serial->setTX(this->tx_pin_->get_pin()); - serial->setRX(this->rx_pin_->get_pin()); + if (this->tx_pin_ != nullptr) + serial->setTX(this->tx_pin_->get_pin()); + if (this->rx_pin_ != nullptr) + serial->setRX(this->rx_pin_->get_pin()); serial->setFIFOSize(this->rx_buffer_size_); serial->begin(this->baud_rate_, config); this->serial_ = serial; diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index 37dd871a6c1..17bbf19c9ea 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -171,6 +171,7 @@ def validate_raw_data(value): }, key=CONF_DATA, ), + synchronous=True, ) async def udp_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 9edf0f89ffa..10b4ece6141 100644 --- a/esphome/components/ufire_ec/sensor.py +++ b/esphome/components/ufire_ec/sensor.py @@ -97,6 +97,7 @@ UFIRE_EC_CALIBRATE_PROBE_SCHEMA = cv.Schema( "ufire_ec.calibrate_probe", UFireECCalibrateProbeAction, UFIRE_EC_CALIBRATE_PROBE_SCHEMA, + synchronous=True, ) async def ufire_ec_calibrate_probe_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -119,6 +120,7 @@ UFIRE_EC_RESET_SCHEMA = cv.Schema( "ufire_ec.reset", UFireECResetAction, UFIRE_EC_RESET_SCHEMA, + synchronous=True, ) async def ufire_ec_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/ufire_ec/ufire_ec.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index a1c3568a1a3..40e3be2757e 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -1,5 +1,6 @@ #include "esphome/core/log.h" #include "ufire_ec.h" +#include namespace esphome { namespace ufire_ec { @@ -60,9 +61,15 @@ float UFireECComponent::measure_temperature_() { return this->read_data_(REGISTE float UFireECComponent::measure_ms_() { return this->read_data_(REGISTER_MS); } -void UFireECComponent::set_solution_(float solution, float temperature) { - solution /= (1 - (this->temperature_coefficient_ * (temperature - 25))); +bool UFireECComponent::set_solution_(float solution, float temperature) { + float denom = 1 - (this->temperature_coefficient_ * (temperature - 25)); + if (std::abs(denom) < 1e-6f) { + ESP_LOGE(TAG, "Temperature compensation denominator is zero"); + return false; + } + solution /= denom; this->write_data_(REGISTER_SOLUTION, solution); + return true; } void UFireECComponent::set_compensation_(float temperature) { this->write_data_(REGISTER_COMPENSATION, temperature); } @@ -72,7 +79,8 @@ void UFireECComponent::set_coefficient_(float coefficient) { this->write_data_(R void UFireECComponent::set_temperature_(float temperature) { this->write_data_(REGISTER_TEMP, temperature); } void UFireECComponent::calibrate_probe(float solution, float temperature) { - this->set_solution_(solution, temperature); + if (!this->set_solution_(solution, temperature)) + return; this->write_byte(REGISTER_TASK, COMMAND_CALIBRATE_PROBE); } diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index bfbed1b43e0..8a648b5038b 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -44,7 +44,7 @@ class UFireECComponent : public PollingComponent, public i2c::I2CDevice { protected: float measure_temperature_(); float measure_ms_(); - void set_solution_(float solution, float temperature); + bool set_solution_(float solution, float temperature); void set_compensation_(float temperature); void set_coefficient_(float coefficient); void set_temperature_(float temperature); diff --git a/esphome/components/ufire_ise/sensor.py b/esphome/components/ufire_ise/sensor.py index 8009cdaa6a8..a116012d055 100644 --- a/esphome/components/ufire_ise/sensor.py +++ b/esphome/components/ufire_ise/sensor.py @@ -91,6 +91,7 @@ UFIRE_ISE_CALIBRATE_PROBE_SCHEMA = cv.Schema( "ufire_ise.calibrate_probe_low", UFireISECalibrateProbeLowAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, + synchronous=True, ) async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -104,6 +105,7 @@ async def ufire_ise_calibrate_probe_low_to_code(config, action_id, template_arg, "ufire_ise.calibrate_probe_high", UFireISECalibrateProbeHighAction, UFIRE_ISE_CALIBRATE_PROBE_SCHEMA, + synchronous=True, ) async def ufire_ise_calibrate_probe_high_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) @@ -120,6 +122,7 @@ UFIRE_ISE_RESET_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(UFireISEComponent "ufire_ise.reset", UFireISEResetAction, UFIRE_ISE_RESET_SCHEMA, + synchronous=True, ) async def ufire_ise_reset_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index c36a4ab769d..db6c1445e34 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -138,6 +138,7 @@ async def to_code(config): cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), } ), + synchronous=True, ) async def update_perform_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -156,6 +157,7 @@ async def update_perform_action_to_code(config, action_id, template_arg, args): cv.GenerateID(): cv.use_id(UpdateEntity), } ), + synchronous=True, ) async def update_check_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.h b/esphome/components/uponor_smatrix/uponor_smatrix.h index bd760f0d77d..bd20e9b6a0a 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.h +++ b/esphome/components/uponor_smatrix/uponor_smatrix.h @@ -89,8 +89,8 @@ class UponorSmatrixComponent : public uart::UARTDevice, public Component { std::vector rx_buffer_; std::queue> tx_queue_; - uint32_t last_rx_; - uint32_t last_tx_; + uint32_t last_rx_{0}; + uint32_t last_tx_{0}; #ifdef USE_TIME time::RealTimeClock *time_id_{nullptr}; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 624f41cf8c6..020542e7490 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -8,7 +8,7 @@ #include #include "freertos/ringbuf.h" -#include "tusb_cdc_acm.h" +#include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index 1a36ef9f31f..583aa77d063 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -11,7 +11,7 @@ #include "esp_log.h" #include "tusb.h" -#include "tusb_cdc_acm.h" +#include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { @@ -140,7 +140,6 @@ void USBCDCACMInstance::setup() { // Configure this CDC interface const tinyusb_config_cdcacm_t acm_cfg = { - .usb_dev = TINYUSB_USBDEV_0, .cdc_port = static_cast(this->itf_), .callback_rx = &tinyusb_cdc_rx_callback, .callback_rx_wanted_char = NULL, @@ -148,9 +147,9 @@ void USBCDCACMInstance::setup() { .callback_line_coding_changed = &tinyusb_cdc_line_coding_changed_callback, }; - esp_err_t result = tusb_cdc_acm_init(&acm_cfg); + esp_err_t result = tinyusb_cdcacm_init(&acm_cfg); if (result != ESP_OK) { - ESP_LOGE(TAG, "tusb_cdc_acm_init failed: %d", result); + ESP_LOGE(TAG, "tinyusb_cdcacm_init failed: %d", result); this->parent_->mark_failed(); return; } diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 94246193e3f..d568dac1930 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -496,6 +496,11 @@ bool USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, u ESP_LOGE(TAG, "Too many requests queued"); return false; } + if (length > trq->transfer->data_buffer_size) { + ESP_LOGE(TAG, "transfer_in: data length %u exceeds buffer size %u", length, trq->transfer->data_buffer_size); + this->release_trq(trq); + return false; + } trq->callback = callback; trq->transfer->callback = transfer_callback; trq->transfer->bEndpointAddress = ep_address | USB_DIR_IN; diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index e6e52a9e2ac..d5428cc8d79 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -8,13 +8,97 @@ namespace esphome::usb_uart { using namespace bytebuffer; -/** - * CH34x - */ + +struct CH34xEntry { + uint16_t pid; + uint8_t byte_idx; // which status.data[] byte to inspect + uint8_t mask; // bitmask applied before comparison + uint8_t match; // 0xFF = wildcard (default/fallthrough for this PID) + CH34xChipType chiptype; + const char *name; + uint8_t num_ports; +}; + +static const CH34xEntry CH34X_TABLE[] = { + {0x55D2, 1, 0xFF, 0x41, CHIP_CH342K, "CH342K", 2}, + {0x55D2, 1, 0xFF, 0xFF, CHIP_CH342F, "CH342F", 2}, + {0x55D3, 1, 0xFF, 0x02, CHIP_CH343J, "CH343J", 1}, + {0x55D3, 1, 0xFF, 0x01, CHIP_CH343K, "CH343K", 1}, + {0x55D3, 1, 0xFF, 0x18, CHIP_CH343G_AUTOBAUD, "CH343G_AUTOBAUD", 1}, + {0x55D3, 1, 0xFF, 0xFF, CHIP_CH343GP, "CH343GP", 1}, + {0x55D4, 1, 0xFF, 0x09, CHIP_CH9102X, "CH9102X", 1}, + {0x55D4, 1, 0xFF, 0xFF, CHIP_CH9102F, "CH9102F", 1}, + {0x55D5, 1, 0xFF, 0xC0, CHIP_CH344L, "CH344L", 4}, // CH344L vs CH344L_V2 resolved below + {0x55D5, 1, 0xFF, 0xFF, CHIP_CH344Q, "CH344Q", 4}, + {0x55D7, 1, 0xFF, 0xFF, CHIP_CH9103M, "CH9103M", 2}, + {0x55D8, 1, 0xFF, 0x0A, CHIP_CH9101RY, "CH9101RY", 1}, + {0x55D8, 1, 0xFF, 0xFF, CHIP_CH9101UH, "CH9101UH", 1}, + {0x55DB, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, + {0x55DD, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 1}, + {0x55DA, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, + {0x55DE, 1, 0xFF, 0xFF, CHIP_CH347TF, "CH347TF", 2}, + {0x55E7, 1, 0xFF, 0xFF, CHIP_CH339W, "CH339W", 1}, + {0x55DF, 1, 0xFF, 0xFF, CHIP_CH9104L, "CH9104L", 4}, + {0x55E9, 1, 0xFF, 0xFF, CHIP_CH9111L_M0, "CH9111L_M0", 1}, + {0x55EA, 1, 0xFF, 0xFF, CHIP_CH9111L_M1, "CH9111L_M1", 1}, + {0x55E8, 2, 0xFF, 0x48, CHIP_CH9114L, "CH9114L", 4}, + {0x55E8, 2, 0xFF, 0x49, CHIP_CH9114W, "CH9114W", 4}, + {0x55E8, 2, 0xFF, 0x4A, CHIP_CH9114F, "CH9114F", 4}, + {0x55EB, 4, 0x01, 0x01, CHIP_CH346C_M1, "CH346C_M1", 1}, + {0x55EB, 4, 0x01, 0xFF, CHIP_CH346C_M0, "CH346C_M0", 1}, + {0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, "CH346C_M2", 2}, +}; void USBUartTypeCH34X::enable_channels() { - // enable the channels - for (auto channel : this->channels_) { + usb_host::transfer_cb_t cb = [this](const usb_host::TransferStatus &status) { + if (!status.success) { + this->defer([this, error_code = status.error_code]() { + ESP_LOGE(TAG, "CH34x chip detection failed: %s", esp_err_to_name(error_code)); + this->apply_line_settings_(); + }); + return; + } + CH34xChipType chiptype = CHIP_UNKNOWN; + uint8_t num_ports = 1; + for (const auto &e : CH34X_TABLE) { + if (e.pid != this->pid_) + continue; + if (e.match != 0xFF && (status.data[e.byte_idx] & e.mask) != e.match) + continue; + chiptype = e.chiptype; + num_ports = e.num_ports; + break; + } + // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) + if (chiptype == CHIP_CH344L && (status.data[0] & 0xF0) != 0x40) + chiptype = CHIP_CH344L_V2; + const char *name = "unknown"; + for (const auto &e : CH34X_TABLE) { + if (e.chiptype == chiptype) { + name = e.name; + break; + } + } + this->defer([this, chiptype, num_ports, name]() { + this->chiptype_ = chiptype; + this->chip_name_ = name; + this->num_ports_ = num_ports; + ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); + this->apply_line_settings_(); + }); + }; + // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes + // used to distinguish CH34x variants sharing the same PID. + this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, cb, {0, 0, 0, 0, 0, 0, 0, 0}); +} + +void USBUartTypeCH34X::dump_config() { + USBUartTypeCdcAcm::dump_config(); + ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); +} + +void USBUartTypeCH34X::apply_line_settings_() { + for (auto *channel : this->channels_) { if (!channel->initialised_.load()) continue; usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index ae9170c5fbd..261f40c0dbb 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -65,7 +65,7 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev } for (uint8_t i = 0; i != config_desc->bNumInterfaces; i++) { - auto data_desc = usb_parse_interface_descriptor(config_desc, 0, 0, &conf_offset); + auto data_desc = usb_parse_interface_descriptor(config_desc, i, 0, &conf_offset); if (!data_desc) { ESP_LOGE(TAG, "data_desc: usb_parse_interface_descriptor failed"); break; diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 83de0b39fcc..3d35f368fb8 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -106,10 +106,15 @@ std::vector USBUartTypeCdcAcm::parse_descriptors(usb_device_handle_t dev } void RingBuffer::push(uint8_t item) { + if (this->get_free_space() == 0) + return; this->buffer_[this->insert_pos_] = item; this->insert_pos_ = (this->insert_pos_ + 1) % this->buffer_size_; } void RingBuffer::push(const uint8_t *data, size_t len) { + size_t free = this->get_free_space(); + if (len > free) + len = free; for (size_t i = 0; i != len; i++) { this->buffer_[this->insert_pos_] = *data++; this->insert_pos_ = (this->insert_pos_ + 1) % this->buffer_size_; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index b1748aebf28..16469df7f60 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -35,6 +35,36 @@ struct CdcEps { uint8_t interrupt_interface_number; }; +enum CH34xChipType : uint8_t { + CHIP_CH342F = 0, + CHIP_CH342K, + CHIP_CH343GP, + CHIP_CH343G_AUTOBAUD, + CHIP_CH343K, + CHIP_CH343J, + CHIP_CH344L, + CHIP_CH344L_V2, + CHIP_CH344Q, + CHIP_CH347TF, + CHIP_CH9101UH, + CHIP_CH9101RY, + CHIP_CH9102F, + CHIP_CH9102X, + CHIP_CH9103M, + CHIP_CH9104L, + CHIP_CH340B, + CHIP_CH339W, + CHIP_CH9111L_M0, + CHIP_CH9111L_M1, + CHIP_CH9114L, + CHIP_CH9114W, + CHIP_CH9114F, + CHIP_CH346C_M0, + CHIP_CH346C_M1, + CHIP_CH346C_M2, + CHIP_UNKNOWN = 0xFF, +}; + enum UARTParityOptions { UART_CONFIG_PARITY_NONE = 0, UART_CONFIG_PARITY_ODD, @@ -192,10 +222,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + void dump_config() override; protected: void enable_channels() override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; + + private: + void apply_line_settings_(); + CH34xChipType chiptype_{CHIP_UNKNOWN}; + const char *chip_name_{"unknown"}; + uint8_t num_ports_{1}; }; } // namespace esphome::usb_uart diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 22cd01988d8..0319ff50e7f 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -180,25 +180,33 @@ VALVE_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action("valve.open", OpenAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.open", OpenAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_open_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("valve.close", CloseAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.close", CloseAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_close_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("valve.stop", StopAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.stop", StopAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_stop_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_action("valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA) +@automation.register_action( + "valve.toggle", ToggleAction, VALVE_ACTION_SCHEMA, synchronous=True +) async def valve_toggle_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -214,7 +222,9 @@ VALVE_CONTROL_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action("valve.control", ControlAction, VALVE_CONTROL_ACTION_SCHEMA) +@automation.register_action( + "valve.control", ControlAction, VALVE_CONTROL_ACTION_SCHEMA, synchronous=True +) async def valve_control_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index 5790a9cce05..26634964569 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -19,6 +19,7 @@ CONF_DELTASOL_BS_2009 = "deltasol_bs_2009" CONF_DELTASOL_BS2 = "deltasol_bs2" CONF_DELTASOL_C = "deltasol_c" CONF_DELTASOL_CS2 = "deltasol_cs2" +CONF_DELTASOL_CS4 = "deltasol_cs4" CONF_DELTASOL_CS_PLUS = "deltasol_cs_plus" CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( diff --git a/esphome/components/vbus/binary_sensor/__init__.py b/esphome/components/vbus/binary_sensor/__init__.py index 70dda943007..85f1172166f 100644 --- a/esphome/components/vbus/binary_sensor/__init__.py +++ b/esphome/components/vbus/binary_sensor/__init__.py @@ -20,6 +20,7 @@ from .. import ( CONF_DELTASOL_BS_PLUS, CONF_DELTASOL_C, CONF_DELTASOL_CS2, + CONF_DELTASOL_CS4, CONF_DELTASOL_CS_PLUS, CONF_VBUS_ID, VBus, @@ -31,6 +32,7 @@ DeltaSol_BS_2009 = vbus_ns.class_("DeltaSolBS2009BSensor", cg.Component) DeltaSol_BS2 = vbus_ns.class_("DeltaSolBS2BSensor", cg.Component) DeltaSol_C = vbus_ns.class_("DeltaSolCBSensor", cg.Component) DeltaSol_CS2 = vbus_ns.class_("DeltaSolCS2BSensor", cg.Component) +DeltaSol_CS4 = vbus_ns.class_("DeltaSolCS4BSensor", cg.Component) DeltaSol_CS_Plus = vbus_ns.class_("DeltaSolCSPlusBSensor", cg.Component) VBusCustom = vbus_ns.class_("VBusCustomBSensor", cg.Component) VBusCustomSub = vbus_ns.class_("VBusCustomSubBSensor", cg.Component) @@ -186,6 +188,28 @@ CONFIG_SCHEMA = cv.typed_schema( ), } ), + CONF_DELTASOL_CS4: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(DeltaSol_CS4), + cv.GenerateID(CONF_VBUS_ID): cv.use_id(VBus), + cv.Optional(CONF_SENSOR1_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SENSOR2_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SENSOR3_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SENSOR4_ERROR): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ), CONF_DELTASOL_CS_PLUS: cv.COMPONENT_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(DeltaSol_CS_Plus), @@ -350,6 +374,23 @@ async def to_code(config): sens = await binary_sensor.new_binary_sensor(config[CONF_SENSOR4_ERROR]) cg.add(var.set_s4_error_bsensor(sens)) + 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)) + elif config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: cg.add(var.set_command(0x0100)) cg.add(var.set_source(0x2211)) diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp index c1d7bc1b18d..e598b1de6b7 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp @@ -110,6 +110,25 @@ void DeltaSolCS2BSensor::handle_message(std::vector &message) { this->s4_error_bsensor_->publish_state(message[18] & 8); } +void DeltaSolCS4BSensor::dump_config() { + ESP_LOGCONFIG(TAG, "Deltasol CS4:"); + LOG_BINARY_SENSOR(" ", "Sensor 1 Error", this->s1_error_bsensor_); + LOG_BINARY_SENSOR(" ", "Sensor 2 Error", this->s2_error_bsensor_); + LOG_BINARY_SENSOR(" ", "Sensor 3 Error", this->s3_error_bsensor_); + LOG_BINARY_SENSOR(" ", "Sensor 4 Error", this->s4_error_bsensor_); +} + +void DeltaSolCS4BSensor::handle_message(std::vector &message) { + if (this->s1_error_bsensor_ != nullptr) + this->s1_error_bsensor_->publish_state(message[20] & 1); + if (this->s2_error_bsensor_ != nullptr) + this->s2_error_bsensor_->publish_state(message[20] & 2); + if (this->s3_error_bsensor_ != nullptr) + this->s3_error_bsensor_->publish_state(message[20] & 4); + if (this->s4_error_bsensor_ != nullptr) + this->s4_error_bsensor_->publish_state(message[20] & 8); +} + void DeltaSolCSPlusBSensor::dump_config() { ESP_LOGCONFIG(TAG, "Deltasol CS Plus:"); LOG_BINARY_SENSOR(" ", "Sensor 1 Error", this->s1_error_bsensor_); diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h index 2decdde602a..04c9a7b8264 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h @@ -94,6 +94,23 @@ class DeltaSolCS2BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; +class DeltaSolCS4BSensor : public VBusListener, public Component { + public: + void dump_config() override; + void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } + void set_s2_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s2_error_bsensor_ = bsensor; } + void set_s3_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s3_error_bsensor_ = bsensor; } + void set_s4_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s4_error_bsensor_ = bsensor; } + + protected: + binary_sensor::BinarySensor *s1_error_bsensor_{nullptr}; + binary_sensor::BinarySensor *s2_error_bsensor_{nullptr}; + binary_sensor::BinarySensor *s3_error_bsensor_{nullptr}; + binary_sensor::BinarySensor *s4_error_bsensor_{nullptr}; + + void handle_message(std::vector &message) override; +}; + class DeltaSolCSPlusBSensor : public VBusListener, public Component { public: void dump_config() override; diff --git a/esphome/components/vbus/sensor/__init__.py b/esphome/components/vbus/sensor/__init__.py index ff8ef98a1aa..9c3665eb1c7 100644 --- a/esphome/components/vbus/sensor/__init__.py +++ b/esphome/components/vbus/sensor/__init__.py @@ -36,6 +36,7 @@ from .. import ( CONF_DELTASOL_BS_PLUS, CONF_DELTASOL_C, CONF_DELTASOL_CS2, + CONF_DELTASOL_CS4, CONF_DELTASOL_CS_PLUS, CONF_VBUS_ID, VBus, @@ -47,6 +48,7 @@ DeltaSol_BS_2009 = vbus_ns.class_("DeltaSolBS2009Sensor", cg.Component) DeltaSol_BS2 = vbus_ns.class_("DeltaSolBS2Sensor", cg.Component) DeltaSol_C = vbus_ns.class_("DeltaSolCSensor", cg.Component) DeltaSol_CS2 = vbus_ns.class_("DeltaSolCS2Sensor", cg.Component) +DeltaSol_CS4 = vbus_ns.class_("DeltaSolCS4Sensor", cg.Component) DeltaSol_CS_Plus = vbus_ns.class_("DeltaSolCSPlusSensor", cg.Component) VBusCustom = vbus_ns.class_("VBusCustomSensor", cg.Component) VBusCustomSub = vbus_ns.class_("VBusCustomSubSensor", cg.Component) @@ -438,6 +440,99 @@ CONFIG_SCHEMA = cv.typed_schema( ), } ), + CONF_DELTASOL_CS4: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(DeltaSol_CS4), + cv.GenerateID(CONF_VBUS_ID): cv.use_id(VBus), + cv.Optional(CONF_TEMPERATURE_1): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_2): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_3): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_4): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE_5): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PUMP_SPEED_1): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PUMP_SPEED_2): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_OPERATING_HOURS_1): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + icon=ICON_TIMER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_OPERATING_HOURS_2): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + icon=ICON_TIMER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HEAT_QUANTITY): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_TIME): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + icon=ICON_TIMER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_VERSION): sensor.sensor_schema( + accuracy_decimals=2, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_FLOW_RATE): sensor.sensor_schema( + accuracy_decimals=0, + device_class=DEVICE_CLASS_EMPTY, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ), CONF_DELTASOL_CS_PLUS: cv.COMPONENT_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(DeltaSol_CS_Plus), @@ -734,7 +829,51 @@ async def to_code(config): sens = await sensor.new_sensor(config[CONF_VERSION]) cg.add(var.set_version_sensor(sens)) - if config[CONF_MODEL] == CONF_DELTASOL_CS_PLUS: + 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)) + + 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)) diff --git a/esphome/components/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 75c9ea1aeed..1cabb49703b 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -168,6 +168,52 @@ void DeltaSolCS2Sensor::handle_message(std::vector &message) { this->version_sensor_->publish_state(get_u16(message, 28) * 0.01f); } +void DeltaSolCS4Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "Deltasol CS4:"); + LOG_SENSOR(" ", "Temperature 1", this->temperature1_sensor_); + LOG_SENSOR(" ", "Temperature 2", this->temperature2_sensor_); + LOG_SENSOR(" ", "Temperature 3", this->temperature3_sensor_); + LOG_SENSOR(" ", "Temperature 4", this->temperature4_sensor_); + LOG_SENSOR(" ", "Temperature 5", this->temperature5_sensor_); + LOG_SENSOR(" ", "Pump Speed 1", this->pump_speed1_sensor_); + LOG_SENSOR(" ", "Pump Speed 2", this->pump_speed2_sensor_); + LOG_SENSOR(" ", "Operating Hours 1", this->operating_hours1_sensor_); + LOG_SENSOR(" ", "Operating Hours 2", this->operating_hours2_sensor_); + LOG_SENSOR(" ", "Heat Quantity", this->heat_quantity_sensor_); + LOG_SENSOR(" ", "System Time", this->time_sensor_); + LOG_SENSOR(" ", "FW Version", this->version_sensor_); + LOG_SENSOR(" ", "Flow Rate", this->flow_rate_sensor_); +} + +void DeltaSolCS4Sensor::handle_message(std::vector &message) { + if (this->temperature1_sensor_ != nullptr) + this->temperature1_sensor_->publish_state(get_i16(message, 0) * 0.1f); + if (this->temperature2_sensor_ != nullptr) + this->temperature2_sensor_->publish_state(get_i16(message, 2) * 0.1f); + if (this->temperature3_sensor_ != nullptr) + this->temperature3_sensor_->publish_state(get_i16(message, 4) * 0.1f); + if (this->temperature4_sensor_ != nullptr) + this->temperature4_sensor_->publish_state(get_i16(message, 6) * 0.1f); + if (this->temperature5_sensor_ != nullptr) + this->temperature5_sensor_->publish_state(get_i16(message, 36) * 0.1f); + if (this->pump_speed1_sensor_ != nullptr) + this->pump_speed1_sensor_->publish_state(message[8]); + if (this->pump_speed2_sensor_ != nullptr) + this->pump_speed2_sensor_->publish_state(message[12]); + if (this->operating_hours1_sensor_ != nullptr) + this->operating_hours1_sensor_->publish_state(get_u16(message, 10)); + if (this->operating_hours2_sensor_ != nullptr) + this->operating_hours2_sensor_->publish_state(get_u16(message, 14)); + if (this->heat_quantity_sensor_ != nullptr) + this->heat_quantity_sensor_->publish_state((get_u16(message, 30) << 16) + get_u16(message, 28)); + if (this->time_sensor_ != nullptr) + this->time_sensor_->publish_state(get_u16(message, 22)); + if (this->version_sensor_ != nullptr) + this->version_sensor_->publish_state(get_u16(message, 32) * 0.01f); + if (this->flow_rate_sensor_ != nullptr) + this->flow_rate_sensor_->publish_state(get_u16(message, 38)); +} + void DeltaSolCSPlusSensor::dump_config() { ESP_LOGCONFIG(TAG, "Deltasol CS Plus:"); LOG_SENSOR(" ", "Temperature 1", this->temperature1_sensor_); diff --git a/esphome/components/vbus/sensor/vbus_sensor.h b/esphome/components/vbus/sensor/vbus_sensor.h index cea2ee1c862..ea248b1db24 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.h +++ b/esphome/components/vbus/sensor/vbus_sensor.h @@ -122,6 +122,41 @@ class DeltaSolCS2Sensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; +class DeltaSolCS4Sensor : public VBusListener, public Component { + public: + void dump_config() override; + void set_temperature1_sensor(sensor::Sensor *sensor) { this->temperature1_sensor_ = sensor; } + void set_temperature2_sensor(sensor::Sensor *sensor) { this->temperature2_sensor_ = sensor; } + void set_temperature3_sensor(sensor::Sensor *sensor) { this->temperature3_sensor_ = sensor; } + void set_temperature4_sensor(sensor::Sensor *sensor) { this->temperature4_sensor_ = sensor; } + void set_temperature5_sensor(sensor::Sensor *sensor) { this->temperature5_sensor_ = sensor; } + void set_pump_speed1_sensor(sensor::Sensor *sensor) { this->pump_speed1_sensor_ = sensor; } + void set_pump_speed2_sensor(sensor::Sensor *sensor) { this->pump_speed2_sensor_ = sensor; } + void set_operating_hours1_sensor(sensor::Sensor *sensor) { this->operating_hours1_sensor_ = sensor; } + void set_operating_hours2_sensor(sensor::Sensor *sensor) { this->operating_hours2_sensor_ = sensor; } + void set_heat_quantity_sensor(sensor::Sensor *sensor) { this->heat_quantity_sensor_ = sensor; } + void set_time_sensor(sensor::Sensor *sensor) { this->time_sensor_ = sensor; } + void set_version_sensor(sensor::Sensor *sensor) { this->version_sensor_ = sensor; } + void set_flow_rate_sensor(sensor::Sensor *sensor) { this->flow_rate_sensor_ = sensor; } + + protected: + sensor::Sensor *temperature1_sensor_{nullptr}; + sensor::Sensor *temperature2_sensor_{nullptr}; + sensor::Sensor *temperature3_sensor_{nullptr}; + sensor::Sensor *temperature4_sensor_{nullptr}; + sensor::Sensor *temperature5_sensor_{nullptr}; + sensor::Sensor *pump_speed1_sensor_{nullptr}; + sensor::Sensor *pump_speed2_sensor_{nullptr}; + sensor::Sensor *operating_hours1_sensor_{nullptr}; + sensor::Sensor *operating_hours2_sensor_{nullptr}; + sensor::Sensor *heat_quantity_sensor_{nullptr}; + sensor::Sensor *time_sensor_{nullptr}; + sensor::Sensor *version_sensor_{nullptr}; + sensor::Sensor *flow_rate_sensor_{nullptr}; + + void handle_message(std::vector &message) override; +}; + class DeltaSolCSPlusSensor : public VBusListener, public Component { public: void dump_config() override; diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index e833657fc4d..0b2b40d723a 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -87,9 +87,9 @@ void VL53L0XSensor::setup() { reg(0x94) = 0x6B; reg(0x83) = 0x00; - this->timeout_start_us_ = micros(); + uint32_t timeout_start_us = micros(); while (reg(0x83).get() == 0x00) { - if (this->timeout_us_ > 0 && ((uint16_t) (micros() - this->timeout_start_us_) > this->timeout_us_)) { + if (this->timeout_us_ > 0 && (micros() - timeout_start_us > this->timeout_us_)) { ESP_LOGE(TAG, "'%s' - setup timeout", this->name_.c_str()); this->mark_failed(); return; diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.h b/esphome/components/vl53l0x/vl53l0x_sensor.h index 2bf90015fe4..f533005b5b3 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.h +++ b/esphome/components/vl53l0x/vl53l0x_sensor.h @@ -64,8 +64,7 @@ class VL53L0XSensor : public sensor::Sensor, public PollingComponent, public i2c bool waiting_for_interrupt_{false}; uint8_t stop_variable_; - uint16_t timeout_start_us_; - uint16_t timeout_us_{}; + uint32_t timeout_us_{}; static std::list vl53_sensors; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool enable_pin_setup_complete; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 8b7dcb4f212..d970df2a44b 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -393,6 +393,7 @@ VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssis "voice_assistant.start_continuous", StartContinuousAction, VOICE_ASSISTANT_ACTION_SCHEMA, + synchronous=True, ) @register_action( "voice_assistant.start", @@ -403,6 +404,7 @@ VOICE_ASSISTANT_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(VoiceAssis cv.Optional(CONF_WAKE_WORD): cv.templatable(cv.string), } ), + synchronous=True, ) async def voice_assistant_listen_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -415,7 +417,9 @@ async def voice_assistant_listen_to_code(config, action_id, template_arg, args): return var -@register_action("voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA) +@register_action( + "voice_assistant.stop", StopAction, VOICE_ASSISTANT_ACTION_SCHEMA, synchronous=True +) async def voice_assistant_stop_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 570e9751522..daa26730517 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2197,7 +2197,7 @@ json::SerializationBuffer<> WebServer::update_state_json_generator(WebServer *we } json::SerializationBuffer<> WebServer::update_all_json_generator(WebServer *web_server, void *source) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE); + return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL); } json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 5ae0ce7aa51..7134dc1f3e3 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -11,6 +11,10 @@ void __attribute__((flatten)) WebServerBase::add_handler(AsyncWebHandler *handle handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } #endif + this->add_handler_without_auth(handler); +} + +void WebServerBase::add_handler_without_auth(AsyncWebHandler *handler) { this->handlers_.push_back(handler); if (this->server_ != nullptr) { this->server_->addHandler(handler); diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 54421c851e5..48e13ad71e2 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -122,6 +122,14 @@ class WebServerBase { #endif void add_handler(AsyncWebHandler *handler); + /** + * WARNING: Registers a handler that bypasses the USE_WEBSERVER_AUTH middleware. + * + * This should only be used for endpoints that are intentionally unauthenticated + * (for example, captive portal or very limited-status endpoints). For normal + * endpoints that should respect web server authentication, use add_handler(). + */ + void add_handler_without_auth(AsyncWebHandler *handler); void set_port(uint16_t port) { port_ = port; } uint16_t get_port() const { return port_; } diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 568c7cb9698..a6976167877 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -74,12 +74,13 @@ int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_ // Use MSG_DONTWAIT to prevent blocking when TCP send buffer is full int ret = send(sockfd, buf, buf_len, flags | MSG_DONTWAIT); if (ret < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { + const int err = errno; + if (err == EAGAIN || err == EWOULDBLOCK) { // Buffer full - retry later return HTTPD_SOCK_ERR_TIMEOUT; } // Real error - ESP_LOGD(TAG, "send error: errno %d", errno); + ESP_LOGD(TAG, "send error: errno %d", err); return HTTPD_SOCK_ERR_FAIL; } return ret; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 46bdb20a105..a3e5556fbae 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -166,6 +166,7 @@ TTLS_PHASE_2 = { } EAP_AUTH_SCHEMA = cv.All( + cv.only_on([Platform.ESP32, Platform.ESP8266]), cv.Schema( { cv.Optional(CONF_IDENTITY): cv.string_strict, @@ -574,13 +575,6 @@ async def to_code(config): cg.add_library("ESP8266WiFi", None) elif CORE.is_rp2040: cg.add_library("WiFi", None) - # RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart - # mDNS when the network interface reconnects. However, this callback is disabled - # in the arduino-pico framework. As a workaround, we block component setup until - # WiFi is connected via can_proceed(), ensuring mDNS.begin() is called with an - # active connection. This define enables the loop priority sorting infrastructure - # used during the setup blocking phase. - cg.add_define("USE_LOOP_PRIORITY") if CORE.is_esp32: if config[CONF_ENABLE_BTM] or config[CONF_ENABLE_RRM]: @@ -682,12 +676,16 @@ async def wifi_ap_active_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg) -@automation.register_action("wifi.enable", WiFiEnableAction, cv.Schema({})) +@automation.register_action( + "wifi.enable", WiFiEnableAction, cv.Schema({}), synchronous=True +) async def wifi_enable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) -@automation.register_action("wifi.disable", WiFiDisableAction, cv.Schema({})) +@automation.register_action( + "wifi.disable", WiFiDisableAction, cv.Schema({}), synchronous=True +) async def wifi_disable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) @@ -793,6 +791,7 @@ async def final_step(): cv.Optional(CONF_ON_ERROR): automation.validate_automation(single=True), } ), + synchronous=False, ) async def wifi_set_sta_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ac521de381f..f7a69312680 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -6,7 +6,7 @@ #include #ifdef USE_ESP32 -#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include #else #include @@ -2110,20 +2110,6 @@ void WiFiComponent::retry_connect() { } } -#ifdef USE_RP2040 -// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart -// mDNS when the network interface reconnects. However, this callback is disabled -// in the arduino-pico framework. As a workaround, we block component setup until -// WiFi is connected, ensuring mDNS.begin() is called with an active connection. - -bool WiFiComponent::can_proceed() { - if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) { - return true; - } - return this->is_connected_(); -} -#endif - void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool WiFiComponent::is_connected_() const { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index f340b708c90..aeb32352a97 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -18,7 +18,7 @@ #endif #if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include #else #include @@ -437,10 +437,6 @@ class WiFiComponent : public Component { void retry_connect(); -#ifdef USE_RP2040 - bool can_proceed() override; -#endif - void set_reboot_timeout(uint32_t reboot_timeout); bool is_connected() const { return this->connected_; } diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 470dfb69c07..fc78aaa4960 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -639,8 +639,6 @@ WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { return WiFiSTAConnectStatus::IDLE; } bool WiFiComponent::wifi_scan_start_(bool passive) { - static bool first_scan = false; - // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -657,23 +655,13 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.show_hidden = 1; #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; - if (first_scan) { - if (passive) { - config.scan_time.passive = 200; - } else { - config.scan_time.active.min = 100; - config.scan_time.active.max = 200; - } + if (passive) { + config.scan_time.passive = 500; } else { - if (passive) { - config.scan_time.passive = 500; - } else { - config.scan_time.active.min = 400; - config.scan_time.active.max = 500; - } + config.scan_time.active.min = 400; + config.scan_time.active.max = 500; } #endif - first_scan = false; bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback); if (!ret) { ESP_LOGV(TAG, "wifi_station_scan failed"); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 366854ed585..ae1d2160a67 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -17,7 +17,7 @@ #include #include #ifdef USE_WIFI_WPA2_EAP -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include #else #include @@ -75,7 +75,11 @@ struct IDFWiFiEvent { #if USE_NETWORK_IPV6 ip_event_got_ip6_t ip_got_ip6; #endif /* USE_NETWORK_IPV6 */ +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + ip_event_assigned_ip_to_client_t ip_assigned_ip_to_client; +#else ip_event_ap_staipassigned_t ip_ap_staipassigned; +#endif } data; }; @@ -116,8 +120,13 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t)); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) { memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t)); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + } else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { + memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t)); +#else } else if (event_base == IP_EVENT && event_id == IP_EVENT_AP_STAIPASSIGNED) { memcpy(&event.data.ip_ap_staipassigned, event_data, sizeof(ip_event_ap_staipassigned_t)); +#endif } else { // did not match any event, don't send anything return; @@ -407,7 +416,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (eap_opt.has_value()) { // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. EAPAuth eap = *eap_opt; -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); #else err = esp_wifi_sta_wpa2_ent_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); @@ -419,7 +428,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { int client_cert_len = strlen(eap.client_cert); int client_key_len = strlen(eap.client_key); if (ca_cert_len) { -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1); #else err = esp_wifi_sta_wpa2_ent_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1); @@ -432,7 +441,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // validation is not required as the config tool has already validated it if (client_cert_len && client_key_len) { // if we have certs, this must be EAP-TLS -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_certificate_and_key((uint8_t *) eap.client_cert, client_cert_len + 1, (uint8_t *) eap.client_key, client_key_len + 1, (uint8_t *) eap.password.c_str(), eap.password.length()); @@ -446,7 +455,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { } } else { // in the absence of certs, assume this is username/password based -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_username((uint8_t *) eap.username.c_str(), eap.username.length()); #else err = esp_wifi_sta_wpa2_ent_set_username((uint8_t *) eap.username.c_str(), eap.username.length()); @@ -454,7 +463,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (err != ESP_OK) { ESP_LOGV(TAG, "set_username failed %d", err); } -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_password((uint8_t *) eap.password.c_str(), eap.password.length()); #else err = esp_wifi_sta_wpa2_ent_set_password((uint8_t *) eap.password.c_str(), eap.password.length()); @@ -463,7 +472,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { ESP_LOGV(TAG, "set_password failed %d", err); } // set TTLS Phase 2, defaults to MSCHAPV2 -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_ttls_phase2_method(eap.ttls_phase_2); #else err = esp_wifi_sta_wpa2_ent_set_ttls_phase2_method(eap.ttls_phase_2); @@ -472,7 +481,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { ESP_LOGV(TAG, "set_ttls_phase2_method failed %d", err); } } -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_wifi_sta_enterprise_enable(); #else err = esp_wifi_sta_wpa2_ent_enable(); @@ -628,14 +637,26 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "Auth Expired"; case WIFI_REASON_AUTH_LEAVE: return "Auth Leave"; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + case WIFI_REASON_DISASSOC_DUE_TO_INACTIVITY: + return "Disassociated Due to Inactivity"; +#else case WIFI_REASON_ASSOC_EXPIRE: return "Association Expired"; +#endif case WIFI_REASON_ASSOC_TOOMANY: return "Too Many Associations"; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + case WIFI_REASON_CLASS2_FRAME_FROM_NONAUTH_STA: + return "Class 2 Frame from Non-Authenticated STA"; + case WIFI_REASON_CLASS3_FRAME_FROM_NONASSOC_STA: + return "Class 3 Frame from Non-Associated STA"; +#else case WIFI_REASON_NOT_AUTHED: return "Not Authenticated"; case WIFI_REASON_NOT_ASSOCED: return "Not Associated"; +#endif case WIFI_REASON_ASSOC_LEAVE: return "Association Leave"; case WIFI_REASON_ASSOC_NOT_AUTHED: @@ -688,7 +709,7 @@ const char *get_disconnect_reason_str(uint8_t reason) { return "Association comeback time too long"; case WIFI_REASON_SA_QUERY_TIMEOUT: return "SA query timeout"; -#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 2) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0) case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: return "No AP found with compatible security"; case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: @@ -917,8 +938,13 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf); #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { + const auto &it = data->data.ip_assigned_ip_to_client; +#else } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_AP_STAIPASSIGNED) { const auto &it = data->data.ip_ap_staipassigned; +#endif ESP_LOGV(TAG, "AP client assigned IP " IPSTR, IP2STR(&it.ip)); } } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 8ef35a5f5d1..7ade170c022 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -23,7 +23,7 @@ class IPAddressWiFiInfo final : public Component, public text_sensor::TextSensor const network::IPAddress &dns2) override; protected: - std::array ip_sensors_; + std::array ip_sensors_{}; }; class DNSAddressWifiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index 124d9a8c328..e2ea61a5428 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -168,6 +168,7 @@ async def wireguard_enabled_to_code(config, condition_id, template_arg, args): "wireguard.enable", WireguardEnableAction, cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), + synchronous=True, ) async def wireguard_enable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) @@ -179,6 +180,7 @@ async def wireguard_enable_to_code(config, action_id, template_arg, args): "wireguard.disable", WireguardDisableAction, cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), + synchronous=True, ) async def wireguard_disable_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index 66c3df14e19..7dcd79bb7ce 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -25,9 +25,9 @@ class X9cOutput : public output::FloatOutput, public Component { InternalGPIOPin *cs_pin_; InternalGPIOPin *inc_pin_; InternalGPIOPin *ud_pin_; - float initial_value_; - float pot_value_; - int step_delay_; + float initial_value_{0.0f}; + float pot_value_{0.0f}; + int step_delay_{0}; }; } // namespace x9c diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index 2b38392a027..6b83012eb42 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -56,7 +56,7 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional(CONF_K_VALUE, default=4096): cv.uint16_t, + cv.Optional(CONF_K_VALUE, default=4096): cv.int_range(min=1, max=65535), } ) .extend(cv.polling_component_schema("60s")) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 97a660f0e3a..2c1611d0c7d 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -5,7 +5,12 @@ #ifdef USE_ESP32 #include +#include +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else #include "mbedtls/ccm.h" +#endif namespace esphome { namespace xiaomi_ble { @@ -314,6 +319,32 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c memcpy(vector.iv + 6, v + 2, 3); // sensor type (2) + packet id (1) memcpy(vector.iv + 9, v + raw.size() - 7, 3); // payload counter +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // PSA AEAD expects ciphertext + tag concatenated + uint8_t ct_with_tag[sizeof(vector.ciphertext) + sizeof(vector.tag)]; + memcpy(ct_with_tag, vector.ciphertext, vector.datasize); + memcpy(ct_with_tag + vector.datasize, vector.tag, vector.tagsize); + size_t ct_with_tag_size = vector.datasize + vector.tagsize; + + psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); + psa_set_key_bits(&attributes, vector.keysize * 8); + psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&attributes, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, vector.tagsize)); + + mbedtls_svc_key_id_t key_id; + if (psa_import_key(&attributes, vector.key, vector.keysize, &key_id) != PSA_SUCCESS) { + ESP_LOGVV(TAG, "decrypt_xiaomi_payload(): psa_import_key() failed."); + return false; + } + + size_t plaintext_length; + psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, vector.tagsize), + vector.iv, vector.ivsize, vector.authdata, vector.authsize, ct_with_tag, + ct_with_tag_size, vector.plaintext, vector.datasize, &plaintext_length); + psa_destroy_key(key_id); + bool decrypt_ok = (status == PSA_SUCCESS && plaintext_length == vector.datasize); +#else mbedtls_ccm_context ctx; mbedtls_ccm_init(&ctx); @@ -326,7 +357,11 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c ret = mbedtls_ccm_auth_decrypt(&ctx, vector.datasize, vector.iv, vector.ivsize, vector.authdata, vector.authsize, vector.ciphertext, vector.plaintext, vector.tag, vector.tagsize); - if (ret) { + mbedtls_ccm_free(&ctx); + bool decrypt_ok = (ret == 0); +#endif + + if (!decrypt_ok) { uint8_t mac_address[6] = {0}; memcpy(mac_address, mac_reverse + 5, 1); memcpy(mac_address + 1, mac_reverse + 4, 1); @@ -346,7 +381,6 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c ESP_LOGVV(TAG, " Iv : %s", format_hex_pretty_to(hex_buf, vector.iv, vector.ivsize)); ESP_LOGVV(TAG, " Cipher : %s", format_hex_pretty_to(hex_buf, vector.ciphertext, vector.datasize)); ESP_LOGVV(TAG, " Tag : %s", format_hex_pretty_to(hex_buf, vector.tag, vector.tagsize)); - mbedtls_ccm_free(&ctx); return false; } @@ -367,7 +401,6 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c ESP_LOGVV(TAG, " Plaintext : %s, Packet : %d", format_hex_pretty_to(hex_buf, raw.data() + cipher_pos, vector.datasize), static_cast(raw[4])); - mbedtls_ccm_free(&ctx); return true; } diff --git a/esphome/components/xxtea/xxtea.cpp b/esphome/components/xxtea/xxtea.cpp index aae663ee016..ba17530b243 100644 --- a/esphome/components/xxtea/xxtea.cpp +++ b/esphome/components/xxtea/xxtea.cpp @@ -7,6 +7,8 @@ static const uint32_t DELTA = 0x9e3779b9; #define MX ((((z >> 5) ^ (y << 2)) + ((y >> 3) ^ (z << 4))) ^ ((sum ^ y) + (k[(p ^ e) & 7] ^ z))) void encrypt(uint32_t *v, size_t n, const uint32_t *k) { + if (n == 0) + return; uint32_t z, y, sum, e; size_t p; size_t q = 6 + 52 / n; @@ -25,6 +27,8 @@ void encrypt(uint32_t *v, size_t n, const uint32_t *k) { } void decrypt(uint32_t *v, size_t n, const uint32_t *k) { + if (n == 0) + return; uint32_t z, y, sum, e; size_t p; size_t q = 6 + 52 / n; diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index eee7fb3f4f8..1d105a10572 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -76,9 +76,7 @@ void Mutex::unlock() { k_mutex_unlock(static_cast(this->handle_)); } IRAM_ATTR InterruptLock::InterruptLock() { state_ = irq_lock(); } IRAM_ATTR InterruptLock::~InterruptLock() { irq_unlock(state_); } -// Zephyr doesn't support lwIP core locking, so this is a no-op -LwIPLock::LwIPLock() {} -LwIPLock::~LwIPLock() {} +// Zephyr LwIPLock is defined inline as a no-op in helpers.h uint32_t random_uint32() { return rand(); } // NOLINT(cert-msc30-c, cert-msc50-cpp) bool random_bytes(uint8_t *data, size_t len) { diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index a327cc29886..280ff6b50cc 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -195,6 +195,7 @@ FactoryResetAction = zigbee_ns.class_( "zigbee.factory_reset", FactoryResetAction, ZIGBEE_ACTION_SCHEMA, + synchronous=True, ) async def reset_zigbee_to_code( config: ConfigType, diff --git a/esphome/const.py b/esphome/const.py index 88e3c33fbc6..29ce0303297 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.0-dev" +__version__ = "2026.4.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( @@ -356,6 +356,7 @@ CONF_EFFECT = "effect" CONF_EFFECTS = "effects" CONF_ELSE = "else" CONF_ENABLE_BTM = "enable_btm" +CONF_ENABLE_FULL_PRINTF = "enable_full_printf" CONF_ENABLE_IPV6 = "enable_ipv6" CONF_ENABLE_ON_BOOT = "enable_on_boot" CONF_ENABLE_OTA_ROLLBACK = "enable_ota_rollback" @@ -1234,6 +1235,7 @@ UNIT_LITRE = "L" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" UNIT_METER = "m" +UNIT_METER_PER_SECOND = "m/s" UNIT_METER_PER_SECOND_SQUARED = "m/s²" UNIT_MICROAMP = "µA" UNIT_MICROGRAMS_PER_CUBIC_METER = "µg/m³" @@ -1243,6 +1245,7 @@ UNIT_MICROSILVERTS_PER_HOUR = "µSv/h" UNIT_MICROTESLA = "µT" UNIT_MILLIAMP = "mA" UNIT_MILLIGRAMS_PER_CUBIC_METER = "mg/m³" +UNIT_MILLILITRE = "mL" UNIT_MILLIMETER = "mm" UNIT_MILLISECOND = "ms" UNIT_MILLISIEMENS_PER_CENTIMETER = "mS/cm" @@ -1254,6 +1257,7 @@ UNIT_PARTS_PER_MILLION = "ppm" UNIT_PASCAL = "Pa" UNIT_PERCENT = "%" UNIT_PH = "pH" +UNIT_POUND = "lb" UNIT_PULSES = "pulses" UNIT_PULSES_PER_MINUTE = "pulses/min" UNIT_REVOLUTIONS_PER_MINUTE = "RPM" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 8781954fcd6..36758c231a3 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -615,6 +615,10 @@ class EsphomeCore: self.address_cache: AddressCache | None = None # Cached config hash (computed lazily) self._config_hash: int | None = None + # True if compiling for C++ unit tests + self.cpp_testing = False + # Allowlist of components whose to_code should run during C++ testing + self.cpp_testing_codegen: set[str] = set() def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -644,6 +648,8 @@ class EsphomeCore: self.current_component = None self.address_cache = None self._config_hash = None + self.cpp_testing = False + self.cpp_testing_codegen = set() PIN_SCHEMA_REGISTRY.reset() @contextmanager @@ -996,6 +1002,15 @@ class EsphomeCore: """ self.platform_counts[platform_name] += 1 + def testing_ensure_platform_registered(self, platform_name: str) -> None: + """Ensure a platform has at least one entity registered for testing. + + Used during C++ test builds to guarantee USE_* defines are emitted + without needing a real component variable. + """ + if not self.platform_counts[platform_name]: + self.platform_counts[platform_name] = 1 + def register_controller(self) -> None: """Track registration of a Controller for ControllerRegistry StaticVector sizing.""" controller_count = self.data.setdefault(KEY_CONTROLLER_REGISTRY_COUNT, 0) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index fc305924e28..c5448dd4f62 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -718,7 +718,8 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret < 0: error (except EINTR which is normal) // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected - if (ret >= 0 || errno == EINTR) [[likely]] { + const int err = errno; + if (ret >= 0 || err == EINTR) [[likely]] { // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); @@ -726,7 +727,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { return; } // select() error - log and fall through to delay() - ESP_LOGW(TAG, "select() failed with errno %d", errno); + ESP_LOGW(TAG, "select() failed with errno %d", err); } // No sockets registered or select() failed - use regular delay delay(delay_ms); diff --git a/esphome/core/application.h b/esphome/core/application.h index ef18566182b..bde591e7c40 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -33,9 +33,6 @@ #include #endif #endif -#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) -#include "esphome/components/socket/socket.h" -#endif #endif // USE_SOCKET_SELECT_SUPPORT #if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_SOCKET_IMPL_LWIP_TCP) namespace esphome::socket { @@ -105,6 +102,9 @@ void socket_wake(); // NOLINT(readability-redundant-declaration) #ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif +#ifdef USE_SERIAL_PROXY +#include "esphome/components/serial_proxy/serial_proxy.h" +#endif #ifdef USE_EVENT #include "esphome/components/event/event.h" #endif @@ -382,6 +382,13 @@ class Application { } #endif +#ifdef USE_SERIAL_PROXY + void register_serial_proxy(serial_proxy::SerialProxy *proxy) { + proxy->set_instance_index(this->serial_proxies_.size()); + this->serial_proxies_.push_back(proxy); + } +#endif + #ifdef USE_EVENT void register_event(event::Event *event) { this->events_.push_back(event); @@ -623,6 +630,10 @@ class Application { GET_ENTITY_METHOD(infrared::Infrared, infrared, infrareds) #endif +#ifdef USE_SERIAL_PROXY + auto &get_serial_proxies() const { return this->serial_proxies_; } +#endif + #ifdef USE_EVENT auto &get_events() const { return this->events_; } GET_ENTITY_METHOD(event::Event, event, events) @@ -888,6 +899,9 @@ class Application { #ifdef USE_INFRARED StaticVector infrareds_{}; #endif +#ifdef USE_SERIAL_PROXY + StaticVector serial_proxies_{}; +#endif #ifdef USE_UPDATE StaticVector updates_{}; #endif diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 364a27a7ca4..2d61843585c 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -522,7 +522,7 @@ void PollingComponent::stop_poller() { uint32_t PollingComponent::get_update_interval() const { return this->update_interval_; } void PollingComponent::set_update_interval(uint32_t update_interval) { this->update_interval_ = update_interval; } -static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time) { +void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time) { bool should_warn; if (component != nullptr) { should_warn = component->should_warn_of_blocking(blocking_time); @@ -536,10 +536,8 @@ static void __attribute__((noinline, cold)) warn_blocking(Component *component, } } -uint32_t WarnIfComponentBlockingGuard::finish() { - uint32_t curr_time = millis(); - uint32_t blocking_time = curr_time - this->started_; #ifdef USE_RUNTIME_STATS +void WarnIfComponentBlockingGuard::record_runtime_stats_() { // Use micros() for accurate sub-millisecond timing. millis() has insufficient // resolution — most components complete in microseconds but millis() only has // 1ms granularity, so results were essentially random noise. @@ -547,12 +545,8 @@ uint32_t WarnIfComponentBlockingGuard::finish() { uint32_t duration_us = micros() - this->started_us_; global_runtime_stats->record_component_time(this->component_, duration_us); } -#endif - if (blocking_time > WARN_IF_BLOCKING_OVER_MS) { - warn_blocking(this->component_, blocking_time); - } - return curr_time; } +#endif #ifdef USE_SETUP_PRIORITY_OVERRIDE void clear_setup_priority_overrides() { diff --git a/esphome/core/component.h b/esphome/core/component.h index 7266f57e151..20a6d2311d2 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -6,6 +6,7 @@ #include #include "esphome/core/defines.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/optional.h" @@ -574,9 +575,10 @@ class PollingComponent : public Component { uint32_t update_interval_; }; -#ifdef USE_RUNTIME_STATS -uint32_t micros(); // Forward declare for inline constructor -#endif +// millis() and micros() are available via hal.h + +// Cold path for blocking warning - defined in component.cpp +void warn_blocking(Component *component, uint32_t blocking_time); class WarnIfComponentBlockingGuard { public: @@ -591,7 +593,18 @@ class WarnIfComponentBlockingGuard { } // Finish the timing operation and return the current time - uint32_t finish(); + // Inlined: the fast path is just millis() + subtract + compare + inline uint32_t HOT finish() { + uint32_t curr_time = millis(); + uint32_t blocking_time = curr_time - this->started_; +#ifdef USE_RUNTIME_STATS + this->record_runtime_stats_(); +#endif + if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] { + warn_blocking(this->component_, blocking_time); + } + return curr_time; + } ~WarnIfComponentBlockingGuard() = default; @@ -600,6 +613,7 @@ class WarnIfComponentBlockingGuard { Component *component_; #ifdef USE_RUNTIME_STATS uint32_t started_us_; + void record_runtime_stats_(); #endif }; diff --git a/esphome/core/config.py b/esphome/core/config.py index d4a839cb795..e112720f2b4 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -589,7 +589,10 @@ async def _add_looping_components() -> None: async def to_code(config: ConfigType) -> None: cg.add_global(cg.global_ns.namespace("esphome").using) # These can be used by user lambdas, put them to default scope + # picolibc (IDF 6.0+) declares isnan in global scope, conflicting with using std::isnan + cg.add_global(cg.RawStatement("#ifndef __PICOLIBC__")) cg.add_global(cg.RawExpression("using std::isnan")) + cg.add_global(cg.RawStatement("#endif")) cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index dc0ee138564..c7e1fc2727c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -107,6 +107,9 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define MICRONOVA_LISTENER_COUNT 1 +#define USE_MICRONOVA_WRITER +#define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE @@ -120,6 +123,7 @@ #define USE_SENSOR #define USE_SETUP_HEAP_STATS #define USE_SENSOR_FILTER +#define USE_SERIAL_PROXY #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR @@ -192,6 +196,7 @@ // ESP32-specific feature flags #ifdef USE_ESP32 +#define USE_ESP32_CRASH_HANDLER #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER #define USE_OTA_ROLLBACK @@ -274,6 +279,12 @@ #define USE_ETHERNET_JL1101 #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_LAN8670 +#define USE_ETHERNET_SPI +#define USE_ETHERNET_SPI_POLLING_SUPPORT +#define USE_ETHERNET_OPENETH +#define CONFIG_ETH_SPI_ETHERNET_W5500 1 +#define CONFIG_ETH_SPI_ETHERNET_DM9051 1 +#define CONFIG_ETH_USE_ESP32_EMAC 1 #define USE_ETHERNET_MANUAL_IP #define USE_ETHERNET_IP_STATE_LISTENERS #define USE_ETHERNET_CONNECT_TRIGGER @@ -334,11 +345,15 @@ #ifdef USE_RP2040 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) #define USE_LOOP_PRIORITY +#define USE_RP2040_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC #define USE_SOCKET_IMPL_LWIP_TCP +#define USE_RP2040_BLE #define USE_SPI +#define USE_ETHERNET +#define USE_ETHERNET_SPI #endif #ifdef USE_LIBRETINY diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 818dae06de1..a47af1dd93c 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -8,9 +8,6 @@ namespace esphome { static const char *const TAG = "entity_base"; -// Entity Name -const StringRef &EntityBase::get_name() const { return this->name_; } - void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); if (this->name_.empty()) { @@ -176,8 +173,6 @@ StringRef EntityBase::get_object_id_to(std::span buf) c return StringRef(buf.data(), len); } -uint32_t EntityBase::get_object_id_hash() { return this->object_id_hash_; } - // Migrate preference data from old_key to new_key if they differ. // This helper is exposed so callers with custom key computation (like TextPrefs) // can use it for manual migration. See: https://github.com/esphome/backlog/issues/85 diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index cccbafd2c36..012a62f1c08 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -68,7 +68,7 @@ static constexpr uint8_t ENTITY_FIELD_ENTITY_CATEGORY_SHIFT = 26; class EntityBase { public: // Get the name of this Entity - const StringRef &get_name() const; + const StringRef &get_name() const { return this->name_; } // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } @@ -86,7 +86,7 @@ class EntityBase { std::string get_object_id() const; // Get the unique Object ID of this Entity - uint32_t get_object_id_hash(); + uint32_t get_object_id_hash() const { return this->object_id_hash_; } /// Get object_id with zero heap allocation /// For static case: returns StringRef to internal storage (buffer unused) diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 8efabb81464..317f40801ef 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -296,6 +296,210 @@ template class StaticVector { operator std::span() const { return std::span(data_.data(), count_); } }; +/// Fixed-size circular buffer with FIFO semantics and iteration support. +/// +/// A tiny ring buffer that avoids dynamic allocations from std::deque/std::queue +/// (which can be wasteful on MCUs), while supporting iteration over queued elements. +/// +/// Not thread-safe. All access (push/pop/iteration) must occur from a single +/// context, or the caller must provide external synchronization. +template class StaticRingBuffer { + using index_type = std::conditional_t<(N <= std::numeric_limits::max()), uint8_t, uint16_t>; + + public: + class Iterator { + public: + Iterator(StaticRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + T &operator*() { return buf_->data_[(buf_->head_ + pos_) % N]; } + Iterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const Iterator &other) const { return pos_ != other.pos_; } + + private: + StaticRingBuffer *buf_; + index_type pos_; + }; + + class ConstIterator { + public: + ConstIterator(const StaticRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + const T &operator*() const { return buf_->data_[(buf_->head_ + pos_) % N]; } + ConstIterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const ConstIterator &other) const { return pos_ != other.pos_; } + + private: + const StaticRingBuffer *buf_; + index_type pos_; + }; + + bool push(const T &value) { + if (this->count_ >= N) { + return false; + } + this->data_[this->tail_] = value; + this->tail_ = (this->tail_ + 1) % N; + ++this->count_; + return true; + } + + void pop() { + if (this->count_ > 0) { + this->head_ = (this->head_ + 1) % N; + --this->count_; + } + } + + T &front() { return this->data_[this->head_]; } + const T &front() const { return this->data_[this->head_]; } + index_type size() const { return this->count_; } + bool empty() const { return this->count_ == 0; } + + /// Clear all elements (reset to empty) + void clear() { + this->head_ = 0; + this->tail_ = 0; + this->count_ = 0; + } + + Iterator begin() { return Iterator(this, 0); } + Iterator end() { return Iterator(this, this->count_); } + ConstIterator begin() const { return ConstIterator(this, 0); } + ConstIterator end() const { return ConstIterator(this, this->count_); } + + protected: + T data_[N]; + index_type head_{0}; + index_type tail_{0}; + index_type count_{0}; +}; + +/// Fixed-capacity circular buffer - allocates once at runtime, never reallocates. +/// Runtime-sized equivalent of StaticRingBuffer - use when capacity is only known at initialization. +/// Supports FIFO push/pop and iteration over queued elements. +/// Not thread-safe. +template::max()> class FixedRingBuffer { + using index_type = std::conditional_t< + (MAX_CAPACITY <= std::numeric_limits::max()), uint8_t, + std::conditional_t<(MAX_CAPACITY <= std::numeric_limits::max()), uint16_t, uint32_t>>; + + public: + class Iterator { + public: + Iterator(FixedRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + T &operator*() { return buf_->data_[(buf_->head_ + pos_) % buf_->capacity_]; } + Iterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const Iterator &other) const { return pos_ != other.pos_; } + + private: + FixedRingBuffer *buf_; + index_type pos_; + }; + + class ConstIterator { + public: + ConstIterator(const FixedRingBuffer *buf, index_type pos) : buf_(buf), pos_(pos) {} + const T &operator*() const { return buf_->data_[(buf_->head_ + pos_) % buf_->capacity_]; } + ConstIterator &operator++() { + ++pos_; + return *this; + } + bool operator!=(const ConstIterator &other) const { return pos_ != other.pos_; } + + private: + const FixedRingBuffer *buf_; + index_type pos_; + }; + + FixedRingBuffer() = default; + ~FixedRingBuffer() { + if constexpr (std::is_trivially_copyable::value && std::is_trivially_default_constructible::value) { + ::operator delete(this->data_); + } else { + delete[] this->data_; + } + } + + // Disable copy + FixedRingBuffer(const FixedRingBuffer &) = delete; + FixedRingBuffer &operator=(const FixedRingBuffer &) = delete; + + /// Allocate capacity - can only be called once + void init(index_type capacity) { + if constexpr (std::is_trivially_copyable::value && std::is_trivially_default_constructible::value) { + // Raw allocation without initialization (elements are written before read) + // NOLINTNEXTLINE(bugprone-sizeof-expression) + this->data_ = static_cast(::operator new(capacity * sizeof(T))); + } else { + this->data_ = new T[capacity]; + } + this->capacity_ = capacity; + } + + /// Push a value. Returns false if full. + bool push(const T &value) { + if (this->count_ >= this->capacity_) + return false; + this->data_[this->tail_] = value; + this->tail_ = (this->tail_ + 1) % this->capacity_; + ++this->count_; + return true; + } + + /// Push a value, overwriting the oldest if full. + void push_overwrite(const T &value) { + this->data_[this->tail_] = value; + this->tail_ = (this->tail_ + 1) % this->capacity_; + if (this->count_ >= this->capacity_) { + // Buffer full - advance head to drop oldest, count stays at capacity + this->head_ = this->tail_; + } else { + ++this->count_; + } + } + + /// Remove the oldest element. + void pop() { + if (this->count_ > 0) { + this->head_ = (this->head_ + 1) % this->capacity_; + --this->count_; + } + } + + T &front() { return this->data_[this->head_]; } + const T &front() const { return this->data_[this->head_]; } + index_type size() const { return this->count_; } + bool empty() const { return this->count_ == 0; } + index_type capacity() const { return this->capacity_; } + bool full() const { return this->count_ == this->capacity_; } + + /// Clear all elements (reset to empty, keep capacity) + void clear() { + this->head_ = 0; + this->tail_ = 0; + this->count_ = 0; + } + + Iterator begin() { return Iterator(this, 0); } + Iterator end() { return Iterator(this, this->count_); } + ConstIterator begin() const { return ConstIterator(this, 0); } + ConstIterator end() const { return ConstIterator(this, this->count_); } + + protected: + T *data_{nullptr}; + index_type head_{0}; + index_type tail_{0}; + index_type count_{0}; + index_type capacity_{0}; +}; + /// Fixed-capacity vector - allocates once at runtime, never reallocates /// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) /// when size is known at initialization but not at compile time @@ -870,6 +1074,28 @@ __attribute__((format(printf, 4, 5))) inline size_t buf_append_printf(char *buf, } #endif +/// Safely append a string to buffer without format parsing, returning new position (capped at size). +/// More efficient than buf_append_printf for plain string literals. +/// @param buf Output buffer +/// @param size Total buffer size +/// @param pos Current position in buffer +/// @param str String to append (must not be null) +/// @return New position after appending (capped at size on overflow) +inline size_t buf_append_str(char *buf, size_t size, size_t pos, const char *str) { + if (pos >= size) { + return size; + } + size_t remaining = size - pos - 1; // reserve space for null terminator + size_t len = strlen(str); + if (len > remaining) { + len = remaining; + } + memcpy(buf + pos, str, len); + pos += len; + buf[pos] = '\0'; + return pos; +} + /// Concatenate a name with a separator and suffix using an efficient stack-based approach. /// This avoids multiple heap allocations during string construction. /// Maximum name length supported is 120 characters for friendly names. @@ -1647,19 +1873,34 @@ template class Parented { */ class Mutex { public: - Mutex(); Mutex(const Mutex &) = delete; + Mutex &operator=(const Mutex &) = delete; + +#if defined(USE_ESP8266) || defined(USE_RP2040) + // Single-threaded platforms: inline no-ops so the compiler eliminates all call overhead. + Mutex() = default; + ~Mutex() = default; + void lock() {} + bool try_lock() { return true; } + void unlock() {} +#elif defined(USE_ESP32) || defined(USE_LIBRETINY) + // FreeRTOS platforms: inline to avoid out-of-line call overhead. + Mutex() { handle_ = xSemaphoreCreateMutex(); } + ~Mutex() = default; + void lock() { xSemaphoreTake(this->handle_, portMAX_DELAY); } + bool try_lock() { return xSemaphoreTake(this->handle_, 0) == pdTRUE; } + void unlock() { xSemaphoreGive(this->handle_); } + + private: + SemaphoreHandle_t handle_; +#else + Mutex(); ~Mutex(); void lock(); bool try_lock(); void unlock(); - Mutex &operator=(const Mutex &) = delete; - private: -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - SemaphoreHandle_t handle_; -#else // d-pointer to store private data on new platforms void *handle_; // NOLINT(clang-diagnostic-unused-private-field) #endif @@ -1711,19 +1952,27 @@ class InterruptLock { /** Helper class to lock the lwIP TCPIP core when making lwIP API calls from non-TCPIP threads. * - * This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled. - * It ensures thread-safe access to lwIP APIs. + * This is needed on multi-threaded platforms (ESP32) when CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled, + * and on RP2040 when CYW43 WiFi is active (cyw43_arch_lwip_begin/end). * - * @note This follows the same pattern as InterruptLock - platform-specific implementations in helpers.cpp + * On platforms without lwIP core locking (ESP8266, LibreTiny, Zephyr), + * this is a no-op defined inline so the compiler can eliminate all call overhead. */ class LwIPLock { public: - LwIPLock(); - ~LwIPLock(); - - // Delete copy constructor and copy assignment operator to prevent accidental copying LwIPLock(const LwIPLock &) = delete; LwIPLock &operator=(const LwIPLock &) = delete; + +#if defined(USE_ESP32) || defined(USE_RP2040) + // Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp + LwIPLock(); + ~LwIPLock(); +#else + // No lwIP core locking — inline no-ops (empty bodies instead of = default + // to prevent clang-tidy unused-variable warnings at call sites) + LwIPLock() {} + ~LwIPLock() {} +#endif }; /** Helper class to request `loop()` to be called as fast as possible. diff --git a/esphome/core/lock_free_queue.h b/esphome/core/lock_free_queue.h index 522fbd36e1a..316186ea542 100644 --- a/esphome/core/lock_free_queue.h +++ b/esphome/core/lock_free_queue.h @@ -104,7 +104,15 @@ template class LockFreeQueue { } } - uint16_t get_and_reset_dropped_count() { return dropped_count_.exchange(0, std::memory_order_relaxed); } + uint16_t get_and_reset_dropped_count() { + // Fast path: relaxed load is a single instruction on all platforms. + // The atomic exchange (especially for uint16_t on Xtensa) compiles to + // an expensive sub-word CAS retry loop (~25 instructions + memory barriers). + // Since drops are rare, avoid the exchange in the common case. + if (dropped_count_.load(std::memory_order_relaxed) == 0) + return 0; + return dropped_count_.exchange(0, std::memory_order_relaxed); + } void increment_dropped_count() { dropped_count_.fetch_add(1, std::memory_order_relaxed); } diff --git a/esphome/core/log.cpp b/esphome/core/log.cpp index f92b59fe134..0da457adec2 100644 --- a/esphome/core/log.cpp +++ b/esphome/core/log.cpp @@ -1,6 +1,7 @@ #include "log.h" #include "defines.h" #include "helpers.h" +#include #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -8,16 +9,26 @@ namespace esphome { -// IMPORTANT: Do not add null checks on global_logger here. -// These functions are the hot path for ALL logging across the entire firmware, -// so every instruction matters. Logger::pre_setup() sets global_logger before -// any other component is created in the generated setup() function, so it is -// guaranteed to be valid by the time any log function is invoked. This invariant -// is enforced by codegen ordering and tested in -// tests/component_tests/logger/test_logger.py. +#ifdef ESPHOME_DEBUG +static void early_log_printf_(const char *tag, int line, const char *format, va_list args) { + fprintf(stderr, "LOG BEFORE LOGGER INIT [%s:%d]: ", tag, line); + vfprintf(stderr, format, args); + fputc('\n', stderr); + assert(false && "log called before Logger::pre_setup()"); // NOLINT +} +#endif + void HOT esp_log_printf_(int level, const char *tag, int line, const char *format, ...) { // NOLINT #ifdef USE_LOGGER - ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); +#ifdef ESPHOME_DEBUG + if (logger::global_logger == nullptr) { + va_list arg; + va_start(arg, format); + early_log_printf_(tag, line, format, arg); + va_end(arg); + return; + } +#endif va_list arg; va_start(arg, format); logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, arg); @@ -39,15 +50,35 @@ void HOT esp_log_printf_(int level, const char *tag, int line, const __FlashStri void HOT esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args) { // NOLINT #ifdef USE_LOGGER - ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); +#ifdef ESPHOME_DEBUG + if (logger::global_logger == nullptr) { + early_log_printf_(tag, line, format, args); + return; + } +#endif logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, args); #endif } +#ifdef USE_STORE_LOG_STR_IN_FLASH +// Remove before 2026.9.0 +void HOT esp_log_vprintf_(int level, const char *tag, int line, const __FlashStringHelper *format, va_list args) { +#ifdef USE_LOGGER + ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); + logger::global_logger->log_vprintf_(static_cast(level), tag, line, format, args); +#endif +} +#endif + #ifdef USE_ESP32 int HOT esp_idf_log_vprintf_(const char *format, va_list args) { // NOLINT #ifdef USE_LOGGER - ESPHOME_DEBUG_ASSERT(logger::global_logger != nullptr); +#ifdef ESPHOME_DEBUG + if (logger::global_logger == nullptr) { + early_log_printf_("esp-idf", 0, format, args); + return 0; + } +#endif logger::global_logger->log_vprintf_(ESPHOME_LOG_LEVEL, "esp-idf", 0, format, args); #endif return 0; diff --git a/esphome/core/log.h b/esphome/core/log.h index 134e8161504..ff39633142e 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -68,6 +68,11 @@ void esp_log_printf_(int level, const char *tag, int line, const char *format, . void esp_log_printf_(int level, const char *tag, int line, const __FlashStringHelper *format, ...); #endif void esp_log_vprintf_(int level, const char *tag, int line, const char *format, va_list args); // NOLINT +#ifdef USE_STORE_LOG_STR_IN_FLASH +// Remove before 2026.9.0 +__attribute__((deprecated("Use esp_log_printf_() instead. Removed in 2026.9.0."))) void esp_log_vprintf_( + int level, const char *tag, int line, const __FlashStringHelper *format, va_list args); +#endif #if defined(USE_ESP32) int esp_idf_log_vprintf_(const char *format, va_list args); // NOLINT #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index c578a9aae91..a695fa396bc 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -112,6 +112,7 @@ // LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. #include #include +#include // FreeRTOS include paths differ: ESP-IDF uses freertos/ prefix, LibreTiny does not #ifdef USE_ESP32 #include @@ -216,6 +217,21 @@ void esphome_lwip_hook_socket(struct lwip_sock *sock) { sock->conn->callback = esphome_socket_event_callback; } +bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable) { + if (sock == NULL || sock->conn == NULL) + return false; + if (NETCONNTYPE_GROUP(sock->conn->type) != NETCONN_TCP) + return false; + if (sock->conn->pcb.tcp == NULL) + return false; + if (enable) { + tcp_nagle_disable(sock->conn->pcb.tcp); + } else { + tcp_nagle_enable(sock->conn->pcb.tcp); + } + return true; +} + // Wake the main loop from another FreeRTOS task. NOT ISR-safe. void esphome_lwip_wake_main_loop(void) { TaskHandle_t task = s_main_loop_task; diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 46c6b711cd2..50706ba9f69 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -66,6 +66,13 @@ void esphome_lwip_wake_main_loop(void); /// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed. void esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken); +/// Set or clear TCP_NODELAY on a socket's tcp_pcb directly. +/// Must be called with the TCPIP core lock held (LwIPLock in C++). +/// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, +/// hooks, refcounting) — just a direct pcb->flags bit set/clear. +/// Returns true if successful, false if sock/conn/pcb is NULL or the socket is not TCP. +bool esphome_lwip_set_nodelay(struct lwip_sock *sock, bool enable); + /// Wake the main loop task from any context (ISR, thread, or main loop). /// ESP32-only: uses xPortInIsrContext() to detect ISR context. /// LibreTiny lacks IRAM_ATTR support needed for ISR-safe paths. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index ca560e8250a..96f7f0c3019 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -30,11 +30,6 @@ static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5; // max delay to start an interval sequence static constexpr uint32_t MAX_INTERVAL_DELAY = 5000; -// Prevent inlining of SchedulerItem deletion. On BK7231N (Thumb-1), GCC inlines -// ~unique_ptr (~30 bytes each) at every destruction site. Defining -// the deleter in the .cpp file ensures a single copy of the destructor + operator delete. -void Scheduler::SchedulerItemDeleter::operator()(SchedulerItem *ptr) const noexcept { delete ptr; } - #if defined(ESPHOME_LOG_HAS_VERBOSE) || defined(ESPHOME_DEBUG_SCHEDULER) // Helper struct for formatting scheduler item names consistently in logs // Uses a stack buffer to avoid heap allocation @@ -110,10 +105,11 @@ static void validate_static_string(const char *name) { // avoid the main thread modifying the list while it is being accessed. // Calculate random offset for interval timers -// Extracted from set_timer_common_ to reduce code size - float math + random_float() -// only needed for intervals, not timeouts +// Extracted from set_timer_common_ to reduce code size - only needed for intervals, not timeouts uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { - return static_cast(std::min(delay / 2, MAX_INTERVAL_DELAY) * random_float()); + uint32_t max_offset = std::min(delay / 2, MAX_INTERVAL_DELAY); + // Multiply-and-shift: uniform random in [0, max_offset) without floating point + return static_cast((static_cast(random_uint32()) * max_offset) >> 32); } // Check if a retry was already cancelled in items_ or to_add_ @@ -122,8 +118,8 @@ uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) { bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id) { for (auto *container : {&this->items_, &this->to_add_}) { - for (auto &item : *container) { - if (item && this->is_item_removed_locked_(item.get()) && + for (auto *item : *container) { + if (item != nullptr && this->is_item_removed_locked_(item) && this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT, /* match_retry= */ true, /* skip_removed= */ false)) { return true; @@ -147,17 +143,37 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type return; } - // Take lock early to protect scheduler_item_pool_ access + // Take lock early to protect scheduler_item_pool_ access and retry-cancelled check LockGuard guard{this->lock_}; + // For retries, check if there's a cancelled timeout first - before allocating an item. + // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name + // Skip check for defer (delay=0) - deferred retries bypass the cancellation check + if (is_retry && delay != 0 && (name_type != NameType::STATIC_STRING || static_name != nullptr) && + type == SchedulerItem::TIMEOUT && + this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { +#ifdef ESPHOME_DEBUG_SCHEDULER + SchedulerNameLog skip_name_log; + ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", + skip_name_log.format(name_type, static_name, hash_or_id)); +#endif + return; + } + // Create and populate the scheduler item - auto item = this->get_item_from_pool_locked_(); + SchedulerItem *item = this->get_item_from_pool_locked_(); item->component = component; item->set_name(name_type, static_name, hash_or_id); item->type = type; - item->callback = std::move(func); + // Use destroy + placement-new instead of move-assignment. + // GCC's std::function::operator=(function&&) does a full swap dance even when the + // target is empty. Since recycled/new items always have an empty callback, we can + // destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of + // swap/destructor code on Xtensa. + item->callback.~function(); + new (&item->callback) std::function(std::move(func)); // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use - this->set_item_removed_(item.get(), false); + this->set_item_removed_(item, false); item->is_retry = is_retry; // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. @@ -193,29 +209,15 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } #ifdef ESPHOME_DEBUG_SCHEDULER - this->debug_log_timer_(item.get(), name_type, static_name, hash_or_id, type, delay, now_64); + this->debug_log_timer_(item, name_type, static_name, hash_or_id, type, delay, now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ - - // For retries, check if there's a cancelled timeout first - // Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name - if (is_retry && (name_type != NameType::STATIC_STRING || static_name != nullptr) && - type == SchedulerItem::TIMEOUT && - this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) { - // Skip scheduling - the retry was cancelled -#ifdef ESPHOME_DEBUG_SCHEDULER - SchedulerNameLog skip_name_log; - ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", - skip_name_log.format(name_type, static_name, hash_or_id)); -#endif - return; - } } // Common epilogue: atomic cancel-and-add (unless skip_cancel is true) if (!skip_cancel) { this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type); } - target->push_back(std::move(item)); + target->push_back(item); } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, @@ -395,7 +397,7 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { if (this->cleanup_() == 0) return {}; - auto &item = this->items_[0]; + SchedulerItem *item = this->items_[0]; const auto now_64 = this->millis_64_from_(now); const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) @@ -414,13 +416,13 @@ void Scheduler::full_cleanup_removed_items_() { // Compact in-place: move valid items forward, recycle removed ones size_t write = 0; for (size_t read = 0; read < this->items_.size(); ++read) { - if (!is_item_removed_locked_(this->items_[read].get())) { + if (!is_item_removed_locked_(this->items_[read])) { if (write != read) { - this->items_[write] = std::move(this->items_[read]); + this->items_[write] = this->items_[read]; } ++write; } else { - this->recycle_item_main_loop_(std::move(this->items_[read])); + this->recycle_item_main_loop_(this->items_[read]); } } this->items_.erase(this->items_.begin() + write, this->items_.end()); @@ -444,7 +446,7 @@ void Scheduler::compact_defer_queue_locked_() { // and recycled on the next loop iteration. size_t remaining = this->defer_queue_.size() - this->defer_queue_front_; for (size_t i = 0; i < remaining; i++) { - this->defer_queue_[i] = std::move(this->defer_queue_[this->defer_queue_front_ + i]); + this->defer_queue_[i] = this->defer_queue_[this->defer_queue_front_ + i]; } // Use erase() instead of resize() to avoid instantiating _M_default_append // (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed. @@ -469,26 +471,26 @@ void HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; - std::vector old_items; + std::vector old_items; ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_.size(), now_64); // Cleanup before debug output this->cleanup_(); while (!this->items_.empty()) { - SchedulerItemPtr item; + SchedulerItem *item; { LockGuard guard{this->lock_}; item = this->pop_raw_locked_(); } SchedulerNameLog name_log; - bool is_cancelled = is_item_removed_(item.get()); + bool is_cancelled = is_item_removed_(item); ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s", item->get_type_str(), LOG_STR_ARG(item->get_source()), name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval, item->get_next_execution() - now_64, item->get_next_execution(), is_cancelled ? " [CANCELLED]" : ""); - old_items.push_back(std::move(item)); + old_items.push_back(item); } ESP_LOGD(TAG, "\n"); @@ -512,7 +514,7 @@ void HOT Scheduler::call(uint32_t now) { } while (!this->items_.empty()) { // Don't copy-by value yet - auto &item = this->items_[0]; + SchedulerItem *item = this->items_[0]; if (item->get_next_execution() > now_64) { // Not reached timeout yet, done for this call break; @@ -532,7 +534,7 @@ void HOT Scheduler::call(uint32_t now) { // Multi-threaded platforms without atomics: must take lock to safely read remove flag { LockGuard guard{this->lock_}; - if (is_item_removed_locked_(item.get())) { + if (is_item_removed_locked_(item)) { this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; @@ -540,7 +542,7 @@ void HOT Scheduler::call(uint32_t now) { } #else // Single-threaded or multi-threaded with atomics: can check without lock - if (is_item_removed_(item.get())) { + if (is_item_removed_(item)) { LockGuard guard{this->lock_}; this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; @@ -561,18 +563,18 @@ void HOT Scheduler::call(uint32_t now) { // Warning: During callback(), a lot of stuff can happen, including: // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled - now = this->execute_item_(item.get(), now); + now = this->execute_item_(item, now); LockGuard guard{this->lock_}; // Only pop after function call, this ensures we were reachable // during the function call and know if we were cancelled. - auto executed_item = this->pop_raw_locked_(); + SchedulerItem *executed_item = this->pop_raw_locked_(); - if (this->is_item_removed_locked_(executed_item.get())) { + if (this->is_item_removed_locked_(executed_item)) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; - this->recycle_item_main_loop_(std::move(executed_item)); + this->recycle_item_main_loop_(executed_item); continue; } @@ -580,10 +582,10 @@ void HOT Scheduler::call(uint32_t now) { executed_item->set_next_execution(now_64 + executed_item->interval); // Add new item directly to to_add_ // since we have the lock held - this->to_add_.push_back(std::move(executed_item)); + this->to_add_.push_back(executed_item); } else { // Timeout completed - recycle it - this->recycle_item_main_loop_(std::move(executed_item)); + this->recycle_item_main_loop_(executed_item); } has_added_items |= !this->to_add_.empty(); @@ -592,17 +594,33 @@ void HOT Scheduler::call(uint32_t now) { if (has_added_items) { this->process_to_add(); } + +#ifdef ESPHOME_DEBUG_SCHEDULER + // Verify no items were leaked during this call() cycle. + // All items must be in items_, to_add_, defer_queue_, or the pool. + // Safe to check here because: + // - process_defer_queue_ has already run its cleanup_defer_queue_locked_(), + // so defer_queue_ contains no nullptr slots inflating the count. + // - The while loop above has finished, so no items are held in local variables; + // every item has been returned to a container (items_, to_add_, or pool). + // Lock needed to get a consistent snapshot of all containers. + { + LockGuard guard{this->lock_}; + this->debug_verify_no_leak_(); + } +#endif } void HOT Scheduler::process_to_add() { LockGuard guard{this->lock_}; - for (auto &it : this->to_add_) { - if (is_item_removed_locked_(it.get())) { + for (auto *&it : this->to_add_) { + if (is_item_removed_locked_(it)) { // Recycle cancelled items - this->recycle_item_main_loop_(std::move(it)); + this->recycle_item_main_loop_(it); + it = nullptr; continue; } - this->items_.push_back(std::move(it)); + this->items_.push_back(it); std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); } this->to_add_.clear(); @@ -628,20 +646,18 @@ size_t HOT Scheduler::cleanup_() { // leading to race conditions LockGuard guard{this->lock_}; while (!this->items_.empty()) { - auto &item = this->items_[0]; - if (!this->is_item_removed_locked_(item.get())) + SchedulerItem *item = this->items_[0]; + if (!this->is_item_removed_locked_(item)) break; this->to_remove_--; this->recycle_item_main_loop_(this->pop_raw_locked_()); } return this->items_.size(); } -Scheduler::SchedulerItemPtr HOT Scheduler::pop_raw_locked_() { +Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - // Move the item out before popping - this is the item that was at the front of the heap - auto item = std::move(this->items_.back()); - + SchedulerItem *item = this->items_.back(); this->items_.pop_back(); return item; } @@ -699,7 +715,7 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type return total_cancelled > 0; } -bool HOT Scheduler::SchedulerItem::cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b) { +bool HOT Scheduler::SchedulerItem::cmp(SchedulerItem *a, SchedulerItem *b) { // High bits are almost always equal (change only on 32-bit rollover ~49 days) // Optimize for common case: check low bits first when high bits are equal return (a->next_execution_high_ == b->next_execution_high_) ? (a->next_execution_low_ > b->next_execution_low_) @@ -710,23 +726,26 @@ bool HOT Scheduler::SchedulerItem::cmp(const SchedulerItemPtr &a, const Schedule // IMPORTANT: Caller must hold the scheduler lock before calling this function. // This protects scheduler_item_pool_ from concurrent access by other threads // that may be acquiring items from the pool in set_timer_common_(). -void Scheduler::recycle_item_main_loop_(SchedulerItemPtr item) { - if (!item) +void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { + if (item == nullptr) return; if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) { // Clear callback to release captured resources item->callback = nullptr; - this->scheduler_item_pool_.push_back(std::move(item)); + this->scheduler_item_pool_.push_back(item); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size()); #endif } else { #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size()); +#endif + delete item; +#ifdef ESPHOME_DEBUG_SCHEDULER + this->debug_live_items_--; #endif } - // else: unique_ptr will delete the item when it goes out of scope } #ifdef ESPHOME_DEBUG_SCHEDULER @@ -753,21 +772,54 @@ void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, // Helper to get or create a scheduler item from the pool // IMPORTANT: Caller must hold the scheduler lock before calling this function. -Scheduler::SchedulerItemPtr Scheduler::get_item_from_pool_locked_() { - SchedulerItemPtr item; +Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() { if (!this->scheduler_item_pool_.empty()) { - item = std::move(this->scheduler_item_pool_.back()); + SchedulerItem *item = this->scheduler_item_pool_.back(); this->scheduler_item_pool_.pop_back(); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); #endif - } else { - item = SchedulerItemPtr(new SchedulerItem()); -#ifdef ESPHOME_DEBUG_SCHEDULER - ESP_LOGD(TAG, "Allocated new item (pool empty)"); -#endif + return item; } +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Allocated new item (pool empty)"); +#endif + auto *item = new SchedulerItem(); +#ifdef ESPHOME_DEBUG_SCHEDULER + this->debug_live_items_++; +#endif return item; } +#ifdef ESPHOME_DEBUG_SCHEDULER +bool Scheduler::debug_verify_no_leak_() const { + // Invariant: every live SchedulerItem must be in exactly one container. + // debug_live_items_ tracks allocations minus deletions. + size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_.size(); +#ifndef ESPHOME_THREAD_SINGLE + accounted += this->defer_queue_.size(); +#endif + if (accounted != this->debug_live_items_) { + ESP_LOGE(TAG, + "SCHEDULER LEAK DETECTED: live=%" PRIu32 " but accounted=%" PRIu32 " (items=%" PRIu32 " to_add=%" PRIu32 + " pool=%" PRIu32 +#ifndef ESPHOME_THREAD_SINGLE + " defer=%" PRIu32 +#endif + ")", + static_cast(this->debug_live_items_), static_cast(accounted), + static_cast(this->items_.size()), static_cast(this->to_add_.size()), + static_cast(this->scheduler_item_pool_.size()) +#ifndef ESPHOME_THREAD_SINGLE + , + static_cast(this->defer_queue_.size()) +#endif + ); + assert(false); + return false; + } + return true; +} +#endif + } // namespace esphome diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 9f48ed7e927..45d8bf6e652 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -2,7 +2,6 @@ #include "esphome/core/defines.h" #include -#include #include #include #ifdef ESPHOME_THREAD_MULTI_ATOMICS @@ -144,19 +143,6 @@ class Scheduler { }; protected: - struct SchedulerItem; - - // Custom deleter for SchedulerItem unique_ptr that prevents the compiler from - // inlining the destructor at every destruction site. On BK7231N (Thumb-1), GCC - // inlines ~unique_ptr (~30 bytes: null check + ~std::function + - // operator delete) at every destruction site, while ESP32/ESP8266/RTL8720CF outline - // it into a single helper. This noinline deleter ensures only one copy exists. - // operator() is defined in scheduler.cpp to prevent inlining. - struct SchedulerItemDeleter { - void operator()(SchedulerItem *ptr) const noexcept; - }; - using SchedulerItemPtr = std::unique_ptr; - struct SchedulerItem { // Ordered by size to minimize padding Component *component; @@ -177,9 +163,11 @@ class Scheduler { uint16_t next_execution_high_; // Upper 16 bits (millis_major counter) #ifdef ESPHOME_THREAD_MULTI_ATOMICS - // Multi-threaded with atomics: use atomic for lock-free access - // Place atomic separately since it can't be packed with bit fields - std::atomic remove{false}; + // Multi-threaded with atomics: use atomic uint8_t for lock-free access. + // std::atomic is not used because GCC on Xtensa generates an indirect + // function call for std::atomic::load() instead of inlining it. + // std::atomic inlines correctly on all platforms. + std::atomic remove{0}; // Bit-packed fields (4 bits used, 4 bits padding in 1 byte) enum Type : uint8_t { TIMEOUT, INTERVAL } type : 1; @@ -203,7 +191,7 @@ class Scheduler { next_execution_low_(0), next_execution_high_(0), #ifdef ESPHOME_THREAD_MULTI_ATOMICS - // remove is initialized in the member declaration as std::atomic{false} + // remove is initialized in the member declaration type(TIMEOUT), name_type_(NameType::STATIC_STRING), is_retry(false) { @@ -216,14 +204,14 @@ class Scheduler { name_.static_name = nullptr; } - // Destructor - no dynamic memory to clean up + // Destructor - no dynamic memory to clean up (callback's std::function handles its own) ~SchedulerItem() = default; // Delete copy operations to prevent accidental copies SchedulerItem(const SchedulerItem &) = delete; SchedulerItem &operator=(const SchedulerItem &) = delete; - // Delete move operations: SchedulerItem objects are only managed via unique_ptr, never moved directly + // Delete move operations: SchedulerItem objects are managed via raw pointers, never moved directly SchedulerItem(SchedulerItem &&) = delete; SchedulerItem &operator=(SchedulerItem &&) = delete; @@ -247,7 +235,7 @@ class Scheduler { name_type_ = type; } - static bool cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b); + static bool cmp(SchedulerItem *a, SchedulerItem *b); // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. // The upper 16 bits of the 64-bit value are always zero, which is fine since @@ -298,12 +286,13 @@ class Scheduler { // Returns the number of items remaining after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). size_t cleanup_(); - // Remove and return the front item from the heap + // Remove and return the front item from the heap as a raw pointer. + // Caller takes ownership and must either recycle or delete the item. // IMPORTANT: Caller must hold the scheduler lock before calling this function. - SchedulerItemPtr pop_raw_locked_(); + SchedulerItem *pop_raw_locked_(); // Get or create a scheduler item from the pool // IMPORTANT: Caller must hold the scheduler lock before calling this function. - SchedulerItemPtr get_item_from_pool_locked_(); + SchedulerItem *get_item_from_pool_locked_(); private: // Helper to cancel items - must be called with lock held @@ -327,19 +316,16 @@ class Scheduler { // Helper function to check if item matches criteria for cancellation // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // IMPORTANT: Must be called with scheduler lock held - inline bool HOT matches_item_locked_(const SchedulerItemPtr &item, Component *component, NameType name_type, + inline bool HOT matches_item_locked_(SchedulerItem *item, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded - // platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries. - // PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_()), but this check - // provides defense-in-depth: helper - // functions should be safe regardless of caller behavior. + // platforms, items can be nulled in defer_queue_ during processing. // Fixes: https://github.com/esphome/esphome/issues/11940 - if (!item) + if (item == nullptr) return false; - if (item->component != component || item->type != type || - (skip_removed && this->is_item_removed_locked_(item.get())) || (match_retry && !item->is_retry)) { + if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) || + (match_retry && !item->is_retry)) { return false; } // Name type must match @@ -361,10 +347,12 @@ class Scheduler { } // Helper to recycle a SchedulerItem back to the pool. + // Takes a raw pointer — caller transfers ownership. The item is either added to the + // pool or deleted if the pool is full. // IMPORTANT: Only call from main loop context! Recycling clears the callback, // so calling from another thread while the callback is executing causes use-after-free. // IMPORTANT: Caller must hold the scheduler lock before calling this function. - void recycle_item_main_loop_(SchedulerItemPtr item); + void recycle_item_main_loop_(SchedulerItem *item); // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); @@ -420,27 +408,28 @@ class Scheduler { // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), // recycle each item after re-acquiring the lock for the next iteration (N+1 total). // The lock is held across: recycle → loop condition → move-out, then released for execution. - SchedulerItemPtr item; + SchedulerItem *item; this->lock_.lock(); while (this->defer_queue_front_ < defer_queue_end) { - // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. - // This is intentional and safe because: + // Take ownership of the item, leaving nullptr in the vector slot. + // This is safe because: // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_) // 3. The lock protects concurrent access, but the nullptr remains until cleanup - item = std::move(this->defer_queue_[this->defer_queue_front_]); + item = this->defer_queue_[this->defer_queue_front_]; + this->defer_queue_[this->defer_queue_front_] = nullptr; this->defer_queue_front_++; this->lock_.unlock(); // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again - if (!this->should_skip_item_(item.get())) { - now = this->execute_item_(item.get(), now); + if (!this->should_skip_item_(item)) { + now = this->execute_item_(item, now); } this->lock_.lock(); - this->recycle_item_main_loop_(std::move(item)); + this->recycle_item_main_loop_(item); } // Clean up the queue (lock already held from last recycle or initial acquisition) this->cleanup_defer_queue_locked_(); @@ -507,7 +496,7 @@ class Scheduler { // Multi-threaded with atomics: use atomic store with appropriate ordering // Release ordering when setting to true ensures cancellation is visible to other threads // Relaxed ordering when setting to false is sufficient for initialization - item->remove.store(removed, removed ? std::memory_order_release : std::memory_order_relaxed); + item->remove.store(removed ? 1 : 0, removed ? std::memory_order_release : std::memory_order_relaxed); #else // Single-threaded (ESPHOME_THREAD_SINGLE) or // multi-threaded without atomics (ESPHOME_THREAD_MULTI_NO_ATOMICS): direct write @@ -520,18 +509,14 @@ class Scheduler { // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // Returns the number of items marked for removal // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, + __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry) { size_t count = 0; - for (auto &item : container) { - // Skip nullptr items (can happen in defer_queue_ when items are being processed) - // The defer_queue_ uses index-based processing: items are std::moved out but left in the - // vector as nullptr until cleanup. Even though this function is called with lock held, - // the vector can still contain nullptr items from the processing loop. This check prevents crashes. - if (item && this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { - this->set_item_removed_(item.get(), true); + for (auto *item : container) { + if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) { + this->set_item_removed_(item, true); count++; } } @@ -539,15 +524,15 @@ class Scheduler { } Mutex lock_; - std::vector items_; - std::vector to_add_; + std::vector items_; + std::vector to_add_; #ifndef ESPHOME_THREAD_SINGLE // Single-core platforms don't need the defer queue and save ~32 bytes of RAM // Using std::vector instead of std::deque avoids 512-byte chunked allocations // Index tracking avoids O(n) erase() calls when draining the queue each loop - std::vector defer_queue_; // FIFO queue for defer() calls - size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) -#endif /* ESPHOME_THREAD_SINGLE */ + std::vector defer_queue_; // FIFO queue for defer() calls + size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) +#endif /* ESPHOME_THREAD_SINGLE */ uint32_t to_remove_{0}; // Memory pool for recycling SchedulerItem objects to reduce heap churn. @@ -558,7 +543,18 @@ class Scheduler { // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation // can stall the entire system, causing timing issues and dropped events for any components that need // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) - std::vector scheduler_item_pool_; + std::vector scheduler_item_pool_; + +#ifdef ESPHOME_DEBUG_SCHEDULER + // Leak detection: tracks total live SchedulerItem allocations. + // Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_.size() + // Verified periodically in call() to catch leaks early. + size_t debug_live_items_{0}; + + // Verify the scheduler memory invariant: all allocated items are accounted for. + // Returns true if no leak detected. Logs an error and asserts on failure. + bool debug_verify_no_leak_() const; +#endif }; } // namespace esphome diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py index ada5575d0e1..9cadc442ef2 100644 --- a/esphome/dashboard/const.py +++ b/esphome/dashboard/const.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys + from esphome.enum import StrEnum @@ -26,4 +28,5 @@ MAX_EXECUTOR_WORKERS = 48 SENTINEL = object() -DASHBOARD_COMMAND = ["esphome", "--dashboard"] +ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] +DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"] diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 92cab929ef4..b8e17244e53 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -52,7 +52,7 @@ from esphome.util import get_serial_ports, shlex_quote from esphome.yaml_util import FastestAvailableSafeLoader from ..helpers import write_file -from .const import DASHBOARD_COMMAND, DashboardEvent +from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent from .core import DASHBOARD, ESPHomeDashboard, Event from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool from .models import build_device_list_response @@ -1079,7 +1079,7 @@ class DownloadBinaryRequestHandler(BaseHandler): return if not path.is_file(): - args = ["esphome", "idedata", settings.rel_path(configuration)] + args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)] rc, stdout, _ = await async_run_system_command(args) if rc != 0: @@ -1462,7 +1462,7 @@ class JsonConfigRequestHandler(BaseHandler): self.send_error(404) return - args = ["esphome", "config", str(filename), "--show-secrets"] + args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"] rc, stdout, stderr = await async_run_system_command(args) diff --git a/esphome/espidf_api.py b/esphome/espidf_api.py index 9e9c57bfbdb..9ebcc48513c 100644 --- a/esphome/espidf_api.py +++ b/esphome/espidf_api.py @@ -8,7 +8,6 @@ import shutil import subprocess from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE -from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) @@ -102,6 +101,55 @@ def run_reconfigure() -> int: return run_idf_py("reconfigure") +def has_outdated_files(): + """Check if the build configuration is stale. + + Returns True if required build files are missing or if configuration inputs + are newer than the generated CMake/Ninja build artifacts. + """ + cmakecache_txt_path = CORE.relative_build_path("build/CMakeCache.txt") + + cmakelists_txt_build_path = CORE.relative_build_path("CMakeLists.txt") + cmakelists_txt_src_path = CORE.relative_src_path("CMakeLists.txt") + build_config_path = CORE.relative_build_path("build/config") + sdkconfig_internal_path = CORE.relative_build_path( + f"sdkconfig.{CORE.name}.esphomeinternal" + ) + dependency_lock_path = CORE.relative_build_path("dependencies.lock") + build_ninja_path = CORE.relative_build_path("build/build.ninja") + + if not os.path.isdir(build_config_path) or not os.listdir(build_config_path): + return True + if not os.path.isfile(cmakecache_txt_path): + return True + if not os.path.isfile(build_ninja_path): + return True + if os.path.isfile(dependency_lock_path) and os.path.getmtime( + dependency_lock_path + ) > os.path.getmtime(build_ninja_path): + return True + + cmakecache_txt_mtime = os.path.getmtime(cmakecache_txt_path) + return any( + os.path.getmtime(f) > cmakecache_txt_mtime + for f in [ + _get_idf_path(), + cmakelists_txt_build_path, + cmakelists_txt_src_path, + sdkconfig_internal_path, + build_config_path, + ] + if f and os.path.exists(f) + ) + + +def need_reconfigure() -> bool: + from esphome.build_gen.espidf import has_discovered_components + + # We need to reconfigure either if the files are outdated or if there is no component discovered + return has_outdated_files() or not has_discovered_components() + + def run_compile(config, verbose: bool) -> int: """Compile the ESP-IDF project. @@ -110,10 +158,10 @@ def run_compile(config, verbose: bool) -> int: 2. Regenerate CMakeLists.txt with discovered components 3. Run full build """ - from esphome.build_gen.espidf import has_discovered_components, write_project + from esphome.build_gen.espidf import write_project # Check if we need to do discovery phase - if not has_discovered_components(): + if need_reconfigure(): _LOGGER.info("Discovering available ESP-IDF components...") write_project(minimal=True) rc = run_reconfigure() @@ -124,15 +172,12 @@ def run_compile(config, verbose: bool) -> int: write_project(minimal=False) # Build - args = ["build"] + args = [] if verbose: args.append("-v") - # Add parallel job limit if configured - if CONF_COMPILE_PROCESS_LIMIT in config.get(CONF_ESPHOME, {}): - limit = config[CONF_ESPHOME][CONF_COMPILE_PROCESS_LIMIT] - args.extend(["-j", str(limit)]) + args.append("build") # Set the sdkconfig file sdkconfig_path = CORE.relative_build_path(f"sdkconfig.{CORE.name}") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 550e7b9af79..bb94de7e052 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,15 +4,15 @@ dependencies: esphome/esp-audio-libs: version: 2.0.3 esphome/micro-opus: - version: 0.3.4 + version: 0.3.5 espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.1 + version: 2.1.5 espressif/mdns: version: 1.10.0 espressif/esp_wifi_remote: - version: 1.3.2 + version: 1.4.0 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: @@ -20,7 +20,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.11.5 + version: 2.12.1 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: @@ -30,7 +30,7 @@ dependencies: rules: - if: "target in [esp32, esp32p4]" espressif/esp_tinyusb: - version: "1.7.6~1" + version: "2.1.1" rules: - if: "target in [esp32s2, esp32s3, esp32p4]" esphome/esp-hub75: diff --git a/esphome/loader.py b/esphome/loader.py index 968c8cf3e05..5771e074738 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -71,6 +71,11 @@ class ComponentManifest: @property def to_code(self) -> Callable[[Any], None] | None: + if CORE.cpp_testing: + # During C++ testing, only run to_code for allowlisted components + name = self.module.__package__.rsplit(".", 1)[-1] + if name not in CORE.cpp_testing_codegen: + return None return getattr(self.module, "to_code", None) @property diff --git a/esphome/mqtt.py b/esphome/mqtt.py index cbf78bd3f6e..ccacbaea54f 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -2,6 +2,7 @@ import contextlib from datetime import datetime import json import logging +import os import ssl import tempfile import time @@ -109,14 +110,18 @@ def prepare( CONF_CLIENT_CERTIFICATE_KEY ): with ( - tempfile.NamedTemporaryFile(mode="w+") as cert_file, - tempfile.NamedTemporaryFile(mode="w+") as key_file, + tempfile.NamedTemporaryFile(mode="w+", delete=False) as cert_file, + tempfile.NamedTemporaryFile(mode="w+", delete=False) as key_file, ): - cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) - cert_file.flush() - key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) - key_file.flush() - context.load_cert_chain(cert_file.name, key_file.name) + try: + cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE)) + key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY)) + cert_file.close() + key_file.close() + context.load_cert_chain(cert_file.name, key_file.name) + finally: + os.unlink(cert_file.name) + os.unlink(key_file.name) client.tls_set_context(context) try: diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index 4c71bdef6bf..7467f88a5ab 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -349,6 +349,8 @@ STACKTRACE_ESP32_BACKTRACE_RE = re.compile( r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+" ) STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") +# ESP32 crash handler (stored backtrace from previous boot) +STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") @@ -380,6 +382,11 @@ def process_stacktrace(config, line, backtrace_state): ) _decode_pc(config, match.group(1)) + # ESP32 crash handler backtrace (from previous boot) + match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # ESP32 single-line backtrace match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line) if match is not None: diff --git a/esphome/util.py b/esphome/util.py index b83c548cd5d..73cc3aa5ab6 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -1,5 +1,6 @@ import collections from collections.abc import Callable +from dataclasses import dataclass import io import logging from pathlib import Path @@ -208,8 +209,8 @@ def run_external_command( func, *cmd, capture_stdout: bool = False, - filter_lines: str = None, - line_callbacks: list | None = None, + filter_lines: list[str] | None = None, + line_callbacks: list[Callable[[str], str | None]] | None = None, ) -> int | str: """ Run a function from an external package that acts like a main method. @@ -219,7 +220,9 @@ def run_external_command( :param func: Function to execute :param cmd: Command to run as (eg first element of sys.argv) :param capture_stdout: Capture text from stdout and return that. - :param filter_lines: Regular expression used to filter captured output. + Note: line_callbacks are not invoked when capture_stdout is True. + :param filter_lines: Regular expressions used to filter captured output. + :param line_callbacks: Callbacks invoked per line; non-None returns are written to output. :return: str if `capture_stdout` is set else int exit code. """ @@ -401,11 +404,34 @@ def get_picotool_path(cc_path: str) -> Path | None: return None -def detect_rp2040_bootsel(picotool_path: str | Path) -> int: +def is_picotool_usb_permission_error(output: str | bytes) -> bool: + """Check if picotool output indicates a USB permission error.""" + if isinstance(output, str): + return ( + "unable to connect" in output + or "LIBUSB_ERROR_ACCESS" in output + or "Permission denied" in output + ) + return ( + b"unable to connect" in output + or b"LIBUSB_ERROR_ACCESS" in output + or b"Permission denied" in output + ) + + +@dataclass +class BootselResult: + """Result of RP2040 BOOTSEL detection.""" + + device_count: int + permission_error: bool = False + + +def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: """Detect RP2040/RP2350 devices in BOOTSEL mode using picotool. - Returns the number of devices found (by counting 'type:' lines in output), - matching PlatformIO's detection approach. + Returns a BootselResult with the number of devices found (by counting + 'type:' lines in output), and whether a permission error was detected. """ try: result = subprocess.run( @@ -414,9 +440,16 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> int: timeout=10, check=False, ) - return result.stdout.count(b"type:") + device_count = result.stdout.count(b"type:") + if device_count > 0: + return BootselResult(device_count) + # Check for permission issues — picotool can see the device + # on the USB bus but can't connect without proper permissions + if is_picotool_usb_permission_error(result.stderr + result.stdout): + return BootselResult(0, permission_error=True) + return BootselResult(0) except (OSError, subprocess.TimeoutExpired): - return 0 + return BootselResult(0) def get_esp32_arduino_flash_error_help() -> str | None: diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 7220fb307ff..0703c54a7a8 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -175,6 +175,12 @@ class _Schema(vol.Schema): else: if self.extra == vol.ALLOW_EXTRA: out[key] = value + elif key == "id": + # Silently drop 'id' on any dict so that + # !extend / !remove work on every list-based + # config without requiring each component to + # declare an id in its schema. + pass elif self.extra != vol.REMOVE_EXTRA: if isinstance(key, str) and key_names: matches = difflib.get_close_matches(key, key_names) diff --git a/platformio.ini b/platformio.ini index 87f992759c5..3c3d62ef76a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,11 +46,11 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} - esphome/noise-c@0.1.10 ; api + esphome/noise-c@0.1.11 ; api improv/Improv@1.2.4 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -542,7 +542,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.10 ; used by api + esphome/noise-c@0.1.11 ; used by api build_flags = ${common.build_flags} -DUSE_HOST diff --git a/pyproject.toml b/pyproject.toml index c6a2c22a5d6..2e3a247768b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==82.0.0", "wheel>=0.43,<0.47"] +requires = ["setuptools==82.0.1", "wheel>=0.43,<0.47"] build-backend = "setuptools.build_meta" [project] diff --git a/requirements.txt b/requirements.txt index 03a7cac5c72..e634bcb1046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.4 +tornado==6.5.5 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.5.0 +aioesphomeapi==44.5.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import diff --git a/requirements_test.txt b/requirements_test.txt index 93a20896aa8..acd8383a2fc 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.5 # also change in .pre-commit-config.yaml when updating +ruff==0.15.6 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 206f8f558bd..dff6c7690a0 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -461,7 +461,7 @@ class FloatType(TypeInfo): class Int64Type(TypeInfo): cpp_type = "int64_t" default_value = "0" - decode_varint = "value.as_int64()" + decode_varint = "static_cast(value)" encode_func = "encode_int64" wire_type = WireType.VARINT # Uses wire type 0 @@ -481,7 +481,7 @@ class Int64Type(TypeInfo): class UInt64Type(TypeInfo): cpp_type = "uint64_t" default_value = "0" - decode_varint = "value.as_uint64()" + decode_varint = "value" encode_func = "encode_uint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -501,7 +501,7 @@ class UInt64Type(TypeInfo): class Int32Type(TypeInfo): cpp_type = "int32_t" default_value = "0" - decode_varint = "value.as_int32()" + decode_varint = "static_cast(value)" encode_func = "encode_int32" wire_type = WireType.VARINT # Uses wire type 0 @@ -573,7 +573,7 @@ class Fixed32Type(TypeInfo): class BoolType(TypeInfo): cpp_type = "bool" default_value = "false" - decode_varint = "value.as_bool()" + decode_varint = "value != 0" encode_func = "encode_bool" wire_type = WireType.VARINT # Uses wire type 0 @@ -642,7 +642,7 @@ class StringType(TypeInfo): # For SOURCE_BOTH, check if StringRef is set (sending) or use string (received) return ( f"if (!this->{self.field_name}_ref_.empty()) {{" - f' out.append("\'").append(this->{self.field_name}_ref_.c_str()).append("\'");' + f' out.append("\'").append(this->{self.field_name}_ref_.c_str(), this->{self.field_name}_ref_.size()).append("\'");' f"}} else {{" f' out.append("\'").append(this->{self.field_name}).append("\'");' f"}}" @@ -1151,7 +1151,7 @@ class FixedArrayBytesType(TypeInfo): class UInt32Type(TypeInfo): cpp_type = "uint32_t" default_value = "0" - decode_varint = "value.as_uint32()" + decode_varint = "value" encode_func = "encode_uint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1175,7 +1175,7 @@ class EnumType(TypeInfo): @property def decode_varint(self) -> str: - return f"static_cast<{self.cpp_type}>(value.as_uint32())" + return f"static_cast<{self.cpp_type}>(value)" default_value = "" wire_type = WireType.VARINT # Uses wire type 0 @@ -1262,7 +1262,7 @@ class SFixed64Type(TypeInfo): class SInt32Type(TypeInfo): cpp_type = "int32_t" default_value = "0" - decode_varint = "value.as_sint32()" + decode_varint = "decode_zigzag32(static_cast(value))" encode_func = "encode_sint32" wire_type = WireType.VARINT # Uses wire type 0 @@ -1282,7 +1282,7 @@ class SInt32Type(TypeInfo): class SInt64Type(TypeInfo): cpp_type = "int64_t" default_value = "0" - decode_varint = "value.as_sint64()" + decode_varint = "decode_zigzag64(value)" encode_func = "encode_sint64" wire_type = WireType.VARINT # Uses wire type 0 @@ -2205,7 +2205,7 @@ def build_message_type( cpp = "" if decode_varint: - o = f"bool {desc.name}::decode_varint(uint32_t field_id, ProtoVarInt value) {{\n" + o = f"bool {desc.name}::decode_varint(uint32_t field_id, proto_varint_value_t value) {{\n" o += " switch (field_id) {\n" o += indent("\n".join(decode_varint), " ") + "\n" o += " default: return false;\n" @@ -2213,7 +2213,7 @@ def build_message_type( o += " return true;\n" o += "}\n" cpp += o - prot = "bool decode_varint(uint32_t field_id, ProtoVarInt value) override;" + prot = "bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;" protected_content.insert(0, prot) if decode_length: o = f"bool {desc.name}::decode_length(uint32_t field_id, ProtoLengthDelimited value) {{\n" @@ -2705,7 +2705,7 @@ namespace esphome::api { static inline void append_quoted_string(DumpBuffer &out, const StringRef &ref) { out.append("'"); if (!ref.empty()) { - out.append(ref.c_str()); + out.append(ref.c_str(), ref.size()); } out.append("'"); } diff --git a/script/ci-custom.py b/script/ci-custom.py index 8e1652b505d..06fcdadb8ce 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -519,7 +519,7 @@ def lint_constants_usage(): continue errs.append( f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " - f"constant to const.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " + f"constant to esphome/components/const/__init__.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " "See https://developers.esphome.io/contributing/code/#python" ) return errs diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index a2961306457..01316da27fe 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -160,6 +160,76 @@ def format_change(before: int, after: int, threshold: float | None = None) -> st return f"{emoji} {delta_str} ({pct_str})" +def _sig_base(sym: str) -> str: + """Strip argument types from a symbol name for fuzzy matching. + + Removes the entire outermost parenthesized argument list (including + the parentheses) from the symbol string. + + This makes, for example, "foo(int)::nested" and "foo(float)::nested" + share the same key "foo::nested", while "foo(int)" maps to "foo" and + therefore does NOT collide with "foo(int)::nested". + """ + start = sym.find("(") + if start == -1: + return sym + end = sym.rfind(")") + if end == -1: + return sym + return sym[:start] + sym[end + 1 :] + + +_AMBIGUOUS = object() + + +def _match_signature_changes( + changed_symbols: list[tuple[str, int, int, int]], + new_symbols: list[tuple[str, int]], + removed_symbols: list[tuple[str, int]], +) -> tuple[ + list[tuple[str, int, int, int]], + list[tuple[str, int]], + list[tuple[str, int]], +]: + """Match new/removed symbol pairs that only differ in argument types. + + When a function's argument types change (e.g. foo(vector<>&) -> foo(Buffer&)), + it appears as a new + removed symbol. This matches them by base name and moves + them to changed_symbols. Only matches unambiguous 1:1 pairs. + """ + if not new_symbols or not removed_symbols: + return changed_symbols, new_symbols, removed_symbols + + # Build base -> entry maps; mark ambiguous bases with sentinel + new_by_base: dict[str, tuple[str, int] | object] = {} + for entry in new_symbols: + base = _sig_base(entry[0]) + new_by_base[base] = _AMBIGUOUS if base in new_by_base else entry + removed_by_base: dict[str, tuple[str, int] | object] = {} + for entry in removed_symbols: + base = _sig_base(entry[0]) + removed_by_base[base] = _AMBIGUOUS if base in removed_by_base else entry + + matched: set[str] = set() # matched base keys + for base, new_entry in new_by_base.items(): + if new_entry is _AMBIGUOUS: + continue + rem_entry = removed_by_base.get(base) + if rem_entry is None or rem_entry is _AMBIGUOUS: + continue + pr_sym, pr_size = new_entry + _rm_sym, target_size = rem_entry + delta = pr_size - target_size + if delta != 0: + changed_symbols.append((pr_sym, target_size, pr_size, delta)) + matched.add(base) + + if matched: + new_symbols = [e for e in new_symbols if _sig_base(e[0]) not in matched] + removed_symbols = [e for e in removed_symbols if _sig_base(e[0]) not in matched] + return changed_symbols, new_symbols, removed_symbols + + def prepare_symbol_changes_data( target_symbols: dict | None, pr_symbols: dict | None ) -> dict | None: @@ -200,6 +270,11 @@ def prepare_symbol_changes_data( delta = pr_size - target_size changed_symbols.append((symbol, target_size, pr_size, delta)) + # Match new/removed symbols that only differ in argument types + changed_symbols, new_symbols, removed_symbols = _match_signature_changes( + changed_symbols, new_symbols, removed_symbols + ) + if not changed_symbols and not new_symbols and not removed_symbols: return None diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index 6ba4127848b..c6cfd8270fa 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -10,9 +10,10 @@ from helpers import get_all_components, get_all_dependencies, root_path 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.loader import get_component from esphome.platformio_api import get_idedata -from esphome.yaml_util import load_yaml # This must coincide with the version in /platformio.ini PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2" @@ -20,6 +21,13 @@ PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2" # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" +# Components whose to_code should run during C++ test builds. +# Most components don't need code generation for tests; only these +# essential ones (platform setup, logging, core config) are needed. +# Note: "core" is the esphome core config module (esphome/core/config.py), +# which registers under package name "core" not "esphome". +CPP_TESTING_CODEGEN_COMPONENTS = {"core", "host", "logger"} + def hash_components(components: list[str]) -> str: key = ",".join(components) @@ -30,12 +38,14 @@ def filter_components_without_tests(components: list[str]) -> list[str]: """Filter out components that do not have a corresponding test file. This is done by checking if the component's directory contains at - least a .cpp file. + least a .cpp or .h file. """ filtered_components: list[str] = [] for component in components: test_dir = COMPONENTS_TESTS_DIR / component - if test_dir.is_dir() and any(test_dir.glob("*.cpp")): + if test_dir.is_dir() and ( + any(test_dir.glob("*.cpp")) or any(test_dir.glob("*.h")) + ): filtered_components.append(component) else: print( @@ -45,38 +55,6 @@ def filter_components_without_tests(components: list[str]) -> list[str]: return filtered_components -# Name of optional per-component YAML config merged into the test build -# before validation so that platform defines (USE_SENSOR, etc.) are generated. -CPP_TEST_CONFIG_FILE = "cpp_test.yaml" - - -def load_component_test_configs(components: list[str]) -> dict: - """Load cpp_test.yaml files from test component directories. - - These configs are merged into the base test config *before* validation - so that entity registration runs during code generation, which causes - the corresponding USE_* defines to be emitted. - """ - merged: dict = {} - for component in components: - config_file = COMPONENTS_TESTS_DIR / component / CPP_TEST_CONFIG_FILE - if not config_file.exists(): - continue - component_config = load_yaml(config_file) - if not component_config: - continue - for key, value in component_config.items(): - if ( - key in merged - and isinstance(merged[key], list) - and isinstance(value, list) - ): - merged[key].extend(value) - else: - merged[key] = value - return merged - - def create_test_config(config_name: str, includes: list[str]) -> dict: """Create ESPHome test configuration for C++ unit tests. @@ -101,6 +79,10 @@ def create_test_config(config_name: str, includes: list[str]) -> dict: "-Og", # optimize for debug "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing "-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 @@ -114,11 +96,52 @@ def create_test_config(config_name: str, includes: list[str]) -> dict: } +def get_platform_components(components: list[str]) -> list[str]: + """Discover platform sub-components referenced by test directory structure. + + For each component being tested, any sub-directory named after a platform + domain (e.g. ``sensor``, ``binary_sensor``) is treated as a request to + include that ``.`` platform in the build. The sub- + directory must name a valid platform domain; anything else raises an error + so that typos are caught early. + + Returns: + List of ``"domain.component"`` strings, one per discovered sub-directory. + """ + platform_components: list[str] = [] + for component in components: + test_dir = COMPONENTS_TESTS_DIR / component + if not test_dir.is_dir(): + continue + # Each sub-directory name is expected to be a platform domain + # (e.g. tests/components/bthome/sensor/ → sensor.bthome). + for domain_dir in test_dir.iterdir(): + if not domain_dir.is_dir(): + continue + domain = domain_dir.name + domain_module = get_component(domain) + if domain_module is None or not domain_module.is_platform_component: + raise ValueError( + f"Component tests for '{component}' reference non-existing or invalid domain '{domain}'" + f" in its directory structure. See ({COMPONENTS_TESTS_DIR / component / domain})." + ) + platform_components.append(f"{domain}.{component}") + return platform_components + + +# Exit codes for run_tests +EXIT_OK = 0 +EXIT_SKIPPED = 1 +EXIT_COMPILE_ERROR = 2 +EXIT_CONFIG_ERROR = 3 +EXIT_NO_EXECUTABLE = 4 + + def run_tests(selected_components: list[str]) -> int: # Skip tests on Windows if os.name == "nt": print("Skipping esphome tests on Windows", file=sys.stderr) - return 1 + return EXIT_SKIPPED # Remove components that do not have tests components = filter_components_without_tests(selected_components) @@ -128,45 +151,63 @@ def run_tests(selected_components: list[str]) -> int: "No components specified or no tests found for the specified components.", file=sys.stderr, ) - return 0 + return EXIT_OK components = sorted(components) - # Obtain possible dependencies for the requested components. - # Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag, - # which causes core/time.h to include components/time/posix_tz.h. - components_with_dependencies = sorted( - get_all_dependencies(set(components) | {"time"}) - ) - - # Build a list of include folders, one folder per component containing tests. - # A special replacement main.cpp is located in /tests/components/main.cpp + # Build a list of include folders relative to COMPONENTS_TESTS_DIR. These folders will + # be added along with their subfolders. + # "main.cpp" is a special entry that points to /tests/components/main.cpp, + # which provides a custom test runner entry-point replacing the default one. + # Each remaining entry is a component folder whose *.cpp files are compiled. includes: list[str] = ["main.cpp"] + components + # Obtain a list of platform components to be tested: + try: + platform_components = get_platform_components(components) + except ValueError as e: + print(f"Error obtaining platform components: {e}") + return EXIT_CONFIG_ERROR + + components = sorted(components + platform_components) + # Create a unique name for this config based on the actual components being tested # to maximize cache during testing config_name: str = "cpptests-" + hash_components(components) - config = create_test_config(config_name, includes) + # Obtain possible dependencies for the requested components. + # Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag, + # which causes core/time.h to include components/time/posix_tz.h. + components_with_dependencies: list[str] = sorted( + get_all_dependencies(set(components) | {"time"}, cpp_testing=True) + ) - # Merge component-specific test configs (e.g. sensor instances) before - # validation so that entity registration and USE_* defines work. - extra_config = load_component_test_configs(components) - config.update(extra_config) + config = create_test_config(config_name, includes) CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml" CORE.dashboard = None + CORE.cpp_testing = True + CORE.cpp_testing_codegen = CPP_TESTING_CODEGEN_COMPONENTS # Validate config will expand the above with defaults: config = validate_config(config, {}) # Add all components and dependencies to the base configuration after validation, so their files - # are added to the build. Use setdefault to avoid overwriting entries that were - # already validated (e.g. sensor instances from cpp_test.yaml). - for key in components_with_dependencies: - config.setdefault(key, {}) + # are added to the build. + for component_name in components_with_dependencies: + if "." in component_name: + # Format is always "domain.component" (exactly one dot), + # as produced by get_platform_components(). + domain, component = component_name.split(".", maxsplit=1) + domain_list = config.setdefault(domain, []) + CORE.testing_ensure_platform_registered(domain) + domain_list.append({CONF_PLATFORM: component}) + else: + config.setdefault(component_name, []) - print(f"Testing components: {', '.join(components)}") + dependencies = set(components_with_dependencies) - set(components) + deps_str = ", ".join(dependencies) if dependencies else "None" + print(f"Testing components: {', '.join(components)}. Dependencies: {deps_str}") CORE.config = config args = parse_args(["program", "compile", str(CORE.config_path)]) try: @@ -179,13 +220,13 @@ def run_tests(selected_components: list[str]) -> int: print( f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}" ) - return 2 + return EXIT_COMPILE_ERROR # After a successful compilation, locate the executable and run it: idedata = get_idedata(config) if idedata is None: print("Cannot find executable") - return 1 + return EXIT_NO_EXECUTABLE program_path: str = idedata.raw["prog_path"] run_cmd: list[str] = [program_path] diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 318ac04a7d0..6808a3cf6c9 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -6,6 +6,8 @@ what files have changed. It outputs JSON with the following structure: { "integration_tests": true/false, + "integration_tests_run_all": true/false, + "integration_test_files": ["tests/integration/test_foo.py", ...], "clang_tidy": true/false, "clang_format": true/false, "python_linters": true/false, @@ -56,13 +58,13 @@ from helpers import ( core_changed, filter_component_and_test_cpp_files, filter_component_and_test_files, - get_all_dependencies, get_changed_components, get_component_from_path, get_component_test_files, - get_components_from_integration_fixtures, get_components_with_dependencies, get_cpp_changed_components, + get_fixture_to_test_files, + get_integration_test_files_for_components, get_target_branch, git_ls_files, parse_test_filename, @@ -143,65 +145,88 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ ] -def should_run_integration_tests(branch: str | None = None) -> bool: - """Determine if integration tests should run based on changed files. +def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[str]]: + """Determine which integration tests should run based on changed files. - This function is used by the CI workflow to intelligently skip integration tests when they're - not needed, saving significant CI time and resources. + This function is used by the CI workflow to intelligently skip or filter + integration tests, saving significant CI time and resources. - Integration tests will run when ANY of the following conditions are met: + Returns (run_all=True, []) when ANY of the following conditions are met: 1. Core C++ files changed (esphome/core/*) - Any .cpp, .h, .tcc files in the core directory - These files contain fundamental functionality used throughout ESPHome - - Examples: esphome/core/component.cpp, esphome/core/application.h 2. Core Python files changed (esphome/core/*.py) - Only .py files in the esphome/core/ directory - These are core Python files that affect the entire system - - Examples: esphome/core/config.py, esphome/core/__init__.py - - NOT included: esphome/*.py, esphome/dashboard/*.py, esphome/components/*/*.py - 3. Integration test files changed - - Any file in tests/integration/ directory - - This includes test files themselves and fixture YAML files - - Examples: tests/integration/test_api.py, tests/integration/fixtures/api.yaml + 3. Integration test infrastructure files changed + - conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc. - 4. Components used by integration tests (or their dependencies) changed - - The function parses all YAML files in tests/integration/fixtures/ - - Extracts which components are used in integration tests - - Recursively finds all dependencies of those components - - If any of these components have changes, tests must run - - Example: If api.yaml uses 'sensor' and 'api' components, and 'api' depends on 'socket', - then changes to sensor/, api/, or socket/ components trigger tests + Returns (run_all=False, [test_files...]) when: + + 4. Specific integration test files changed + - Only those specific test files are returned + + 5. Components used by integration tests (or their dependencies) changed + - Only test files whose fixtures use the changed components are returned Args: branch: Branch to compare against. If None, uses default. Returns: - True if integration tests should run, False otherwise. + Tuple of (run_all, test_files) where: + - run_all: True if all integration tests should run + - test_files: List of specific test file paths to run (empty if run_all + is True, or if no tests need to run) """ files = changed_files(branch) if core_changed(files): - # If any core files changed, run integration tests - return True + # If any core files changed, run all integration tests + return (True, []) - # Check if any integration test files changed - if any("tests/integration" in file for file in files): - return True + # If infrastructure Python files changed (conftest, utils, etc.), run all tests + # Excludes test files (test_*.py), fixtures, and non-Python files (README.md) + if any( + f.startswith("tests/integration/") + and f.endswith(".py") + and not f.startswith("tests/integration/test_") + and "/fixtures/" not in f + for f in files + ): + return (True, []) - # Get all components used in integration tests and their dependencies - fixture_components = get_components_from_integration_fixtures() - all_required_components = get_all_dependencies(fixture_components) + # Collect specific test files that need to run + test_files: set[str] = set() + fixture_to_test_files = get_fixture_to_test_files() - # Check if any required components changed - for file in files: - component = get_component_from_path(file) - if component and component in all_required_components: - return True + for f in files: + if f.startswith("tests/integration/test_") and f.endswith(".py"): + test_files.add(f) + elif f.startswith("tests/integration/fixtures/"): + if f.endswith(".yaml"): + # Fixture YAML changed - add corresponding test file(s) + test_files.update(fixture_to_test_files.get(Path(f).stem, ())) + else: + # Non-YAML fixture file changed (e.g., external_components/) + # Run all tests since we can't determine which tests are affected + return (True, []) - return False + # Find test files whose fixtures use any of the changed components + changed_component_set = { + component for file in files if (component := get_component_from_path(file)) + } + if changed_component_set: + test_files.update( + get_integration_test_files_for_components(changed_component_set) + ) + + if test_files: + return (False, sorted(test_files)) + + return (False, []) @cache @@ -682,7 +707,10 @@ def main() -> None: args = parser.parse_args() # Determine what should run - run_integration = should_run_integration_tests(args.branch) + integration_run_all, integration_test_files = determine_integration_tests( + args.branch + ) + run_integration = integration_run_all or bool(integration_test_files) run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) @@ -810,6 +838,8 @@ def main() -> None: output: dict[str, Any] = { "integration_tests": run_integration, + "integration_tests_run_all": integration_run_all, + "integration_test_files": integration_test_files, "clang_tidy": run_clang_tidy, "clang_tidy_mode": clang_tidy_mode, "clang_format": run_clang_format, diff --git a/script/generate-rp2040-boards.py b/script/generate-rp2040-boards.py new file mode 100755 index 00000000000..1b4846fd2b8 --- /dev/null +++ b/script/generate-rp2040-boards.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys +import tempfile + +from esphome.components.rp2040 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +from esphome.components.rp2040.generate_boards import generate +from esphome.helpers import write_file_if_changed + +ver = RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +version_tag: str = f"{ver.major}.{ver.minor}.{ver.patch}" +root: Path = Path(__file__).parent.parent +boards_file_path: Path = root / "esphome" / "components" / "rp2040" / "boards.py" + + +def main(check: bool) -> None: + with tempfile.TemporaryDirectory() as tempdir: + subprocess.run( + [ + "git", + "clone", + "-q", + "-c", + "advice.detachedHead=false", + "--depth", + "1", + "--branch", + version_tag, + "https://github.com/earlephilhower/arduino-pico", + tempdir, + ], + check=True, + ) + + content: str = generate(Path(tempdir)) + + if check: + existing_content: str = boards_file_path.read_text(encoding="utf-8") + if existing_content != content: + print("esphome/components/rp2040/boards.py is not up to date.") + print("Please run `script/generate-rp2040-boards.py`") + sys.exit(1) + print("esphome/components/rp2040/boards.py is up to date") + elif write_file_if_changed(boards_file_path, content): + print("RP2040 boards updated successfully.") + + +if __name__ == "__main__": + parser: argparse.ArgumentParser = argparse.ArgumentParser() + parser.add_argument( + "--check", + help="Check if the boards.py file is up to date.", + action="store_true", + ) + args: argparse.Namespace = parser.parse_args() + main(args.check) diff --git a/script/helpers.py b/script/helpers.py index 202ac9b5fc7..9665af70ec7 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -15,6 +15,8 @@ from typing import Any import colorama +from esphome.loader import get_platform + root_path = os.path.abspath(os.path.normpath(os.path.join(__file__, "..", ".."))) basepath = os.path.join(root_path, "esphome") temp_folder = os.path.join(root_path, ".temp") @@ -624,11 +626,15 @@ def get_usable_cpu_count() -> int: ) -def get_all_dependencies(component_names: set[str]) -> set[str]: +def get_all_dependencies( + component_names: set[str], cpp_testing: bool = False +) -> set[str]: """Get all dependencies for a set of components. Args: component_names: Set of component names to get dependencies for + cpp_testing: If True, set CORE.cpp_testing so AUTO_LOAD callables that + conditionally include testing-only dependencies work correctly Returns: Set of all components including dependencies and auto-loaded components @@ -646,6 +652,7 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: # Reset CORE to ensure clean state CORE.reset() + CORE.cpp_testing = cpp_testing # Set up fake config path for component loading root = Path(__file__).parent.parent @@ -660,7 +667,11 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: new_components: set[str] = set() for comp_name in all_components: - comp = get_component(comp_name) + if "." in comp_name: + domain, platform = comp_name.split(".", maxsplit=1) + comp = get_platform(domain, platform) + else: + comp = get_component(comp_name) if not comp: continue @@ -689,35 +700,141 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: return all_components +def _extract_components_from_yaml(config: dict) -> set[str]: + """Extract component names from a parsed YAML config. + + Args: + config: Parsed YAML configuration dictionary + + Returns: + Set of component names found in the config + """ + components: set[str] = set() + + # Add all top-level component keys (skip YAML anchor keys starting with '.') + components.update(k for k in config if isinstance(k, str) and not k.startswith(".")) + + # Add platform values from list entries (e.g., sensor -> platform: template adds "template") + for value in config.values(): + if isinstance(value, list): + components.update( + item["platform"] + for item in value + if isinstance(item, dict) and "platform" in item + ) + + return components + + def get_components_from_integration_fixtures() -> set[str]: """Extract all components used in integration test fixtures. Returns: Set of component names used in integration test fixtures """ + return { + comp + for components in get_components_per_integration_fixture().values() + for comp in components + } + + +@cache +def get_components_per_integration_fixture() -> dict[str, set[str]]: + """Extract components used in each integration test fixture. + + Returns: + Dictionary mapping fixture name (stem) to set of component names + """ from esphome import yaml_util - components: set[str] = set() + result: dict[str, set[str]] = {} fixtures_dir = Path(__file__).parent.parent / "tests" / "integration" / "fixtures" for yaml_file in fixtures_dir.glob("*.yaml"): - config: dict[str, any] | None = yaml_util.load_yaml(yaml_file) + config: dict[str, Any] | None = yaml_util.load_yaml(yaml_file) if not config: continue - # Add all top-level component keys - components.update(config.keys()) + result[yaml_file.stem] = _extract_components_from_yaml(config) - # Add platform components (e.g., output.template) - for value in config.values(): - if not isinstance(value, list): - continue + return result - for item in value: - if isinstance(item, dict) and "platform" in item: - components.add(item["platform"]) - return components +_TEST_FUNC_RE = re.compile(r"async def (test_\w+)") + + +@cache +def get_fixture_to_test_files() -> dict[str, frozenset[str]]: + """Map integration test fixture names to the test files that use them. + + Returns: + Dictionary mapping fixture name to frozenset of test file paths + (relative to repo root) + """ + integration_dir = Path(__file__).parent.parent / "tests" / "integration" + result: dict[str, set[str]] = {} + + for test_file in integration_dir.glob("test_*.py"): + content = test_file.read_text(encoding="utf-8") + rel_path = test_file.relative_to(Path(__file__).parent.parent).as_posix() + for func in _TEST_FUNC_RE.findall(content): + base_name = func.replace("test_", "").partition("[")[0] + result.setdefault(base_name, set()).add(rel_path) + + return {k: frozenset(v) for k, v in result.items()} + + +@cache +def _get_component_to_integration_test_files() -> dict[str, frozenset[str]]: + """Build index mapping each component to the test files that depend on it. + + Resolves full dependency trees once per fixture, then inverts the mapping + so lookups are O(1) per component. + + Returns: + Dictionary mapping component name to frozenset of test file paths + """ + fixture_components = get_components_per_integration_fixture() + fixture_to_test_files = get_fixture_to_test_files() + + result: dict[str, set[str]] = {} + for fixture_name, components in fixture_components.items(): + test_files = fixture_to_test_files.get(fixture_name) + if not test_files: + continue + # Get full dependency tree for this fixture's components + all_deps = get_all_dependencies(components) + for dep in all_deps: + result.setdefault(dep, set()).update(test_files) + + return {k: frozenset(v) for k, v in result.items()} + + +def get_integration_test_files_for_components( + changed_components: set[str], +) -> list[str]: + """Get integration test file paths that use any of the given components. + + Uses a precomputed component → test files index for O(C) lookup + where C is the number of changed components. + + Args: + changed_components: Set of component names that have changed + + Returns: + Sorted list of test file paths relative to repo root + (e.g., ["tests/integration/test_api.py", ...]) + """ + component_to_tests = _get_component_to_integration_test_files() + + return sorted( + { + test_file + for component in changed_components + for test_file in component_to_tests.get(component, ()) + } + ) def filter_component_and_test_files(file_path: str) -> bool: diff --git a/tests/component_tests/logger/test_logger.py b/tests/component_tests/logger/test_logger.py index 83f8caa2d82..98aa7419642 100644 --- a/tests/component_tests/logger/test_logger.py +++ b/tests/component_tests/logger/test_logger.py @@ -11,33 +11,40 @@ def test_logger_pre_setup_before_other_components(generate_main): """ main_cpp = generate_main("tests/component_tests/logger/test_logger.yaml") - # Find the position of logger pre_setup - pre_setup_match = re.search(r"->pre_setup\(\)", main_cpp) - assert pre_setup_match is not None, "Logger pre_setup() not found in generated code" + # Find the logger's pre_setup() call specifically + logger_pre_setup = re.search(r"logger_logger->pre_setup\(\)", main_cpp) + if logger_pre_setup is None: + # Fall back to finding any logger-related pre_setup + logger_pre_setup = re.search(r"logger\w*->pre_setup\(\)", main_cpp) + assert logger_pre_setup is not None, ( + "Logger pre_setup() not found in generated code" + ) # Find all "new " allocations (component creation) new_allocations = list(re.finditer(r"\bnew [\w:]+", main_cpp)) assert len(new_allocations) > 0, "No component allocations found" - # Find the logger allocation - logger_new = None - for alloc in new_allocations: - if "logger" in alloc.group(): - logger_new = alloc - break + # Separate logger and non-logger allocations + logger_allocs = [a for a in new_allocations if "logger" in a.group().lower()] + non_logger_allocs = [ + a + for a in new_allocations + if "logger" not in a.group().lower() + # Skip placement new for App + and "(&App)" not in main_cpp[max(0, a.start() - 5) : a.start()] + ] - assert logger_new is not None, ( + assert len(logger_allocs) > 0, ( f"Logger allocation not found in: {[a.group() for a in new_allocations]}" ) + assert len(non_logger_allocs) > 0, ( + "No non-logger component allocations found — " + "add a component to test_logger.yaml so the ordering check is meaningful" + ) - # All non-logger allocations must appear after pre_setup() - for alloc in new_allocations: - if alloc == logger_new: - continue - # Skip "new (&App)" placement new which is before logger - if "(&App)" in main_cpp[max(0, alloc.start() - 5) : alloc.start()]: - continue - assert alloc.start() > pre_setup_match.start(), ( + # All non-logger allocations must appear after logger pre_setup() + for alloc in non_logger_allocs: + assert alloc.start() > logger_pre_setup.start(), ( f"Component allocation '{alloc.group()}' at position {alloc.start()} " - f"appears before logger pre_setup() at position {pre_setup_match.start()}" + f"appears before logger pre_setup() at position {logger_pre_setup.start()}" ) diff --git a/tests/component_tests/logger/test_logger.yaml b/tests/component_tests/logger/test_logger.yaml index 5d983cb3f5d..f43e99d94fd 100644 --- a/tests/component_tests/logger/test_logger.yaml +++ b/tests/component_tests/logger/test_logger.yaml @@ -7,3 +7,8 @@ esp8266: logger: level: DEBUG + +# Need at least one non-logger component so the ordering test +# can verify that logger pre_setup() comes before other allocations. +preferences: + flash_write_interval: 1min diff --git a/tests/components/README.md b/tests/components/README.md index 0901f2ef177..6da0dadd25e 100644 --- a/tests/components/README.md +++ b/tests/components/README.md @@ -7,10 +7,23 @@ testing binaries that combine many components. By convention, this unique namespace is `esphome::component::testing` (where "component" is the component under test), for example: `esphome::uart::testing`. +### Platform components + +For components that expose to a platform component, create a folder under your component test folder with the platform component name, e.g. `binary_sensor` and +include the relevant `.cpp` and `.h` test files there. + +### Override component code generation for testing + +When generating code for testing, ESPHome won't invoke the component's `to_code` function, since most components do not +need to generate configuration code for testing. + +If you do need to generate code to for example configure compilation flags or add libraries, +add the component name to the `CPP_TESTING_CODEGEN_COMPONENTS` allowlist in `script/cpp_unit_test.py`. ## Running component unit tests (from the repository root) + ```bash ./script/cpp_unit_test.py component1 component2 ... ``` diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..4cc865bb5d3 --- /dev/null +++ b/tests/components/adc/test.rp2040-pico2-ard.yaml @@ -0,0 +1,11 @@ +sensor: + - id: my_sensor + platform: adc + pin: VCC + name: ADC Test sensor + update_interval: "1:01" + unit_of_measurement: "°C" + icon: "mdi:water-percent" + accuracy_decimals: 5 + setup_priority: -100 + force_update: true diff --git a/tests/components/dew_point/common.yaml b/tests/components/dew_point/common.yaml new file mode 100644 index 00000000000..527eeb2f84b --- /dev/null +++ b/tests/components/dew_point/common.yaml @@ -0,0 +1,19 @@ +sensor: + - platform: dew_point + name: Dew Point + temperature: template_temperature + humidity: template_humidity + - platform: template + id: template_humidity + lambda: |- + if (millis() > 10000) { + return 0.6; + } + return 0.0; + - platform: template + id: template_temperature + lambda: |- + if (millis() > 10000) { + return 42.0; + } + return 0.0; diff --git a/tests/components/dew_point/test.esp32-idf.yaml b/tests/components/dew_point/test.esp32-idf.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/dew_point/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/dew_point/test.esp8266-ard.yaml b/tests/components/dew_point/test.esp8266-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/dew_point/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/dew_point/test.rp2040-ard.yaml b/tests/components/dew_point/test.rp2040-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/dew_point/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/esp8266/test.esp8266-ard.yaml b/tests/components/esp8266/test.esp8266-ard.yaml index 039a2610160..c77218f7a3c 100644 --- a/tests/components/esp8266/test.esp8266-ard.yaml +++ b/tests/components/esp8266/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +esp8266: + enable_full_printf: false + logger: level: VERBOSE diff --git a/tests/components/ethernet/common-w5500-rp2040.yaml b/tests/components/ethernet/common-w5500-rp2040.yaml new file mode 100644 index 00000000000..78b2b952fc0 --- /dev/null +++ b/tests/components/ethernet/common-w5500-rp2040.yaml @@ -0,0 +1,18 @@ +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + on_connect: + - logger.log: "Ethernet connected!" + on_disconnect: + - logger.log: "Ethernet disconnected!" diff --git a/tests/components/ethernet/common-w5500.yaml b/tests/components/ethernet/common-w5500.yaml index 1f8b8650dd0..bf3f6f3f0c4 100644 --- a/tests/components/ethernet/common-w5500.yaml +++ b/tests/components/ethernet/common-w5500.yaml @@ -2,10 +2,10 @@ ethernet: type: W5500 clk_pin: 19 mosi_pin: 21 - miso_pin: 23 + miso_pin: 17 cs_pin: 18 interrupt_pin: 36 - reset_pin: 22 + reset_pin: 12 clock_speed: 10Mhz manual_ip: static_ip: 192.168.178.56 diff --git a/tests/components/ethernet/test-w5500.rp2040-ard.yaml b/tests/components/ethernet/test-w5500.rp2040-ard.yaml new file mode 100644 index 00000000000..7953198b7e3 --- /dev/null +++ b/tests/components/ethernet/test-w5500.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-w5500-rp2040.yaml diff --git a/tests/components/ethernet/test.esp32-p4-idf.yaml b/tests/components/ethernet/test.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e52329d7ea2 --- /dev/null +++ b/tests/components/ethernet/test.esp32-p4-idf.yaml @@ -0,0 +1 @@ +<<: !include common-ip101.yaml diff --git a/tests/components/ethernet/test.esp32-s3-idf.yaml b/tests/components/ethernet/test.esp32-s3-idf.yaml new file mode 100644 index 00000000000..36f1b5365f1 --- /dev/null +++ b/tests/components/ethernet/test.esp32-s3-idf.yaml @@ -0,0 +1 @@ +<<: !include common-w5500.yaml diff --git a/tests/components/external_components/common.yaml b/tests/components/external_components/common.yaml index 2b51267ec68..c55129094c4 100644 --- a/tests/components/external_components/common.yaml +++ b/tests/components/external_components/common.yaml @@ -1,6 +1,8 @@ external_components: - - source: github://esphome/esphome@dev + - id: my_ext + source: github://esphome/esphome@dev refresh: 1d components: [bh1750] - - source: ../../../esphome/components + - id: my_local + source: ../../../esphome/components components: [sntp] diff --git a/tests/components/external_components/test.esp32-idf.yaml b/tests/components/external_components/test.esp32-idf.yaml index dade44d145b..afe2bd5d6ae 100644 --- a/tests/components/external_components/test.esp32-idf.yaml +++ b/tests/components/external_components/test.esp32-idf.yaml @@ -1 +1,5 @@ +# WARNING: Using !extend or !remove prevents automatic component grouping in CI, making builds slower. <<: !include common.yaml + +external_components: + - id: !remove my_local diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index e5fab62a793..e1216e7b60b 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -60,6 +60,12 @@ esphome: } } + # Test set_effect with const char* doesn't cause ambiguous overload (issue #14728) + - lambda: |- + auto call = id(test_monochromatic_light).turn_on(); + call.set_effect("None"); + call.perform(); + - light.toggle: test_binary_light - light.turn_off: test_rgb_light - light.turn_on: diff --git a/tests/components/modbus/modbus_test.cpp b/tests/components/modbus/modbus_test.cpp new file mode 100644 index 00000000000..afe5ced082b --- /dev/null +++ b/tests/components/modbus/modbus_test.cpp @@ -0,0 +1,59 @@ +#include +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/helpers.h" + +namespace esphome::modbus { + +// Exposes protected methods for testing. +class TestModbus : public Modbus { + public: + bool test_parse_modbus_byte(uint8_t byte) { return this->parse_modbus_byte_(byte); } + void test_clear_rx_buffer() { this->rx_buffer_.clear(); } + void set_waiting(uint8_t addr) { this->waiting_for_response_ = addr; } +}; + +class MockDevice : public ModbusDevice { + public: + void on_modbus_data(const std::vector &data) override { this->data_received = true; } + bool data_received{false}; +}; + +TEST(ModbusTest, TwoByteRegressionTest) { + TestModbus modbus; + modbus.set_role(ModbusRole::CLIENT); + // First byte (at=0) + EXPECT_TRUE(modbus.test_parse_modbus_byte(0x01)); + // Second byte (at=1) + // This used to reach raw[2] because it skipped the if(at==2) check, causing a + // buffer overflow. + EXPECT_TRUE(modbus.test_parse_modbus_byte(0x03)); +} + +TEST(ModbusTest, TestValidFrame) { + TestModbus modbus; + modbus.set_role(ModbusRole::CLIENT); + + MockDevice device; + device.set_parent(&modbus); + device.set_address(0x01); + modbus.register_device(&device); + modbus.set_waiting(0x01); + + // Address 1, Function 3, Length 2, Data 0x1234 + uint8_t frame_data[] = {0x01, 0x03, 0x02, 0x12, 0x34}; + uint16_t crc = esphome::crc16(frame_data, sizeof(frame_data)); + + std::vector frame; + for (uint8_t b : frame_data) + frame.push_back(b); + frame.push_back(crc & 0xFF); + frame.push_back((crc >> 8) & 0xFF); + + for (size_t i = 0; i < frame.size(); i++) { + bool result = modbus.test_parse_modbus_byte(frame[i]); + EXPECT_TRUE(result) << "Failed at byte " << i << " (0x" << std::hex << (int) frame[i] << ")"; + } + EXPECT_TRUE(device.data_received); +} + +} // namespace esphome::modbus diff --git a/tests/components/nextion/common_tft_upload.yaml b/tests/components/nextion/common_tft_upload.yaml new file mode 100644 index 00000000000..190abbc7b19 --- /dev/null +++ b/tests/components/nextion/common_tft_upload.yaml @@ -0,0 +1,5 @@ +display: + - id: !extend main_lcd + tft_url: http://esphome.io/default35.tft + tft_upload_http_timeout: 20s + tft_upload_http_retries: 10 diff --git a/tests/components/nextion/common_tft_upload_watchdog.yaml b/tests/components/nextion/common_tft_upload_watchdog.yaml new file mode 100644 index 00000000000..385fee359e7 --- /dev/null +++ b/tests/components/nextion/common_tft_upload_watchdog.yaml @@ -0,0 +1,3 @@ +display: + - id: !extend main_lcd + tft_upload_watchdog_timeout: 30s diff --git a/tests/components/nextion/test.esp32-ard.yaml b/tests/components/nextion/test.esp32-ard.yaml index 7e94a9b4a5b..a4f71628dac 100644 --- a/tests/components/nextion/test.esp32-ard.yaml +++ b/tests/components/nextion/test.esp32-ard.yaml @@ -1,7 +1,5 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-ard.yaml base: !include common.yaml - -display: - - id: !extend main_lcd - tft_url: http://esphome.io/default35.tft + tft_upload: !include common_tft_upload.yaml + tft_upload_watchdog: !include common_tft_upload_watchdog.yaml diff --git a/tests/components/nextion/test.esp32-idf.yaml b/tests/components/nextion/test.esp32-idf.yaml index 99820f0f8da..259b71c9d04 100644 --- a/tests/components/nextion/test.esp32-idf.yaml +++ b/tests/components/nextion/test.esp32-idf.yaml @@ -1,7 +1,5 @@ packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml base: !include common.yaml - -display: - - id: !extend main_lcd - tft_url: http://esphome.io/default35.tft + tft_upload: !include common_tft_upload.yaml + tft_upload_watchdog: !include common_tft_upload_watchdog.yaml diff --git a/tests/components/nextion/test.esp8266-ard.yaml b/tests/components/nextion/test.esp8266-ard.yaml index 49f79b2f4c5..a2b0e727cce 100644 --- a/tests/components/nextion/test.esp8266-ard.yaml +++ b/tests/components/nextion/test.esp8266-ard.yaml @@ -1,7 +1,4 @@ packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml base: !include common.yaml - -display: - - id: !extend main_lcd - tft_url: http://esphome.io/default35.tft + tft_upload: !include common_tft_upload.yaml diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index 422a24b5406..fc3cc942172 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -40,6 +40,10 @@ online_image: url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: BINARY + - id: online_rgb_bmp_8bit + url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp + format: BMP + type: RGB - id: online_jpeg_image url: http://www.faqs.org/images/library.jpg format: JPEG diff --git a/tests/components/packet_transport/binary_sensor/binary_sensor_test.cpp b/tests/components/packet_transport/binary_sensor/binary_sensor_test.cpp new file mode 100644 index 00000000000..36af087d2c2 --- /dev/null +++ b/tests/components/packet_transport/binary_sensor/binary_sensor_test.cpp @@ -0,0 +1,77 @@ +#include "../common.h" + +namespace esphome::packet_transport::testing { + +TEST(PacketTransportBinarySensorTest, AddBinarySensor) { + TestablePacketTransport transport; + binary_sensor::BinarySensor bs; + transport.add_binary_sensor("motion", &bs); + ASSERT_EQ(transport.binary_sensors_.size(), 1u); + EXPECT_STREQ(transport.binary_sensors_[0].id, "motion"); + EXPECT_EQ(transport.binary_sensors_[0].sensor, &bs); +} + +TEST(PacketTransportBinarySensorTest, AddRemoteBinarySensor) { + TestablePacketTransport transport; + binary_sensor::BinarySensor bs; + transport.add_remote_binary_sensor("host1", "remote_motion", &bs); + EXPECT_TRUE(transport.providers_.contains("host1")); + EXPECT_EQ(transport.remote_binary_sensors_["host1"]["remote_motion"], &bs); +} + +TEST(PacketTransportBinarySensorTest, UnencryptedBinarySensorRoundTrip) { + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + binary_sensor::BinarySensor local_bs; + local_bs.state = true; + encoder.add_binary_sensor("motion", &local_bs); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + binary_sensor::BinarySensor remote_bs; + decoder.add_remote_binary_sensor("sender", "motion", &remote_bs); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + EXPECT_TRUE(remote_bs.state); +} + +TEST(PacketTransportBinarySensorTest, MultipleSensorsRoundTrip) { + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + + sensor::Sensor s1, s2; + s1.state = 10.0f; + s2.state = 20.0f; + encoder.add_sensor("s1", &s1); + encoder.add_sensor("s2", &s2); + + binary_sensor::BinarySensor bs1; + bs1.state = true; + encoder.add_binary_sensor("bs1", &bs1); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor rs1, rs2; + binary_sensor::BinarySensor rbs1; + rs1.state = -999.0f; + rs2.state = -999.0f; + decoder.add_remote_sensor("sender", "s1", &rs1); + decoder.add_remote_sensor("sender", "s2", &rs2); + decoder.add_remote_binary_sensor("sender", "bs1", &rbs1); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + + EXPECT_FLOAT_EQ(rs1.state, 10.0f); + EXPECT_FLOAT_EQ(rs2.state, 20.0f); + EXPECT_TRUE(rbs1.state); +} + +} // namespace esphome::packet_transport::testing diff --git a/tests/components/packet_transport/cpp_test.yaml b/tests/components/packet_transport/cpp_test.yaml deleted file mode 100644 index fa39df3c0ae..00000000000 --- a/tests/components/packet_transport/cpp_test.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Extra component configuration required by C++ unit tests. -# Loaded by cpp_unit_test.py and merged into the test build config -# before validation, so that platform defines (USE_SENSOR, etc.) are generated. - -sensor: - - platform: template - id: test_cpp_sensor - -binary_sensor: - - platform: template - id: test_cpp_binary_sensor diff --git a/tests/components/packet_transport/packet_transport_test.cpp b/tests/components/packet_transport/packet_transport_test.cpp index d8f11ca6072..59c0a88ed7c 100644 --- a/tests/components/packet_transport/packet_transport_test.cpp +++ b/tests/components/packet_transport/packet_transport_test.cpp @@ -65,198 +65,6 @@ TEST(PacketTransportTest, SetProviderEncryption) { EXPECT_EQ(transport.providers_["host1"].encryption_key, key); } -// --- Sensor management (requires USE_SENSOR / USE_BINARY_SENSOR) --- - -#ifdef USE_SENSOR -TEST(PacketTransportTest, AddSensor) { - TestablePacketTransport transport; - sensor::Sensor s; - transport.add_sensor("temp", &s); - ASSERT_EQ(transport.sensors_.size(), 1u); - EXPECT_STREQ(transport.sensors_[0].id, "temp"); - EXPECT_EQ(transport.sensors_[0].sensor, &s); - EXPECT_TRUE(transport.sensors_[0].updated); -} - -TEST(PacketTransportTest, AddRemoteSensor) { - TestablePacketTransport transport; - sensor::Sensor s; - transport.add_remote_sensor("host1", "remote_temp", &s); - EXPECT_TRUE(transport.providers_.contains("host1")); - EXPECT_EQ(transport.remote_sensors_["host1"]["remote_temp"], &s); -} -#endif - -#ifdef USE_BINARY_SENSOR -TEST(PacketTransportTest, AddBinarySensor) { - TestablePacketTransport transport; - binary_sensor::BinarySensor bs; - transport.add_binary_sensor("motion", &bs); - ASSERT_EQ(transport.binary_sensors_.size(), 1u); - EXPECT_STREQ(transport.binary_sensors_[0].id, "motion"); - EXPECT_EQ(transport.binary_sensors_[0].sensor, &bs); -} - -TEST(PacketTransportTest, AddRemoteBinarySensor) { - TestablePacketTransport transport; - binary_sensor::BinarySensor bs; - transport.add_remote_binary_sensor("host1", "remote_motion", &bs); - EXPECT_TRUE(transport.providers_.contains("host1")); - EXPECT_EQ(transport.remote_binary_sensors_["host1"]["remote_motion"], &bs); -} -#endif - -// --- Unencrypted round-trip tests (require USE_SENSOR / USE_BINARY_SENSOR) --- - -#ifdef USE_SENSOR -TEST(PacketTransportTest, UnencryptedSensorRoundTrip) { - // Encoder - TestablePacketTransport encoder; - encoder.init_for_test("sender"); - sensor::Sensor local_sensor; - local_sensor.state = 42.5f; - encoder.add_sensor("temp", &local_sensor); - - encoder.send_data_(true); - ASSERT_EQ(encoder.sent_packets.size(), 1u); - - // Decoder - TestablePacketTransport decoder; - decoder.init_for_test("receiver"); - sensor::Sensor remote_sensor; - remote_sensor.state = -999.0f; // sentinel - decoder.add_remote_sensor("sender", "temp", &remote_sensor); - - auto &packet = encoder.sent_packets[0]; - decoder.process_({packet.data(), packet.size()}); - EXPECT_FLOAT_EQ(remote_sensor.state, 42.5f); -} -#endif - -#ifdef USE_BINARY_SENSOR -TEST(PacketTransportTest, UnencryptedBinarySensorRoundTrip) { - TestablePacketTransport encoder; - encoder.init_for_test("sender"); - binary_sensor::BinarySensor local_bs; - local_bs.state = true; - encoder.add_binary_sensor("motion", &local_bs); - - encoder.send_data_(true); - ASSERT_EQ(encoder.sent_packets.size(), 1u); - - TestablePacketTransport decoder; - decoder.init_for_test("receiver"); - binary_sensor::BinarySensor remote_bs; - decoder.add_remote_binary_sensor("sender", "motion", &remote_bs); - - auto &packet = encoder.sent_packets[0]; - decoder.process_({packet.data(), packet.size()}); - EXPECT_TRUE(remote_bs.state); -} -#endif - -#if defined(USE_SENSOR) && defined(USE_BINARY_SENSOR) -TEST(PacketTransportTest, MultipleSensorsRoundTrip) { - TestablePacketTransport encoder; - encoder.init_for_test("sender"); - - sensor::Sensor s1, s2; - s1.state = 10.0f; - s2.state = 20.0f; - encoder.add_sensor("s1", &s1); - encoder.add_sensor("s2", &s2); - - binary_sensor::BinarySensor bs1; - bs1.state = true; - encoder.add_binary_sensor("bs1", &bs1); - - encoder.send_data_(true); - ASSERT_EQ(encoder.sent_packets.size(), 1u); - - TestablePacketTransport decoder; - decoder.init_for_test("receiver"); - sensor::Sensor rs1, rs2; - binary_sensor::BinarySensor rbs1; - rs1.state = -999.0f; - rs2.state = -999.0f; - decoder.add_remote_sensor("sender", "s1", &rs1); - decoder.add_remote_sensor("sender", "s2", &rs2); - decoder.add_remote_binary_sensor("sender", "bs1", &rbs1); - - auto &packet = encoder.sent_packets[0]; - decoder.process_({packet.data(), packet.size()}); - - EXPECT_FLOAT_EQ(rs1.state, 10.0f); - EXPECT_FLOAT_EQ(rs2.state, 20.0f); - EXPECT_TRUE(rbs1.state); -} -#endif - -// --- Encrypted round-trip --- - -#ifdef USE_SENSOR -TEST(PacketTransportTest, EncryptedSensorRoundTrip) { - std::vector key(32); - for (int i = 0; i < 32; i++) - key[i] = i; - - TestablePacketTransport encoder; - encoder.init_for_test("sender"); - encoder.set_encryption_key(key); - sensor::Sensor local_sensor; - local_sensor.state = 99.9f; - encoder.add_sensor("temp", &local_sensor); - - encoder.send_data_(true); - ASSERT_EQ(encoder.sent_packets.size(), 1u); - - TestablePacketTransport decoder; - decoder.init_for_test("receiver"); - sensor::Sensor remote_sensor; - remote_sensor.state = -999.0f; - decoder.add_remote_sensor("sender", "temp", &remote_sensor); - decoder.set_provider_encryption("sender", key); - - auto &packet = encoder.sent_packets[0]; - decoder.process_({packet.data(), packet.size()}); - EXPECT_FLOAT_EQ(remote_sensor.state, 99.9f); -} - -// --- Selective send --- - -TEST(PacketTransportTest, SendDataOnlyUpdated) { - TestablePacketTransport encoder; - encoder.init_for_test("sender"); - - sensor::Sensor s1, s2; - s1.state = 1.0f; - s2.state = 2.0f; - encoder.add_sensor("s1", &s1); - encoder.add_sensor("s2", &s2); - - // Mark s1 as not updated, only s2 as updated - encoder.sensors_[0].updated = false; - encoder.sensors_[1].updated = true; - - encoder.send_data_(false); - ASSERT_EQ(encoder.sent_packets.size(), 1u); - - TestablePacketTransport decoder; - decoder.init_for_test("receiver"); - sensor::Sensor rs1, rs2; - rs1.state = -999.0f; - rs2.state = -999.0f; - decoder.add_remote_sensor("sender", "s1", &rs1); - decoder.add_remote_sensor("sender", "s2", &rs2); - - auto &packet = encoder.sent_packets[0]; - decoder.process_({packet.data(), packet.size()}); - - EXPECT_FLOAT_EQ(rs1.state, -999.0f); // not updated, not sent - EXPECT_FLOAT_EQ(rs2.state, 2.0f); // updated, sent -} -#endif - // --- Ping key tests --- TEST(PacketTransportTest, PingKeyStoredWhenEncrypted) { @@ -319,73 +127,6 @@ TEST(PacketTransportTest, PingKeyMaxLimit) { EXPECT_FALSE(transport.ping_keys_.contains("host4")); } -#ifdef USE_SENSOR -TEST(PacketTransportTest, PingKeyIncludedInTransmittedPacket) { - std::vector key(32, 0xBB); - - // Responder: encrypted, owns a sensor - TestablePacketTransport responder; - responder.init_for_test("responder"); - responder.set_encryption_key(key); - sensor::Sensor local_sensor; - local_sensor.state = 77.7f; - responder.add_sensor("temp", &local_sensor); - - // Requester sends a MAGIC_PING that the responder processes - auto ping = build_ping_packet("requester", 0xDEADBEEF); - responder.process_({ping.data(), ping.size()}); - ASSERT_EQ(responder.ping_keys_.size(), 1u); - - // Responder sends sensor data — ping key should be embedded - responder.send_data_(true); - ASSERT_EQ(responder.sent_packets.size(), 1u); - - // Requester: encrypted provider, ping-pong enabled, expects key 0xDEADBEEF - TestablePacketTransport requester; - requester.init_for_test("requester"); - requester.set_ping_pong_enable(true); - requester.ping_key_ = 0xDEADBEEF; - sensor::Sensor remote_sensor; - remote_sensor.state = -999.0f; - requester.add_remote_sensor("responder", "temp", &remote_sensor); - requester.set_provider_encryption("responder", key); - - // The requester decrypts the packet and finds its ping key echoed back, - // which gates the sensor data — if the key is missing, data is blocked. - auto &packet = responder.sent_packets[0]; - requester.process_({packet.data(), packet.size()}); - EXPECT_FLOAT_EQ(remote_sensor.state, 77.7f); -} - -TEST(PacketTransportTest, MissingPingKeyBlocksSensorData) { - std::vector key(32, 0xBB); - - // Responder sends data WITHOUT receiving any MAGIC_PING first — no ping keys - TestablePacketTransport responder; - responder.init_for_test("responder"); - responder.set_encryption_key(key); - sensor::Sensor local_sensor; - local_sensor.state = 77.7f; - responder.add_sensor("temp", &local_sensor); - responder.send_data_(true); - ASSERT_EQ(responder.sent_packets.size(), 1u); - - // Requester with ping-pong enabled expects a key that isn't in the packet - TestablePacketTransport requester; - requester.init_for_test("requester"); - requester.set_ping_pong_enable(true); - requester.ping_key_ = 0xDEADBEEF; - sensor::Sensor remote_sensor; - remote_sensor.state = -999.0f; - requester.add_remote_sensor("responder", "temp", &remote_sensor); - requester.set_provider_encryption("responder", key); - - auto &packet = responder.sent_packets[0]; - requester.process_({packet.data(), packet.size()}); - EXPECT_FLOAT_EQ(remote_sensor.state, -999.0f); // blocked — ping key not found -} -#endif - // --- Process error handling --- TEST(PacketTransportTest, ProcessShortBuffer) { diff --git a/tests/components/packet_transport/sensor/sensor_test.cpp b/tests/components/packet_transport/sensor/sensor_test.cpp new file mode 100644 index 00000000000..2f681aee587 --- /dev/null +++ b/tests/components/packet_transport/sensor/sensor_test.cpp @@ -0,0 +1,170 @@ +#include "../common.h" + +namespace esphome::packet_transport::testing { + +TEST(PacketTransportSensorTest, AddSensor) { + TestablePacketTransport transport; + sensor::Sensor s; + transport.add_sensor("temp", &s); + ASSERT_EQ(transport.sensors_.size(), 1u); + EXPECT_STREQ(transport.sensors_[0].id, "temp"); + EXPECT_EQ(transport.sensors_[0].sensor, &s); + EXPECT_TRUE(transport.sensors_[0].updated); +} + +TEST(PacketTransportSensorTest, AddRemoteSensor) { + TestablePacketTransport transport; + sensor::Sensor s; + transport.add_remote_sensor("host1", "remote_temp", &s); + EXPECT_TRUE(transport.providers_.contains("host1")); + EXPECT_EQ(transport.remote_sensors_["host1"]["remote_temp"], &s); +} + +TEST(PacketTransportSensorTest, UnencryptedSensorRoundTrip) { + // Encoder + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + sensor::Sensor local_sensor; + local_sensor.state = 42.5f; + encoder.add_sensor("temp", &local_sensor); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + // Decoder + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; // sentinel + decoder.add_remote_sensor("sender", "temp", &remote_sensor); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, 42.5f); +} + +TEST(PacketTransportSensorTest, EncryptedSensorRoundTrip) { + std::vector key(32); + for (int i = 0; i < 32; i++) + key[i] = i; + + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + encoder.set_encryption_key(key); + sensor::Sensor local_sensor; + local_sensor.state = 99.9f; + encoder.add_sensor("temp", &local_sensor); + + encoder.send_data_(true); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; + decoder.add_remote_sensor("sender", "temp", &remote_sensor); + decoder.set_provider_encryption("sender", key); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, 99.9f); +} + +TEST(PacketTransportSensorTest, SendDataOnlyUpdated) { + TestablePacketTransport encoder; + encoder.init_for_test("sender"); + + sensor::Sensor s1, s2; + s1.state = 1.0f; + s2.state = 2.0f; + encoder.add_sensor("s1", &s1); + encoder.add_sensor("s2", &s2); + + // Mark s1 as not updated, only s2 as updated + encoder.sensors_[0].updated = false; + encoder.sensors_[1].updated = true; + + encoder.send_data_(false); + ASSERT_EQ(encoder.sent_packets.size(), 1u); + + TestablePacketTransport decoder; + decoder.init_for_test("receiver"); + sensor::Sensor rs1, rs2; + rs1.state = -999.0f; + rs2.state = -999.0f; + decoder.add_remote_sensor("sender", "s1", &rs1); + decoder.add_remote_sensor("sender", "s2", &rs2); + + auto &packet = encoder.sent_packets[0]; + decoder.process_({packet.data(), packet.size()}); + + EXPECT_FLOAT_EQ(rs1.state, -999.0f); // not updated, not sent + EXPECT_FLOAT_EQ(rs2.state, 2.0f); // updated, sent +} + +TEST(PacketTransportSensorTest, PingKeyIncludedInTransmittedPacket) { + std::vector key(32, 0xBB); + + // Responder: encrypted, owns a sensor + TestablePacketTransport responder; + responder.init_for_test("responder"); + responder.set_encryption_key(key); + sensor::Sensor local_sensor; + local_sensor.state = 77.7f; + responder.add_sensor("temp", &local_sensor); + + // Requester sends a MAGIC_PING that the responder processes + auto ping = build_ping_packet("requester", 0xDEADBEEF); + responder.process_({ping.data(), ping.size()}); + ASSERT_EQ(responder.ping_keys_.size(), 1u); + + // Responder sends sensor data — ping key should be embedded + responder.send_data_(true); + ASSERT_EQ(responder.sent_packets.size(), 1u); + + // Requester: encrypted provider, ping-pong enabled, expects key 0xDEADBEEF + TestablePacketTransport requester; + requester.init_for_test("requester"); + requester.set_ping_pong_enable(true); + requester.ping_key_ = 0xDEADBEEF; + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; + requester.add_remote_sensor("responder", "temp", &remote_sensor); + requester.set_provider_encryption("responder", key); + + // The requester decrypts the packet and finds its ping key echoed back, + // which gates the sensor data — if the key is missing, data is blocked. + auto &packet = responder.sent_packets[0]; + requester.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, 77.7f); +} + +TEST(PacketTransportSensorTest, MissingPingKeyBlocksSensorData) { + std::vector key(32, 0xBB); + + // Responder sends data WITHOUT receiving any MAGIC_PING first — no ping keys + TestablePacketTransport responder; + responder.init_for_test("responder"); + responder.set_encryption_key(key); + sensor::Sensor local_sensor; + local_sensor.state = 77.7f; + responder.add_sensor("temp", &local_sensor); + responder.send_data_(true); + ASSERT_EQ(responder.sent_packets.size(), 1u); + + // Requester with ping-pong enabled expects a key that isn't in the packet + TestablePacketTransport requester; + requester.init_for_test("requester"); + requester.set_ping_pong_enable(true); + requester.ping_key_ = 0xDEADBEEF; + sensor::Sensor remote_sensor; + remote_sensor.state = -999.0f; + requester.add_remote_sensor("responder", "temp", &remote_sensor); + requester.set_provider_encryption("responder", key); + + auto &packet = responder.sent_packets[0]; + requester.process_({packet.data(), packet.size()}); + EXPECT_FLOAT_EQ(remote_sensor.state, -999.0f); // blocked — ping key not found +} + +} // namespace esphome::packet_transport::testing diff --git a/tests/components/psram/test.esp32-c61-idf.yaml b/tests/components/psram/test.esp32-c61-idf.yaml new file mode 100644 index 00000000000..d443aab9518 --- /dev/null +++ b/tests/components/psram/test.esp32-c61-idf.yaml @@ -0,0 +1,7 @@ +esp32: + framework: + type: esp-idf + +psram: + speed: 80MHz + ignore_not_found: false diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2040/test.rp2040-ard.yaml index 039a2610160..1eb315a3b47 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2040/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +rp2040: + enable_full_printf: false + logger: level: VERBOSE diff --git a/tests/components/rp2040_ble/common.yaml b/tests/components/rp2040_ble/common.yaml new file mode 100644 index 00000000000..f6205a4724f --- /dev/null +++ b/tests/components/rp2040_ble/common.yaml @@ -0,0 +1 @@ +rp2040_ble: diff --git a/tests/components/rp2040_ble/test-disable-on-boot.rp2040-ard.yaml b/tests/components/rp2040_ble/test-disable-on-boot.rp2040-ard.yaml new file mode 100644 index 00000000000..2154536111f --- /dev/null +++ b/tests/components/rp2040_ble/test-disable-on-boot.rp2040-ard.yaml @@ -0,0 +1,2 @@ +rp2040_ble: + enable_on_boot: false diff --git a/tests/components/rp2040_ble/test.rp2040-ard.yaml b/tests/components/rp2040_ble/test.rp2040-ard.yaml new file mode 100644 index 00000000000..dade44d145b --- /dev/null +++ b/tests/components/rp2040_ble/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/serial_proxy/common.yaml b/tests/components/serial_proxy/common.yaml new file mode 100644 index 00000000000..6f03cf95dff --- /dev/null +++ b/tests/components/serial_proxy/common.yaml @@ -0,0 +1,10 @@ +wifi: + ssid: MySSID + password: password1 + +api: + +serial_proxy: + - id: serial_proxy_1 + name: Test Serial Port + port_type: RS232 diff --git a/tests/components/serial_proxy/test.esp32-idf.yaml b/tests/components/serial_proxy/test.esp32-idf.yaml new file mode 100644 index 00000000000..b415125e84b --- /dev/null +++ b/tests/components/serial_proxy/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/serial_proxy/test.esp8266-ard.yaml b/tests/components/serial_proxy/test.esp8266-ard.yaml new file mode 100644 index 00000000000..96ab4ef6aca --- /dev/null +++ b/tests/components/serial_proxy/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/serial_proxy/test.rp2040-ard.yaml b/tests/components/serial_proxy/test.rp2040-ard.yaml new file mode 100644 index 00000000000..b28f2b5e05e --- /dev/null +++ b/tests/components/serial_proxy/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml new file mode 100644 index 00000000000..7d663b802c6 --- /dev/null +++ b/tests/components/speaker_source/common.yaml @@ -0,0 +1,61 @@ +i2s_audio: + i2s_lrclk_pin: ${i2s_bclk_pin} + i2s_bclk_pin: ${i2s_lrclk_pin} + i2s_mclk_pin: ${i2s_mclk_pin} + +speaker: + - platform: i2s_audio + id: speaker_id + dac_type: external + i2s_dout_pin: ${i2s_dout_pin} + sample_rate: 48000 + num_channels: 2 + - platform: mixer + output_speaker: speaker_id + source_speakers: + - id: announcement_mixer_speaker_id + - id: media_mixer_speaker_id + +audio_file: + - id: test_audio + file: + type: local + path: $component_dir/test.wav + +media_source: + - platform: audio_file + id: announcement_audio_file_source + - platform: audio_file + id: media_audio_file_source + +media_player: + - platform: speaker_source + id: media_player_id + name: Media Player + volume_increment: 0.02 + volume_initial: 0.75 + volume_max: 0.95 + volume_min: 0.0 + announcement_pipeline: + speaker: announcement_mixer_speaker_id + format: FLAC + num_channels: 1 + sources: + - announcement_audio_file_source + media_pipeline: + speaker: media_mixer_speaker_id + format: FLAC + num_channels: 1 + sources: + - media_audio_file_source + on_mute: + - media_player.shuffle: + id: media_player_id + on_unmute: + - media_player.unshuffle: + id: media_player_id + on_volume: + - speaker_source.set_playlist_delay: + id: media_player_id + pipeline: media + delay: 500ms diff --git a/tests/components/speaker_source/test.esp32-idf.yaml b/tests/components/speaker_source/test.esp32-idf.yaml new file mode 100644 index 00000000000..e2439ebdf21 --- /dev/null +++ b/tests/components/speaker_source/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + i2s_mclk_pin: GPIO25 + i2s_dout_pin: GPIO23 + +<<: !include common.yaml diff --git a/tests/components/speaker_source/test.wav b/tests/components/speaker_source/test.wav new file mode 100644 index 00000000000..f9d07ef2238 Binary files /dev/null and b/tests/components/speaker_source/test.wav differ diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..81a8acafd88 --- /dev/null +++ b/tests/components/spi/test.rp2040-pico2-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + clk_pin: GPIO2 + mosi_pin: GPIO3 + miso_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/uart/test.rp2040-ard.yaml b/tests/components/uart/test.rp2040-ard.yaml index 5eb2b533ea0..1d5f91c6a7d 100644 --- a/tests/components/uart/test.rp2040-ard.yaml +++ b/tests/components/uart/test.rp2040-ard.yaml @@ -23,3 +23,6 @@ uart: baud_rate: 115200 debug: debug_prefix: "[UART1] " + - id: uart_rx_only + rx_pin: 17 + baud_rate: 1200 diff --git a/tests/components/vbus/common.yaml b/tests/components/vbus/common.yaml index 5c771be922b..bdd75a2b975 100644 --- a/tests/components/vbus/common.yaml +++ b/tests/components/vbus/common.yaml @@ -19,6 +19,10 @@ binary_sensor: name: BS2 Sensor 3 Error sensor4_error: name: BS2 Sensor 4 Error + - platform: vbus + model: deltasol_cs4 + sensor1_error: + name: "DeltaSol CS4 Sensor 1 Error" - platform: vbus model: custom command: 0x100 @@ -44,6 +48,10 @@ sensor: name: DeltaSol C Heat Quantity time: name: DeltaSol C System Time + - platform: vbus + model: deltasol_cs4 + temperature_1: + name: "DeltaSol CS4 Temperature 1" - platform: vbus model: deltasol_bs2 temperature_1: diff --git a/tests/integration/fixtures/online_image_bmp.yaml b/tests/integration/fixtures/online_image_bmp.yaml new file mode 100644 index 00000000000..e36514e9ae2 --- /dev/null +++ b/tests/integration/fixtures/online_image_bmp.yaml @@ -0,0 +1,27 @@ +esphome: + name: online-image-bmp + +host: + +http_request: + +display: + +online_image: + - url: http://127.0.0.1:HTTP_PORT/foo.bmp + id: myimg + format: BMP + type: RGB + on_download_finished: + logger.log: + format: "download finished. cache hit: %u" + args: [cached] + +api: + actions: + - action: fetch_image + then: + - component.update: myimg + +logger: + level: DEBUG diff --git a/tests/integration/fixtures/scheduler_bulk_cleanup.yaml b/tests/integration/fixtures/scheduler_bulk_cleanup.yaml index de876da8c47..3d2c47a0de5 100644 --- a/tests/integration/fixtures/scheduler_bulk_cleanup.yaml +++ b/tests/integration/fixtures/scheduler_bulk_cleanup.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-bulk-cleanup external_components: diff --git a/tests/integration/fixtures/scheduler_defer_cancel.yaml b/tests/integration/fixtures/scheduler_defer_cancel.yaml index 9e3f927c33c..92ae0062aca 100644 --- a/tests/integration/fixtures/scheduler_defer_cancel.yaml +++ b/tests/integration/fixtures/scheduler_defer_cancel.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-cancel host: diff --git a/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml b/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml index fb6b1791dc4..cf7f6ec7338 100644 --- a/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml +++ b/tests/integration/fixtures/scheduler_defer_cancels_regular.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-cancel-regular host: diff --git a/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml b/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml index 7384082ac2d..f69e5c6c67b 100644 --- a/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml +++ b/tests/integration/fixtures/scheduler_defer_fifo_simple.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-fifo-simple host: diff --git a/tests/integration/fixtures/scheduler_defer_stress.yaml b/tests/integration/fixtures/scheduler_defer_stress.yaml index 0d9c1d14051..70eac01daf6 100644 --- a/tests/integration/fixtures/scheduler_defer_stress.yaml +++ b/tests/integration/fixtures/scheduler_defer_stress.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-defer-stress-test external_components: diff --git a/tests/integration/fixtures/scheduler_heap_stress.yaml b/tests/integration/fixtures/scheduler_heap_stress.yaml index d4d340b68ba..486a5d12764 100644 --- a/tests/integration/fixtures/scheduler_heap_stress.yaml +++ b/tests/integration/fixtures/scheduler_heap_stress.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-heap-stress-test external_components: diff --git a/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml index 46dbb8e728d..e696e99efa6 100644 --- a/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml +++ b/tests/integration/fixtures/scheduler_internal_id_no_collision.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-internal-id-test on_boot: priority: -100 diff --git a/tests/integration/fixtures/scheduler_null_name.yaml b/tests/integration/fixtures/scheduler_null_name.yaml index 42eaacdd439..d5488761d68 100644 --- a/tests/integration/fixtures/scheduler_null_name.yaml +++ b/tests/integration/fixtures/scheduler_null_name.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-null-name host: diff --git a/tests/integration/fixtures/scheduler_numeric_id_test.yaml b/tests/integration/fixtures/scheduler_numeric_id_test.yaml index 1669f026f5c..25decf20f51 100644 --- a/tests/integration/fixtures/scheduler_numeric_id_test.yaml +++ b/tests/integration/fixtures/scheduler_numeric_id_test.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-numeric-id-test on_boot: priority: -100 diff --git a/tests/integration/fixtures/scheduler_rapid_cancellation.yaml b/tests/integration/fixtures/scheduler_rapid_cancellation.yaml index 4824654c5c6..530b8241f58 100644 --- a/tests/integration/fixtures/scheduler_rapid_cancellation.yaml +++ b/tests/integration/fixtures/scheduler_rapid_cancellation.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-rapid-cancel-test external_components: diff --git a/tests/integration/fixtures/scheduler_recursive_timeout.yaml b/tests/integration/fixtures/scheduler_recursive_timeout.yaml index f1168802f6e..66b6f4b19bb 100644 --- a/tests/integration/fixtures/scheduler_recursive_timeout.yaml +++ b/tests/integration/fixtures/scheduler_recursive_timeout.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-recursive-timeout external_components: diff --git a/tests/integration/fixtures/scheduler_removed_item_race.yaml b/tests/integration/fixtures/scheduler_removed_item_race.yaml index 2f8a7fb987b..55d2197d7ce 100644 --- a/tests/integration/fixtures/scheduler_removed_item_race.yaml +++ b/tests/integration/fixtures/scheduler_removed_item_race.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-removed-item-race host: diff --git a/tests/integration/fixtures/scheduler_retry_test.yaml b/tests/integration/fixtures/scheduler_retry_test.yaml index ffe9082a69f..cdf71152bdc 100644 --- a/tests/integration/fixtures/scheduler_retry_test.yaml +++ b/tests/integration/fixtures/scheduler_retry_test.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-retry-test on_boot: priority: -100 diff --git a/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml b/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml index 446ee7fdc0e..c15edc3ffd5 100644 --- a/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml +++ b/tests/integration/fixtures/scheduler_simultaneous_callbacks.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-simul-callbacks-test external_components: diff --git a/tests/integration/fixtures/scheduler_string_lifetime.yaml b/tests/integration/fixtures/scheduler_string_lifetime.yaml index ebd5052b8bf..5ae5a1914e7 100644 --- a/tests/integration/fixtures/scheduler_string_lifetime.yaml +++ b/tests/integration/fixtures/scheduler_string_lifetime.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: scheduler-string-lifetime-test external_components: diff --git a/tests/integration/fixtures/scheduler_string_name_stress.yaml b/tests/integration/fixtures/scheduler_string_name_stress.yaml index d1ef55c8d5f..8f68d1d1023 100644 --- a/tests/integration/fixtures/scheduler_string_name_stress.yaml +++ b/tests/integration/fixtures/scheduler_string_name_stress.yaml @@ -1,4 +1,5 @@ esphome: + debug_scheduler: true # Enable scheduler leak detection name: sched-string-name-stress external_components: diff --git a/tests/integration/fixtures/template_text_save.yaml b/tests/integration/fixtures/template_text_save.yaml new file mode 100644 index 00000000000..526561732de --- /dev/null +++ b/tests/integration/fixtures/template_text_save.yaml @@ -0,0 +1,23 @@ +esphome: + name: host-template-text-save-test + +host: + +api: + batch_delay: 0ms + +logger: + +preferences: + flash_write_interval: 0s + +text: + - platform: template + name: "Test Text Restore" + id: test_text_restore + optimistic: true + min_length: 0 + max_length: 10 + mode: text + initial_value: "hello" + restore_value: true diff --git a/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml b/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml index 103dbed132f..a69e18888e4 100644 --- a/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml +++ b/tests/integration/fixtures/uart_mock_ld2412_engineering.yaml @@ -102,6 +102,18 @@ uart_mock: 0xF8, 0xF7, 0xF6, 0xF5, ] +# Common filter definitions +.sensor_filters: &sensor_filters + filters: + - timeout: + timeout: 50ms + value: last + - throttle_with_priority: 50ms + +.binary_filters: &binary_filters + filters: + - settle: 50ms + ld2412: id: ld2412_dev uart_id: mock_uart @@ -111,107 +123,56 @@ sensor: ld2412_id: ld2412_dev moving_distance: name: "Moving Distance" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters still_distance: name: "Still Distance" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters moving_energy: name: "Moving Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters still_energy: name: "Still Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters detection_distance: name: "Detection Distance" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters light: name: "Light" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters gate_0: move_energy: name: "Gate 0 Move Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters still_energy: name: "Gate 0 Still Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters gate_1: move_energy: name: "Gate 1 Move Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters still_energy: name: "Gate 1 Still Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters gate_2: move_energy: name: "Gate 2 Move Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters still_energy: name: "Gate 2 Still Energy" - filters: - - timeout: - timeout: 50ms - value: last - - throttle_with_priority: 50ms + <<: *sensor_filters binary_sensor: - platform: ld2412 ld2412_id: ld2412_dev has_target: name: "Has Target" - filters: - - settle: 50ms + <<: *binary_filters has_moving_target: name: "Has Moving Target" - filters: - - settle: 50ms + <<: *binary_filters has_still_target: name: "Has Still Target" - filters: - - settle: 50ms + <<: *binary_filters button: - platform: template diff --git a/tests/integration/fixtures/uart_mock_ld2412_engineering_truncated.yaml b/tests/integration/fixtures/uart_mock_ld2412_engineering_truncated.yaml new file mode 100644 index 00000000000..c0bd5147629 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_engineering_truncated.yaml @@ -0,0 +1,167 @@ +esphome: + name: uart-mock-ld2412-eng-trunc + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + injections: + # Phase 1 (t=100ms): Valid engineering mode frame (52 bytes, buffer_pos_=52) + # Establishes baseline: gate_0_move=100, light=87 + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, + 0x2A, 0x00, + 0x01, 0xAA, + 0x03, + 0x1E, 0x00, + 0x64, + 0x1E, 0x00, + 0x64, + 0x00, 0x00, + 0x64, 0x41, 0x06, 0x0E, 0x2B, 0x16, 0x03, 0x03, 0x07, 0x05, 0x09, 0x08, 0x07, 0x06, + 0x00, 0x00, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64, 0x64, 0x50, 0x40, 0x30, 0x20, 0x10, + 0x57, + 0x55, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 2 (t=200ms): Truncated engineering mode frame (24 bytes, buffer_pos_=24) + # This frame has data_type=0x01 (engineering) but only enough data for the + # basic target fields, not the gate energies or light sensor. + # buffer_pos_=24 passes the old check (>= 12) but fails the new check (< 46). + # Without the fix, indices 17-45 would read stale buffer data from Phase 1. + # + # Layout (24 bytes): + # [0-3] F4 F3 F2 F1 = data frame header + # [4-5] 0E 00 = length 14 + # [6] 01 = data type (engineering mode) + # [7] AA = data header marker + # [8] 03 = target states (moving+still) + # [9-10] 1E 00 = moving distance 30 + # [11] 50 = moving energy 80 + # [12-13] 1E 00 = still distance 30 + # [14] 50 = still energy 80 + # [15-16] FF FF = garbage detection distance bytes + # [17] FF = padding (would be gate data in full frame) + # [18] 55 = data footer marker (at buffer_pos_ - 6) + # [19] 00 = check byte + # [20-23] F8 F7 F6 F5 = data frame footer + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, + 0x0E, 0x00, + 0x01, 0xAA, + 0x03, + 0x1E, 0x00, + 0x50, + 0x1E, 0x00, + 0x50, + 0xFF, 0xFF, + 0xFF, + 0x55, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + + # Phase 3 (t=300ms): Valid recovery frame with different values + # gate_0_move=50, light=42 — proves component recovered + - delay: 100ms + inject_rx: + [ + 0xF4, 0xF3, 0xF2, 0xF1, + 0x2A, 0x00, + 0x01, 0xAA, + 0x03, + 0x1E, 0x00, + 0x64, + 0x1E, 0x00, + 0x64, + 0x00, 0x00, + 0x32, 0x20, 0x06, 0x0E, 0x2B, 0x16, 0x03, 0x03, 0x07, 0x05, 0x09, 0x08, 0x07, 0x06, + 0x00, 0x00, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x32, 0x28, 0x20, 0x18, 0x10, 0x08, + 0x2A, + 0x55, 0x00, + 0xF8, 0xF7, 0xF6, 0xF5, + ] + +# Common filter definitions +.sensor_filters: &sensor_filters + filters: + - timeout: + timeout: 50ms + value: last + - throttle_with_priority: 50ms + +.binary_filters: &binary_filters + filters: + - settle: 50ms + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +sensor: + - platform: ld2412 + ld2412_id: ld2412_dev + moving_distance: + name: "Moving Distance" + <<: *sensor_filters + still_distance: + name: "Still Distance" + <<: *sensor_filters + moving_energy: + name: "Moving Energy" + <<: *sensor_filters + still_energy: + name: "Still Energy" + <<: *sensor_filters + detection_distance: + name: "Detection Distance" + <<: *sensor_filters + light: + name: "Light" + <<: *sensor_filters + gate_0: + move_energy: + name: "Gate 0 Move Energy" + <<: *sensor_filters + still_energy: + name: "Gate 0 Still Energy" + <<: *sensor_filters + +binary_sensor: + - platform: ld2412 + ld2412_id: ld2412_dev + has_target: + name: "Has Target" + <<: *binary_filters + has_moving_target: + name: "Has Moving Target" + <<: *binary_filters + has_still_target: + name: "Has Still Target" + <<: *binary_filters + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2450.yaml b/tests/integration/fixtures/uart_mock_ld2450.yaml index 7354ebf1588..269136da682 100644 --- a/tests/integration/fixtures/uart_mock_ld2450.yaml +++ b/tests/integration/fixtures/uart_mock_ld2450.yaml @@ -93,12 +93,17 @@ uart_mock: - delay: 100ms inject_rx: [0xAA, 0xFF, 0x03, 0x00, 0x01, 0x02, 0x03, 0x04] - # Phase 4 (t=600ms): Overflow - inject 50 bytes of 0xFF (MAX_LINE_LENGTH=45) + # Phase 4 (t=600ms): Overflow - inject 75 bytes of 0xFF (MAX_LINE_LENGTH=45) # Buffer has 15 bytes from phases 2+3. - # Overflow math: need 29 bytes to fill positions 15-43 (buffer_pos_=44), - # then byte 30 at position 44 = MAX_LINE_LENGTH-1 triggers overflow. - # After overflow, buffer_pos_ = 0. Remaining 0xFF bytes accumulate - # until another overflow or until valid data arrives. + # readline_() stores bytes while buffer_pos_ < 44. When buffer_pos_ == 44, + # the next byte triggers overflow: logs warning, resets buffer_pos_ to 0, + # and discards that byte. + # + # First overflow: 29 bytes fill positions 15-43 (buffer_pos_=44), byte 30 + # triggers overflow (discarded). Total consumed: 30 bytes. + # Second overflow: 44 bytes fill positions 0-43 (buffer_pos_=44), byte 45 + # triggers overflow (discarded). Total consumed: 30+45 = 75 bytes. + # After both overflows, buffer_pos_ = 0 (clean state for recovery frame). - delay: 200ms inject_rx: [ @@ -107,6 +112,9 @@ uart_mock: 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, ] # Phase 5 (t=700ms): Valid frame after overflow - recovery test diff --git a/tests/integration/test_online_image_bmp.py b/tests/integration/test_online_image_bmp.py new file mode 100644 index 00000000000..7c32154fdd6 --- /dev/null +++ b/tests/integration/test_online_image_bmp.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# black 8x8 RGB BMP, generated with +# from PIL import Image +# from io import BytesIO +# b = BytesIO() +# img = Image.new("RGB", (8, 8)) +# img.save(b, format="BMP") +# b.getvalue() +BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" +LEN_BMP_IMAGE = len(BMP_IMAGE) + + +def handle_http(http_request_future): + async def handler(reader, writer): + try: + async with asyncio.timeout(1.0): + data = await reader.readuntil(b"\r\n") + + # ensure our request matches the expectation + expected_request = b"GET /foo.bmp HTTP/1.1\r\n" + assert data[: len(expected_request)] == expected_request + + # consume rest of request + async with asyncio.timeout(1.0): + data = await reader.readuntil(b"\r\n\r\n") + + http_request_future.set_result(True) + + http_response = [ + b"HTTP/1.1 200 OK", + b"Content-Length: %d" % LEN_BMP_IMAGE, + b"Content-Type: text/plain", + b"Connection: close", + b"", + b"", + ] + writer.write(b"\r\n".join(http_response)) + await writer.drain() + + writer.write(BMP_IMAGE) + + await writer.drain() + except Exception as exc: + if not http_request_future.done(): + http_request_future.set_exception(exc) + raise + finally: + writer.close() + + return handler + + +@pytest.mark.asyncio +async def test_online_image_bmp( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Esphome shouldn't block the main loop when a http response is slow""" + loop = asyncio.get_running_loop() + + # Track http request + http_request_future = loop.create_future() + download_finished_future = loop.create_future() + downloaded_bytes_future = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + + if match := re.search(r"Image fully downloaded, (\d+) bytes", line): + downloaded_bytes_future.set_result(int(match.group(1))) + + if "download finished" in line: + download_finished_future.set_result(True) + + server = await asyncio.start_server( + handle_http(http_request_future), "127.0.0.1", 0 + ) + http_server_port = server.sockets[0].getsockname()[1] + + config = yaml_config.replace("HTTP_PORT", str(http_server_port)) + + # Run with log monitoring + async with ( + server, + run_compiled(config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "online-image-bmp" + + # List services to find our test service + _, services = await client.list_entities_services() + + # Find test service + request_service = next((s for s in services if s.name == "fetch_image"), None) + + assert request_service is not None, "fetch_image service not found" + + await client.execute_service(request_service, {}) + + async with asyncio.timeout(0.1): + await http_request_future + + async with asyncio.timeout(0.5): + numbytes = await downloaded_bytes_future + assert numbytes == LEN_BMP_IMAGE + await download_finished_future diff --git a/tests/integration/test_template_text_save.py b/tests/integration/test_template_text_save.py new file mode 100644 index 00000000000..47c8e3188ab --- /dev/null +++ b/tests/integration/test_template_text_save.py @@ -0,0 +1,131 @@ +"""Integration test for template text restore_value persistence. + +Tests that: +1. A template text with restore_value saves its value to preferences +2. The saved value persists across restarts (binary re-run) +3. Setting the same value again does not produce a spurious "too long" warning +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import socket +from typing import Any + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + + +@pytest.mark.asyncio +async def test_template_text_save( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> None: + """Test template text save/restore persistence and duplicate-save behavior.""" + port, port_socket = reserved_tcp_port + + # Clean up any stale preference file from previous runs + prefs_file = ( + Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs" + ) + if prefs_file.exists(): + prefs_file.unlink() + + # Write and compile once + config_path = await write_yaml_config(yaml_config) + binary_path = await compile_esphome(config_path) + + # Release the reserved port so the binary can bind to it + port_socket.close() + + # --- First run: set a value and verify no spurious warnings --- + warning_lines: list[str] = [] + + def capture_warnings(line: str) -> None: + if "too long to save" in line.lower(): + warning_lines.append(line) + + async with ( + run_binary_and_wait_for_port( + binary_path, "127.0.0.1", port, line_callback=capture_warnings + ), + wait_and_connect_api_client(port=port) as client, + ): + device_info = await client.device_info() + assert device_info.name == "host-template-text-save-test" + + entities, _ = await client.list_entities_services() + text_entity = require_entity( + entities, "test_text_restore", TextInfo, "Test Text Restore" + ) + + # Set up state tracking + loop = asyncio.get_running_loop() + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + # Verify initial value from config + initial = initial_state_helper.initial_states[text_entity.key] + assert isinstance(initial, TextState) + assert initial.state == "hello" + + async def wait_for_state(key: int, timeout: float = 2.0) -> Any: + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + # Set a new value that fits within max_length + client.text_command(key=text_entity.key, state="world") + state = await wait_for_state(text_entity.key) + assert state.state == "world" + + # Set the same value again - should NOT produce "too long" warning + client.text_command(key=text_entity.key, state="world") + # Give time for the warning to appear (if any) + await asyncio.sleep(0.5) + + # No warnings should have appeared + assert warning_lines == [], ( + f"Unexpected 'too long to save' warning(s): {warning_lines}" + ) + + # --- Second run: verify the value was restored from preferences --- + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + text_entity = require_entity( + entities, "test_text_restore", TextInfo, "Test Text Restore" + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(lambda s: None)) + await initial_state_helper.wait_for_initial_states() + + # The value should be "world" - restored from preferences + restored = initial_state_helper.initial_states[text_entity.key] + assert isinstance(restored, TextState) + assert restored.state == "world", ( + f"Expected restored value 'world', got '{restored.state}'" + ) + + # Clean up preference file + if prefs_file.exists(): + prefs_file.unlink() diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index a964ba00738..9b928ef14f2 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -14,6 +14,12 @@ test_uart_mock_ld2412_engineering (engineering mode): 2. Multi-byte still distance (291cm) using high byte > 0 3. Gate energy sensor values 4. Detection distance computed from target state + +test_uart_mock_ld2412_engineering_truncated (truncated engineering mode): + 1. Valid engineering frame establishes baseline sensor values + 2. Truncated engineering frame (24 bytes) is rejected — gate/light sensors + must not receive garbage from stale buffer data or frame footer bytes + 3. Recovery frame with different values proves the component survived """ from __future__ import annotations @@ -273,3 +279,122 @@ async def test_uart_mock_ld2412_engineering( ) assert pytest.approx(291.0) in collector.sensor_states["detection_distance"] + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_engineering_truncated( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that truncated engineering mode frames don't corrupt sensor values. + + Without the fix, a 24-byte engineering mode frame passes the old buffer_pos_ >= 12 + check but reads indices 17-45 from stale buffer data, publishing garbage values + (e.g. frame footer bytes 0xF8=248 as gate energy). + """ + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + loop = asyncio.get_running_loop() + + # Track the truncated frame warning + truncated_warning_seen = loop.create_future() + + def line_callback(line: str) -> None: + if ( + "Engineering mode packet too short" in line + and not truncated_warning_seen.done() + ): + truncated_warning_seen.set_result(True) + + collector = SensorStateCollector( + sensor_names=[ + "moving_distance", + "still_distance", + "moving_energy", + "still_energy", + "detection_distance", + "light", + "gate_0_move_energy", + "gate_0_still_energy", + ], + binary_sensor_names=[ + "has_target", + "has_moving_target", + "has_still_target", + ], + ) + + # Signal when we see Phase 3 recovery values (gate_0_move=50) + recovery_received = collector.add_waiter( + lambda: pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + collector.build_key_mapping(entities) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states( + initial_state_helper.on_state_wrapper(collector.on_state) + ) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + + # Wait for Phase 1 — valid engineering frame establishes baseline + try: + await collector.wait_for_all(timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for Phase 1 frame. Received:\n" + f" sensor_states: {collector.sensor_states}\n" + f" binary_states: {collector.binary_states}" + ) + + # Phase 1 baseline: gate_0_move=100, light=87 + assert collector.sensor_states["gate_0_move_energy"][0] == pytest.approx(100.0) + assert collector.sensor_states["light"][0] == pytest.approx(87.0) + + # Wait for Phase 3 recovery frame (gate_0_move=50) + try: + await asyncio.wait_for(recovery_received, timeout=3.0) + except TimeoutError: + pytest.fail( + f"Timeout waiting for recovery frame. Received:\n" + f" gate_0_move_energy: {collector.sensor_states['gate_0_move_energy']}\n" + f" light: {collector.sensor_states['light']}" + ) + + # Verify the truncated frame warning was logged + assert truncated_warning_seen.done(), ( + "Expected 'Engineering mode packet too short' warning in logs" + ) + + # Phase 3 recovery: gate_0_move=50, light=42 + assert pytest.approx(50.0) in collector.sensor_states["gate_0_move_energy"] + assert pytest.approx(42.0) in collector.sensor_states["light"] + + # The critical assertion: gate_0_move_energy must never have received + # garbage values from the truncated frame. Without the fix, + # buffer_data_[17] = 0xFF = 255 would be published as gate_0_move. + for value in collector.sensor_states["gate_0_move_energy"]: + assert value == pytest.approx(100.0) or value == pytest.approx(50.0), ( + f"gate_0_move_energy got unexpected value {value} — " + f"truncated frame likely leaked stale buffer data. " + f"All values: {collector.sensor_states['gate_0_move_energy']}" + ) diff --git a/tests/integration/test_uart_mock_ld2450.py b/tests/integration/test_uart_mock_ld2450.py index 91298545051..b1aa2f6952b 100644 --- a/tests/integration/test_uart_mock_ld2450.py +++ b/tests/integration/test_uart_mock_ld2450.py @@ -189,26 +189,16 @@ async def test_uart_mock_ld2450( # Recovery frame values (Phase 5, after overflow): # Target 1: X=300, Y=400, Distance=500, Speed=30 (moving away) # target_count=1, moving=1, still=0 - recovery_idx = next( - i - for i, v in enumerate(collector.sensor_states["target_1_distance"]) - if v == pytest.approx(500.0, abs=1.0) + # + # Note: throttle filters cause sensor lists to have different lengths, + # so we check each value appeared somewhere rather than using a shared index. + assert ( + pytest.approx(500.0, abs=1.0) + in collector.sensor_states["target_1_distance"] ) - assert collector.sensor_states["target_1_x"][recovery_idx] == pytest.approx( - 300.0 - ) - assert collector.sensor_states["target_1_y"][recovery_idx] == pytest.approx( - 400.0 - ) - assert collector.sensor_states["target_1_speed"][recovery_idx] == pytest.approx( - 30.0 - ) - assert collector.sensor_states["target_count"][recovery_idx] == pytest.approx( - 1.0 - ) - assert collector.sensor_states["moving_target_count"][ - recovery_idx - ] == pytest.approx(1.0) - assert collector.sensor_states["still_target_count"][ - recovery_idx - ] == pytest.approx(0.0) + assert pytest.approx(300.0) in collector.sensor_states["target_1_x"] + assert pytest.approx(400.0) in collector.sensor_states["target_1_y"] + assert pytest.approx(30.0) in collector.sensor_states["target_1_speed"] + assert pytest.approx(1.0) in collector.sensor_states["target_count"] + assert pytest.approx(1.0) in collector.sensor_states["moving_target_count"] + assert pytest.approx(0.0) in collector.sensor_states["still_target_count"] diff --git a/tests/integration/test_water_heater_template.py b/tests/integration/test_water_heater_template.py index 096d4c84615..d63d1d69845 100644 --- a/tests/integration/test_water_heater_template.py +++ b/tests/integration/test_water_heater_template.py @@ -102,7 +102,11 @@ async def test_water_heater_template( f"Expected target temp 60.0, got {initial_state.target_temperature}" ) - # Verify supported features: away mode and on/off (fixture has away + is_on lambdas) + # Verify supported features: operation mode, away mode, and on/off + assert ( + test_water_heater.supported_features + & WaterHeaterFeature.SUPPORTS_OPERATION_MODE + ) != 0, "Expected SUPPORTS_OPERATION_MODE in supported_features" assert ( test_water_heater.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE ) != 0, "Expected SUPPORTS_AWAY_MODE in supported_features" diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 61ef8985df9..5c81ad374b1 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -29,9 +29,9 @@ spec.loader.exec_module(determine_jobs) @pytest.fixture -def mock_should_run_integration_tests() -> Generator[Mock, None, None]: - """Mock should_run_integration_tests from helpers.""" - with patch.object(determine_jobs, "should_run_integration_tests") as mock: +def mock_determine_integration_tests() -> Generator[Mock, None, None]: + """Mock determine_integration_tests.""" + with patch.object(determine_jobs, "determine_integration_tests") as mock: yield mock @@ -87,7 +87,7 @@ def clear_determine_jobs_caches() -> None: def test_main_all_tests_should_run( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -100,7 +100,7 @@ def test_main_all_tests_should_run( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = True + mock_determine_integration_tests.return_value = (True, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True @@ -152,6 +152,8 @@ def test_main_all_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is True + assert output["integration_tests_run_all"] is True + assert output["integration_test_files"] == [] assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is True @@ -183,7 +185,7 @@ def test_main_all_tests_should_run( def test_main_no_tests_should_run( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -196,7 +198,7 @@ def test_main_no_tests_should_run( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = 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 @@ -233,6 +235,8 @@ def test_main_no_tests_should_run( output = json.loads(captured.out) assert output["integration_tests"] is False + assert output["integration_tests_run_all"] is False + assert output["integration_test_files"] == [] assert output["clang_tidy"] is False assert output["clang_tidy_mode"] == "disabled" assert output["clang_format"] is False @@ -253,7 +257,7 @@ def test_main_no_tests_should_run( def test_main_with_branch_argument( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -266,7 +270,7 @@ def test_main_with_branch_argument( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = True @@ -302,7 +306,7 @@ def test_main_with_branch_argument( determine_jobs.main() # Check that functions were called with branch - mock_should_run_integration_tests.assert_called_once_with("main") + mock_determine_integration_tests.assert_called_once_with("main") mock_should_run_clang_tidy.assert_called_once_with("main") mock_should_run_clang_format.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main") @@ -312,6 +316,8 @@ def test_main_with_branch_argument( output = json.loads(captured.out) assert output["integration_tests"] is False + assert output["integration_tests_run_all"] is False + assert output["integration_test_files"] == [] assert output["clang_tidy"] is True assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is False @@ -334,30 +340,33 @@ def test_main_with_branch_argument( assert output["cpp_unit_tests_components"] == ["mqtt"] -def test_should_run_integration_tests( +def test_determine_integration_tests( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Test should_run_integration_tests function.""" - # Core C++ files trigger tests + """Test determine_integration_tests function.""" + # Core C++ files trigger run_all with patch.object( determine_jobs, "changed_files", return_value=["esphome/core/component.cpp"] ): - result = determine_jobs.should_run_integration_tests() - assert result is True + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] - # Core Python files trigger tests + # Core Python files trigger run_all with patch.object( determine_jobs, "changed_files", return_value=["esphome/core/config.py"] ): - result = determine_jobs.should_run_integration_tests() - assert result is True + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] # Python files directly in esphome/ do NOT trigger tests with patch.object( determine_jobs, "changed_files", return_value=["esphome/config.py"] ): - result = determine_jobs.should_run_integration_tests() - assert result is False + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [] # Python files in subdirectories (not core) do NOT trigger tests with patch.object( @@ -365,35 +374,151 @@ def test_should_run_integration_tests( "changed_files", return_value=["esphome/dashboard/web_server.py"], ): - result = determine_jobs.should_run_integration_tests() - assert result is False + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [] -def test_should_run_integration_tests_with_branch() -> None: - """Test should_run_integration_tests with branch argument.""" +def test_determine_integration_tests_with_branch() -> None: + """Test determine_integration_tests with branch argument.""" with patch.object(determine_jobs, "changed_files") as mock_changed: mock_changed.return_value = [] - determine_jobs.should_run_integration_tests("release") + run_all, test_files = determine_jobs.determine_integration_tests("release") mock_changed.assert_called_once_with("release") + assert run_all is False + assert test_files == [] -def test_should_run_integration_tests_component_dependency() -> None: - """Test that integration tests run when components used in fixtures change.""" +def test_determine_integration_tests_component_dependency() -> None: + """Test that integration tests return specific test files when components used in fixtures change.""" with ( patch.object( determine_jobs, "changed_files", return_value=["esphome/components/api/api.cpp"], ), + patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map, patch.object( - determine_jobs, "get_components_from_integration_fixtures" - ) as mock_fixtures, + determine_jobs, "get_integration_test_files_for_components" + ) as mock_test_files, ): - mock_fixtures.return_value = {"api", "sensor"} - with patch.object(determine_jobs, "get_all_dependencies") as mock_deps: - mock_deps.return_value = {"api", "sensor", "network"} - result = determine_jobs.should_run_integration_tests() - assert result is True + mock_fixture_map.return_value = {} + mock_test_files.return_value = [ + "tests/integration/test_api.py", + "tests/integration/test_sensor.py", + ] + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [ + "tests/integration/test_api.py", + "tests/integration/test_sensor.py", + ] + + +def test_determine_integration_tests_component_only_affected_tests() -> None: + """Test that only tests using the changed component are returned.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/modbus/modbus.cpp"], + ), + patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}), + patch.object( + determine_jobs, "get_integration_test_files_for_components" + ) as mock_test_files, + ): + mock_test_files.return_value = [ + "tests/integration/test_uart_mock_modbus.py", + ] + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == ["tests/integration/test_uart_mock_modbus.py"] + # Verify it was called with the right component + mock_test_files.assert_called_once_with({"modbus"}) + + +def test_determine_integration_tests_infra_file_runs_all() -> None: + """Test that changing infrastructure files (conftest.py, etc.) runs all tests.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/conftest.py"], + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] + + +def test_determine_integration_tests_readme_does_not_run_all() -> None: + """Test that changing README.md does not trigger integration tests.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/README.md"], + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == [] + + +def test_determine_integration_tests_changed_test_file() -> None: + """Test that changing a specific test file only runs that test.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/test_syslog.py"], + ), + patch.object(determine_jobs, "get_fixture_to_test_files", return_value={}), + patch.object( + determine_jobs, + "get_integration_test_files_for_components", + return_value=[], + ), + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == ["tests/integration/test_syslog.py"] + + +def test_determine_integration_tests_changed_fixture_yaml() -> None: + """Test that changing a fixture YAML runs the corresponding test file.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["tests/integration/fixtures/uart_mock_modbus.yaml"], + ), + patch.object(determine_jobs, "get_fixture_to_test_files") as mock_fixture_map, + patch.object( + determine_jobs, + "get_integration_test_files_for_components", + return_value=[], + ), + ): + mock_fixture_map.return_value = { + "uart_mock_modbus": frozenset( + {"tests/integration/test_uart_mock_modbus.py"} + ), + } + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is False + assert test_files == ["tests/integration/test_uart_mock_modbus.py"] + + +def test_determine_integration_tests_non_yaml_fixture_runs_all() -> None: + """Test that non-YAML changes under fixtures/ (e.g., external_components) run all tests.""" + with patch.object( + determine_jobs, + "changed_files", + return_value=[ + "tests/integration/fixtures/external_components/test_component/__init__.py" + ], + ): + run_all, test_files = determine_jobs.determine_integration_tests() + assert run_all is True + assert test_files == [] @pytest.mark.parametrize( @@ -538,7 +663,7 @@ def test_count_changed_cpp_files_with_branch() -> None: def test_main_filters_components_without_tests( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -551,7 +676,7 @@ def test_main_filters_components_without_tests( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = 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 @@ -631,7 +756,7 @@ def test_main_filters_components_without_tests( def test_main_detects_components_with_variant_tests( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -649,7 +774,7 @@ def test_main_detects_components_with_variant_tests( # Ensure we're not in GITHUB_ACTIONS mode for this test monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = 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 @@ -999,7 +1124,7 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None: def test_clang_tidy_mode_full_scan( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1010,7 +1135,7 @@ def test_clang_tidy_mode_full_scan( """Test that full scan (hash changed) always uses split mode.""" monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False @@ -1065,7 +1190,7 @@ def test_clang_tidy_mode_targeted_scan( component_count: int, files_per_component: int, expected_mode: str, - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1076,7 +1201,7 @@ def test_clang_tidy_mode_targeted_scan( """Test clang-tidy mode selection based on files_to_check count.""" monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = False + mock_determine_integration_tests.return_value = (False, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False @@ -1123,7 +1248,7 @@ def test_clang_tidy_mode_targeted_scan( def test_main_core_files_changed_still_detects_components( - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1135,7 +1260,7 @@ def test_main_core_files_changed_still_detects_components( """Test that component changes are detected even when core files change.""" monkeypatch.delenv("GITHUB_ACTIONS", raising=False) - mock_should_run_integration_tests.return_value = True + mock_determine_integration_tests.return_value = (True, []) mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True @@ -1604,7 +1729,7 @@ def test_detect_platform_hint_from_filename_case_insensitive( def test_component_batching_beta_branch_40_per_batch( tmp_path: Path, - mock_should_run_integration_tests: Mock, + mock_determine_integration_tests: Mock, mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, @@ -1628,7 +1753,7 @@ def test_component_batching_beta_branch_40_per_batch( (comp_dir / "test.esp32-idf.yaml").write_text(f"# Test for {comp}") # Setup mocks - mock_should_run_integration_tests.return_value = 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 diff --git a/tests/script/test_helpers.py b/tests/script/test_helpers.py index 7e60ba41fcd..e3802d2d51f 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -36,6 +36,7 @@ def clear_helpers_cache() -> None: """Clear cached functions before each test.""" helpers._get_github_event_data.cache_clear() helpers._get_changed_files_github_actions.cache_clear() + helpers.get_components_per_integration_fixture.cache_clear() @pytest.mark.parametrize( @@ -1027,6 +1028,73 @@ def test_get_all_dependencies_empty_set() -> None: assert result == set() +def test_get_all_dependencies_platform_component() -> None: + """Platform components (domain.component) are looked up via get_platform, + not get_component.""" + platform_comp = Mock() + platform_comp.dependencies = [] + platform_comp.auto_load = [] + + with ( + patch("esphome.loader.get_component") as mock_get_component, + patch("helpers.get_platform") as mock_get_platform, + ): + mock_get_platform.return_value = platform_comp + mock_get_component.return_value = None + + result = helpers.get_all_dependencies({"sensor.bthome"}) + + mock_get_platform.assert_called_once_with("sensor", "bthome") + mock_get_component.assert_not_called() + assert result == {"sensor.bthome"} + + +def test_get_all_dependencies_platform_component_with_dependencies() -> None: + """Dependencies of a platform component are resolved transitively.""" + platform_comp = Mock() + platform_comp.dependencies = ["sensor"] + platform_comp.auto_load = [] + + sensor_comp = Mock() + sensor_comp.dependencies = [] + sensor_comp.auto_load = [] + + with ( + patch("esphome.loader.get_component") as mock_get_component, + patch("helpers.get_platform") as mock_get_platform, + ): + mock_get_platform.return_value = platform_comp + mock_get_component.side_effect = lambda name: ( + sensor_comp if name == "sensor" else None + ) + + result = helpers.get_all_dependencies({"sensor.bthome"}) + + assert result == {"sensor.bthome", "sensor"} + + +def test_get_all_dependencies_cpp_testing_flag() -> None: + """cpp_testing=True propagates to CORE.cpp_testing during resolution.""" + from esphome.core import CORE + + with ( + patch("esphome.loader.get_component") as mock_get_component, + patch("esphome.loader.get_platform"), + ): + observed: list[bool] = [] + + def capturing_get_component(name: str): + observed.append(CORE.cpp_testing) + + mock_get_component.side_effect = capturing_get_component + + helpers.get_all_dependencies({"some_comp"}, cpp_testing=True) + + assert observed and all(observed), ( + "CORE.cpp_testing should be True during resolution" + ) + + def test_get_components_from_integration_fixtures() -> None: """Test extraction of components from fixture YAML files.""" yaml_content = { @@ -1044,7 +1112,7 @@ def test_get_components_from_integration_fixtures() -> None: "gpio", } - mock_yaml_file = Mock() + mock_yaml_file = Mock(stem="test_fixture") with ( patch("pathlib.Path.glob") as mock_glob, @@ -1057,6 +1125,55 @@ def test_get_components_from_integration_fixtures() -> None: assert components == expected_components +def test_get_components_from_integration_fixtures_skips_yaml_anchors() -> None: + """Test that YAML anchor keys (starting with '.') are excluded.""" + yaml_content = { + "sensor": [{"platform": "template", "name": "test"}], + "esphome": {"name": "test"}, + ".sensor_filters": {"filters": [{"timeout": "50ms"}]}, + ".binary_filters": {"filters": [{"settle": "50ms"}]}, + } + + mock_yaml_file = Mock(stem="test_fixture") + + with ( + patch("pathlib.Path.glob") as mock_glob, + patch("esphome.yaml_util.load_yaml", return_value=yaml_content), + ): + mock_glob.return_value = [mock_yaml_file] + + components = helpers.get_components_from_integration_fixtures() + + assert ".sensor_filters" not in components + assert ".binary_filters" not in components + assert components == {"sensor", "esphome", "template"} + + +def test_get_integration_test_files_for_components_real_fixtures() -> None: + """Test that component changes map to the correct real integration test files. + + This test uses real fixtures to verify the mapping stays correct + as new tests are added. + """ + # modbus should include at least the modbus test + modbus_tests = helpers.get_integration_test_files_for_components({"modbus"}) + assert "tests/integration/test_uart_mock_modbus.py" in modbus_tests + + # ld2410 should include at least the ld2410 test + ld2410_tests = helpers.get_integration_test_files_for_components({"ld2410"}) + assert "tests/integration/test_uart_mock_ld2410.py" in ld2410_tests + + # syslog should include at least the syslog test + syslog_tests = helpers.get_integration_test_files_for_components({"syslog"}) + assert "tests/integration/test_syslog.py" in syslog_tests + + # A component not used by any fixture should return nothing + fake_tests = helpers.get_integration_test_files_for_components( + {"nonexistent_component_xyz"} + ) + assert fake_tests == [] + + @pytest.mark.parametrize( "output,expected", [ diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..0922a5238e8 --- /dev/null +++ b/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestrp2040pico2ard + friendly_name: $component_name + +rp2040: + board: rpipico2 + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml new file mode 100644 index 00000000000..205beb6e1bb --- /dev/null +++ b/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml @@ -0,0 +1,12 @@ +# Common SPI configuration for RP2040 Pico 2 (RP2350) Arduino tests + +substitutions: + clk_pin: GPIO18 + mosi_pin: GPIO19 + miso_pin: GPIO16 + +spi: + - id: spi_bus + clk_pin: ${clk_pin} + mosi_pin: ${mosi_pin} + miso_pin: ${miso_pin} diff --git a/tests/unit_tests/analyze_memory/__init__.py b/tests/unit_tests/analyze_memory/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/analyze_memory/test_ci_memory_impact_comment.py b/tests/unit_tests/analyze_memory/test_ci_memory_impact_comment.py new file mode 100644 index 00000000000..8399ac0303c --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ci_memory_impact_comment.py @@ -0,0 +1,99 @@ +"""Tests for script/ci_memory_impact_comment.py symbol matching.""" + +from pathlib import Path +import sys + +# Add script directory to path so we can import the module +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "script")) + +from ci_memory_impact_comment import prepare_symbol_changes_data # noqa: E402 + + +def test_prepare_symbol_changes_signature_match() -> None: + """Symbols with same base name but different args are matched as changed.""" + target = { + "Foo::bar(std::vector&, int)": 300, + "unchanged()": 50, + } + pr = { + "Foo::bar(ProtoByteBuffer&, int)": 320, + "unchanged()": 50, + } + result = prepare_symbol_changes_data(target, pr) + assert result is not None + assert len(result["changed_symbols"]) == 1 + assert len(result["new_symbols"]) == 0 + assert len(result["removed_symbols"]) == 0 + sym, t_size, p_size, delta = result["changed_symbols"][0] + assert sym == "Foo::bar(ProtoByteBuffer&, int)" + assert t_size == 300 + assert p_size == 320 + assert delta == 20 + + +def test_prepare_symbol_changes_ambiguous_overloads_not_matched() -> None: + """Multiple overloads with same base name stay as new/removed.""" + target = { + "Foo::bar(int)": 100, + "Foo::bar(float)": 200, + } + pr = { + "Foo::bar(double)": 150, + "Foo::bar(long)": 250, + } + result = prepare_symbol_changes_data(target, pr) + assert result is not None + assert len(result["changed_symbols"]) == 0 + assert len(result["new_symbols"]) == 2 + assert len(result["removed_symbols"]) == 2 + + +def test_prepare_symbol_changes_no_parens_not_matched() -> None: + """Symbols without parens (variables) are not fuzzy-matched.""" + target = {"my_global_var": 100} + pr = {"my_global_var_v2": 120} + result = prepare_symbol_changes_data(target, pr) + assert result is not None + assert len(result["changed_symbols"]) == 0 + assert len(result["new_symbols"]) == 1 + assert len(result["removed_symbols"]) == 1 + + +def test_prepare_symbol_changes_nested_symbols_matched_separately() -> None: + """Nested symbols like ::__pstr__ don't collide with parent function.""" + target = { + "Foo::bar(std::vector&, int)": 300, + "Foo::bar(std::vector&, int)::__pstr__": 19, + } + pr = { + "Foo::bar(ProtoByteBuffer&, int)": 320, + "Foo::bar(ProtoByteBuffer&, int)::__pstr__": 19, + } + result = prepare_symbol_changes_data(target, pr) + assert result is not None + # Both the function and its nested __pstr__ should be matched (not new/removed) + assert len(result["new_symbols"]) == 0 + assert len(result["removed_symbols"]) == 0 + # __pstr__ has delta=0 so it's silently dropped, only the function shows + assert len(result["changed_symbols"]) == 1 + sym, t_size, p_size, delta = result["changed_symbols"][0] + assert sym == "Foo::bar(ProtoByteBuffer&, int)" + assert delta == 20 + + +def test_prepare_symbol_changes_exact_match_preferred() -> None: + """Exact name matches are found before fuzzy matching runs.""" + target = { + "Foo::bar(int)": 100, + } + pr = { + "Foo::bar(int)": 120, + } + result = prepare_symbol_changes_data(target, pr) + assert result is not None + assert len(result["changed_symbols"]) == 1 + assert len(result["new_symbols"]) == 0 + assert len(result["removed_symbols"]) == 0 + sym, t_size, p_size, delta = result["changed_symbols"][0] + assert sym == "Foo::bar(int)" + assert delta == 20 diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 174b3fec85d..22be59653aa 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -841,6 +841,18 @@ class TestEsphomeCore: assert "WiFi" in target.platformio_libraries + def test_testing_ensure_platform_registered__sets_count(self, target): + """Test testing_ensure_platform_registered sets count to 1 for new platform.""" + assert target.platform_counts["sensor"] == 0 + target.testing_ensure_platform_registered("sensor") + assert target.platform_counts["sensor"] == 1 + + def test_testing_ensure_platform_registered__does_not_overwrite(self, target): + """Test testing_ensure_platform_registered preserves existing count.""" + target.platform_counts["sensor"] = 3 + target.testing_ensure_platform_registered("sensor") + assert target.platform_counts["sensor"] == 3 + 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_main.py b/tests/unit_tests/test_main.py index 8183c26fc8f..b8534611517 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -76,6 +76,7 @@ from esphome.const import ( PLATFORM_RP2040, ) from esphome.core import CORE, EsphomeError +from esphome.util import BootselResult def strip_ansi_codes(text: str) -> str: @@ -875,7 +876,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=1), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)), ): result = choose_upload_log_host( default=None, @@ -898,7 +899,7 @@ def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None: patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=0), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), pytest.raises(EsphomeError, match="BOOTSEL"), ): choose_upload_log_host( @@ -923,7 +924,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota( patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=0), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100", @@ -952,7 +953,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool"), ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=0), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(0)), caplog.at_level(logging.INFO, logger="esphome.__main__"), ): choose_upload_log_host( @@ -963,6 +964,69 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( assert "BOOTSEL" in caplog.text +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_permission_error_no_options( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test permission warning shown when BOOTSEL device found but not accessible.""" + setup_core(platform=PLATFORM_RP2040) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch( + "esphome.__main__.detect_rp2040_bootsel", + return_value=BootselResult(0, permission_error=True), + ), + patch("esphome.__main__.sys.platform", "linux"), + pytest.raises(EsphomeError, match="BOOTSEL"), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "USB permissions" in caplog.text + assert "udev" in caplog.text + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_rp2040_permission_error_with_ota( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test permission warning shown with OTA fallback available.""" + setup_core( + platform=PLATFORM_RP2040, + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, + address="192.168.1.100", + ) + + with ( + patch( + "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") + ), + patch( + "esphome.__main__.detect_rp2040_bootsel", + return_value=BootselResult(0, permission_error=True), + ), + patch( + "esphome.__main__.choose_prompt", + return_value="192.168.1.100", + ), + caplog.at_level(logging.WARNING, logger="esphome.__main__"), + ): + choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.UPLOADING, + ) + + assert "USB permissions" in caplog.text + + def test_choose_upload_log_host_no_bootsel_for_non_rp2040( mock_no_serial_ports: Mock, ) -> None: @@ -1000,7 +1064,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel( patch( "esphome.__main__._find_picotool", return_value=Path("/usr/bin/picotool") ), - patch("esphome.__main__.detect_rp2040_bootsel", return_value=1), + patch("esphome.__main__.detect_rp2040_bootsel", return_value=BootselResult(1)), ): choose_upload_log_host( default=None, @@ -3658,38 +3722,38 @@ esp32: assert "secrets.yaml" not in summary_section -def test_get_configured_xtal_freq_reads_sdkconfig(setup_core: Path) -> None: +def test_get_configured_xtal_freq_reads_sdkconfig(tmp_path: Path) -> None: """Test reading XTAL_FREQ from sdkconfig.""" CORE.name = "test-device" - CORE.build_path = setup_core - sdkconfig = setup_core / "sdkconfig.test-device" + CORE.build_path = tmp_path + sdkconfig = tmp_path / "sdkconfig.test-device" sdkconfig.write_text( "CONFIG_SOC_XTAL_SUPPORT_26M=y\nCONFIG_XTAL_FREQ=26\nCONFIG_XTAL_FREQ_26=y\n" ) assert _get_configured_xtal_freq() == 26 -def test_get_configured_xtal_freq_default_40(setup_core: Path) -> None: +def test_get_configured_xtal_freq_default_40(tmp_path: Path) -> None: """Test reading default 40MHz XTAL_FREQ from sdkconfig.""" CORE.name = "test-device" - CORE.build_path = setup_core - sdkconfig = setup_core / "sdkconfig.test-device" + CORE.build_path = tmp_path + sdkconfig = tmp_path / "sdkconfig.test-device" sdkconfig.write_text("CONFIG_XTAL_FREQ=40\nCONFIG_XTAL_FREQ_40=y\n") assert _get_configured_xtal_freq() == 40 -def test_get_configured_xtal_freq_missing_file(setup_core: Path) -> None: +def test_get_configured_xtal_freq_missing_file(tmp_path: Path) -> None: """Test that missing sdkconfig returns None.""" CORE.name = "test-device" - CORE.build_path = setup_core + CORE.build_path = tmp_path assert _get_configured_xtal_freq() is None -def test_get_configured_xtal_freq_no_xtal_line(setup_core: Path) -> None: +def test_get_configured_xtal_freq_no_xtal_line(tmp_path: Path) -> None: """Test that sdkconfig without XTAL_FREQ returns None.""" CORE.name = "test-device" - CORE.build_path = setup_core - sdkconfig = setup_core / "sdkconfig.test-device" + CORE.build_path = tmp_path + sdkconfig = tmp_path / "sdkconfig.test-device" sdkconfig.write_text("CONFIG_OTHER=123\n") assert _get_configured_xtal_freq() is None diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 16861442777..e1b3908c249 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -673,6 +673,34 @@ def test_process_stacktrace_bad_alloc( assert state is False +def test_process_stacktrace_esp32_crash_handler( + setup_core: Path, mock_decode_pc: Mock +) -> None: + """Test process_stacktrace handles ESP32 crash handler backtrace lines.""" + config = {"name": "test"} + + # Simulate crash handler log lines as they appear from the API/serial + line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)" + state = platformio_api.process_stacktrace(config, line_pc, False) + # PC line is matched by existing STACKTRACE_ESP32_PC_RE + mock_decode_pc.assert_called_with(config, "400D1234") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt0, False) + mock_decode_pc.assert_called_once_with(config, "400D5678") + assert state is False + + mock_decode_pc.reset_mock() + + line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)" + state = platformio_api.process_stacktrace(config, line_bt1, False) + mock_decode_pc.assert_called_once_with(config, "42005ABC") + assert state is False + + def test_patch_file_downloader_succeeds_first_try() -> None: """Test patch_file_downloader succeeds on first attempt.""" mock_exception_cls = type("PackageException", (Exception,), {}) diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 6faa3be7e65..ff58fb13944 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import io from pathlib import Path import subprocess @@ -409,103 +410,9 @@ def test_shlex_quote_edge_cases() -> None: assert util.shlex_quote(" ") == "' '" -def test_get_picotool_path_found(tmp_path: Path) -> None: - """Test picotool path derivation from cc_path.""" - # Create the expected directory structure - packages_dir = tmp_path / "packages" - toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" - toolchain_dir.mkdir(parents=True) - gcc = toolchain_dir / "arm-none-eabi-gcc" - gcc.touch() - - binary_name = "picotool.exe" if sys.platform == "win32" else "picotool" - picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" - picotool_dir.mkdir(parents=True) - picotool = picotool_dir / binary_name - picotool.touch() - - result = util.get_picotool_path(str(gcc)) - assert result == picotool - - -def test_get_picotool_path_not_found(tmp_path: Path) -> None: - """Test picotool path returns None when not installed.""" - packages_dir = tmp_path / "packages" - toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" - toolchain_dir.mkdir(parents=True) - gcc = toolchain_dir / "arm-none-eabi-gcc" - gcc.touch() - - result = util.get_picotool_path(str(gcc)) - assert result is None - - -def test_get_picotool_path_windows(tmp_path: Path) -> None: - """Test picotool path uses .exe on Windows.""" - packages_dir = tmp_path / "packages" - toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" - toolchain_dir.mkdir(parents=True) - gcc = toolchain_dir / "arm-none-eabi-gcc.exe" - gcc.touch() - - picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" - picotool_dir.mkdir(parents=True) - picotool = picotool_dir / "picotool.exe" - picotool.touch() - - with patch("esphome.util.sys.platform", "win32"): - result = util.get_picotool_path(str(gcc)) - assert result == picotool - - -def test_detect_rp2040_bootsel_found() -> None: - """Test BOOTSEL device detection when device is present.""" - mock_result = MagicMock() - mock_result.stdout = b"Device Information\n type: RP2040\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): - count = util.detect_rp2040_bootsel("/usr/bin/picotool") - assert count == 1 - - -def test_detect_rp2040_bootsel_multiple() -> None: - """Test BOOTSEL detection with multiple devices.""" - mock_result = MagicMock() - mock_result.stdout = b"type: RP2040\ntype: RP2350\n" - with patch("esphome.util.subprocess.run", return_value=mock_result): - count = util.detect_rp2040_bootsel("/usr/bin/picotool") - assert count == 2 - - -def test_detect_rp2040_bootsel_none() -> None: - """Test BOOTSEL detection when no device found.""" - mock_result = MagicMock() - mock_result.stdout = ( - b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n" - ) - with patch("esphome.util.subprocess.run", return_value=mock_result): - count = util.detect_rp2040_bootsel("/usr/bin/picotool") - assert count == 0 - - -def test_detect_rp2040_bootsel_oserror() -> None: - """Test BOOTSEL detection handles OSError.""" - with patch("esphome.util.subprocess.run", side_effect=OSError("not found")): - count = util.detect_rp2040_bootsel("/usr/bin/picotool") - assert count == 0 - - -def test_detect_rp2040_bootsel_timeout() -> None: - """Test BOOTSEL detection handles timeout.""" - with patch( - "esphome.util.subprocess.run", - side_effect=subprocess.TimeoutExpired("picotool", 10), - ): - count = util.detect_rp2040_bootsel("/usr/bin/picotool") - assert count == 0 - - def _make_redirect( - line_callbacks: list | None = None, filter_lines: list[str] | None = None + line_callbacks: list[Callable[[str], str | None]] | None = None, + filter_lines: list[str] | None = None, ) -> tuple[util.RedirectText, io.StringIO]: """Create a RedirectText that writes to a StringIO buffer.""" buf = io.StringIO() @@ -673,3 +580,132 @@ def test_run_external_process_line_callbacks() -> None: assert rc == 0 assert any("from subprocess" in r for r in results) + + +def test_get_picotool_path_found(tmp_path: Path) -> None: + """Test picotool path derivation from cc_path.""" + # Create the expected directory structure + packages_dir = tmp_path / "packages" + toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_dir.mkdir(parents=True) + gcc = toolchain_dir / "arm-none-eabi-gcc" + gcc.touch() + + binary_name = "picotool.exe" if sys.platform == "win32" else "picotool" + picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" + picotool_dir.mkdir(parents=True) + picotool = picotool_dir / binary_name + picotool.touch() + + result = util.get_picotool_path(str(gcc)) + assert result == picotool + + +def test_get_picotool_path_not_found(tmp_path: Path) -> None: + """Test picotool path returns None when not installed.""" + packages_dir = tmp_path / "packages" + toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_dir.mkdir(parents=True) + gcc = toolchain_dir / "arm-none-eabi-gcc" + gcc.touch() + + result = util.get_picotool_path(str(gcc)) + assert result is None + + +def test_get_picotool_path_windows(tmp_path: Path) -> None: + """Test picotool path uses .exe on Windows.""" + packages_dir = tmp_path / "packages" + toolchain_dir = packages_dir / "toolchain-rp2040-earlephilhower" / "bin" + toolchain_dir.mkdir(parents=True) + gcc = toolchain_dir / "arm-none-eabi-gcc.exe" + gcc.touch() + + picotool_dir = packages_dir / "tool-picotool-rp2040-earlephilhower" + picotool_dir.mkdir(parents=True) + picotool = picotool_dir / "picotool.exe" + picotool.touch() + + with patch("esphome.util.sys.platform", "win32"): + result = util.get_picotool_path(str(gcc)) + assert result == picotool + + +def test_detect_rp2040_bootsel_found() -> None: + """Test BOOTSEL device detection when device is present.""" + mock_result = MagicMock() + mock_result.stdout = b"Device Information\n type: RP2040\n" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 1 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_multiple() -> None: + """Test BOOTSEL detection with multiple devices.""" + mock_result = MagicMock() + mock_result.stdout = b"type: RP2040\ntype: RP2350\n" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 2 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_none() -> None: + """Test BOOTSEL detection when no device found.""" + mock_result = MagicMock() + mock_result.stdout = ( + b"No accessible RP2040/RP2350 devices in BOOTSEL mode were found.\n" + ) + mock_result.stderr = b"" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_permission_error() -> None: + """Test BOOTSEL detection with device found but not accessible.""" + mock_result = MagicMock() + mock_result.stdout = ( + b"No accessible RP-series devices in BOOTSEL mode were found.\n" + ) + mock_result.stderr = ( + b"RP2040 device at bus 5, address 24 appears to be in BOOTSEL mode, " + b"but picotool was unable to connect. " + b"Maybe try 'sudo' or check your permissions.\n" + ) + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is True + + +def test_detect_rp2040_bootsel_libusb_access_error() -> None: + """Test BOOTSEL detection with LIBUSB_ERROR_ACCESS.""" + mock_result = MagicMock() + mock_result.stdout = b"" + mock_result.stderr = b"LIBUSB_ERROR_ACCESS\n" + with patch("esphome.util.subprocess.run", return_value=mock_result): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is True + + +def test_detect_rp2040_bootsel_oserror() -> None: + """Test BOOTSEL detection handles OSError.""" + with patch("esphome.util.subprocess.run", side_effect=OSError("not found")): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False + + +def test_detect_rp2040_bootsel_timeout() -> None: + """Test BOOTSEL detection handles timeout.""" + with patch( + "esphome.util.subprocess.run", + side_effect=subprocess.TimeoutExpired("picotool", 10), + ): + result = util.detect_rp2040_bootsel("/usr/bin/picotool") + assert result.device_count == 0 + assert result.permission_error is False diff --git a/tests/unit_tests/test_voluptuous_schema.py b/tests/unit_tests/test_voluptuous_schema.py new file mode 100644 index 00000000000..21c7decede1 --- /dev/null +++ b/tests/unit_tests/test_voluptuous_schema.py @@ -0,0 +1,86 @@ +"""Tests for voluptuous_schema.py.""" + +import pytest +import voluptuous as vol + +from esphome.voluptuous_schema import _Schema + + +class TestIdKeyDropping: + """Test that 'id' keys are silently dropped in PREVENT_EXTRA schemas.""" + + def test_id_key_silently_dropped(self): + """Schema without 'id' should accept and drop 'id' key from input.""" + schema = _Schema( + { + vol.Required("name"): str, + vol.Optional("value", default=0): int, + } + ) + result = schema({"name": "test", "value": 42, "id": "my_id"}) + assert result == {"name": "test", "value": 42} + assert "id" not in result + + def test_id_key_dropped_with_only_required(self): + """Schema with only required keys should still drop 'id'.""" + schema = _Schema( + { + vol.Required("source"): str, + } + ) + result = schema({"source": "github://test", "id": "my_component"}) + assert result == {"source": "github://test"} + + def test_other_extra_keys_still_rejected(self): + """Non-'id' extra keys should still raise errors.""" + schema = _Schema( + { + vol.Required("name"): str, + } + ) + with pytest.raises(vol.MultipleInvalid, match="extra keys not allowed"): + schema({"name": "test", "unknown_key": "value"}) + + def test_id_key_not_dropped_when_in_schema(self): + """When 'id' is declared in the schema, it should be validated normally.""" + schema = _Schema( + { + vol.Required("id"): str, + vol.Required("name"): str, + } + ) + result = schema({"id": "my_id", "name": "test"}) + assert result == {"id": "my_id", "name": "test"} + + def test_id_key_not_dropped_with_allow_extra(self): + """With ALLOW_EXTRA, 'id' should be kept (not dropped).""" + schema = _Schema( + { + vol.Required("name"): str, + }, + extra=vol.ALLOW_EXTRA, + ) + result = schema({"name": "test", "id": "my_id"}) + assert result == {"name": "test", "id": "my_id"} + + def test_id_key_dropped_with_remove_extra(self): + """With REMOVE_EXTRA, 'id' should be removed along with other extras.""" + schema = _Schema( + { + vol.Required("name"): str, + }, + extra=vol.REMOVE_EXTRA, + ) + result = schema({"name": "test", "id": "my_id", "other": "value"}) + assert result == {"name": "test"} + + def test_without_id_no_extra_keys(self): + """Normal validation without 'id' key should work as before.""" + schema = _Schema( + { + vol.Required("name"): str, + vol.Optional("value", default=0): int, + } + ) + result = schema({"name": "test"}) + assert result == {"name": "test", "value": 0}