mirror of
				https://github.com/esphome/esphome.git
				synced 2025-11-04 00:51:49 +00:00 
			
		
		
		
	Compare commits
	
		
			4 Commits
		
	
	
		
			jesserockz
			...
			copilot/fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					9476a4b1ce | ||
| 
						 | 
					8d2078275d | ||
| 
						 | 
					12db82e03a | ||
| 
						 | 
					331d98830a | 
@@ -1,222 +0,0 @@
 | 
			
		||||
# ESPHome AI Collaboration Guide
 | 
			
		||||
 | 
			
		||||
This document provides essential context for AI models interacting with this project. Adhering to these guidelines will ensure consistency and maintain code quality.
 | 
			
		||||
 | 
			
		||||
## 1. Project Overview & Purpose
 | 
			
		||||
 | 
			
		||||
*   **Primary Goal:** ESPHome is a system to configure microcontrollers (like ESP32, ESP8266, RP2040, and LibreTiny-based chips) using simple yet powerful YAML configuration files. It generates C++ firmware that can be compiled and flashed to these devices, allowing users to control them remotely through home automation systems.
 | 
			
		||||
*   **Business Domain:** Internet of Things (IoT), Home Automation.
 | 
			
		||||
 | 
			
		||||
## 2. Core Technologies & Stack
 | 
			
		||||
 | 
			
		||||
*   **Languages:** Python (>=3.10), C++ (gnu++20)
 | 
			
		||||
*   **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF.
 | 
			
		||||
*   **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative.
 | 
			
		||||
*   **Configuration:** YAML.
 | 
			
		||||
*   **Key Libraries/Dependencies:**
 | 
			
		||||
    *   **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `tornado` (for the web server), `aioesphomeapi` (for the native API).
 | 
			
		||||
    *   **C++:** `ArduinoJson` (for JSON serialization/deserialization), `AsyncMqttClient-esphome` (for MQTT), `ESPAsyncWebServer` (for the web server).
 | 
			
		||||
*   **Package Manager(s):** `pip` (for Python dependencies), `platformio` (for C++/PlatformIO dependencies).
 | 
			
		||||
*   **Communication Protocols:** Protobuf (for native API), MQTT, HTTP.
 | 
			
		||||
 | 
			
		||||
## 3. Architectural Patterns
 | 
			
		||||
 | 
			
		||||
*   **Overall Architecture:** The project follows a code-generation architecture. The Python code parses user-defined YAML configuration files and generates C++ source code. This C++ code is then compiled and flashed to the target microcontroller using PlatformIO.
 | 
			
		||||
 | 
			
		||||
*   **Directory Structure Philosophy:**
 | 
			
		||||
    *   `/esphome`: Contains the core Python source code for the ESPHome application.
 | 
			
		||||
    *   `/esphome/components`: Contains the individual components that can be used in ESPHome configurations. Each component is a self-contained unit with its own C++ and Python code.
 | 
			
		||||
    *   `/tests`: Contains all unit and integration tests for the Python code.
 | 
			
		||||
    *   `/docker`: Contains Docker-related files for building and running ESPHome in a container.
 | 
			
		||||
    *   `/script`: Contains helper scripts for development and maintenance.
 | 
			
		||||
 | 
			
		||||
*   **Core Architectural Components:**
 | 
			
		||||
    1.  **Configuration System** (`esphome/config*.py`): Handles YAML parsing and validation using Voluptuous, schema definitions, and multi-platform configurations.
 | 
			
		||||
    2.  **Code Generation** (`esphome/codegen.py`, `esphome/cpp_generator.py`): Manages Python to C++ code generation, template processing, and build flag management.
 | 
			
		||||
    3.  **Component System** (`esphome/components/`): Contains modular hardware and software components with platform-specific implementations and dependency management.
 | 
			
		||||
    4.  **Core Framework** (`esphome/core/`): Manages the application lifecycle, hardware abstraction, and component registration.
 | 
			
		||||
    5.  **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates.
 | 
			
		||||
 | 
			
		||||
*   **Platform Support:**
 | 
			
		||||
    1.  **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (S2, S3, C3, etc.) and both IDF and Arduino frameworks.
 | 
			
		||||
    2.  **ESP8266** (`components/esp8266/`): Espressif ESP8266. Arduino framework only, with memory constraints.
 | 
			
		||||
    3.  **RP2040** (`components/rp2040/`): Raspberry Pi Pico/RP2040. Arduino framework with PIO (Programmable I/O) support.
 | 
			
		||||
    4.  **LibreTiny** (`components/libretiny/`): Realtek and Beken chips. Supports multiple chip families and auto-generated components.
 | 
			
		||||
 | 
			
		||||
## 4. Coding Conventions & Style Guide
 | 
			
		||||
 | 
			
		||||
*   **Formatting:**
 | 
			
		||||
    *   **Python:** Uses `ruff` and `flake8` for linting and formatting. Configuration is in `pyproject.toml`.
 | 
			
		||||
    *   **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`.
 | 
			
		||||
 | 
			
		||||
*   **Naming Conventions:**
 | 
			
		||||
    *   **Python:** Follows PEP 8. Use clear, descriptive names following snake_case.
 | 
			
		||||
    *   **C++:** Follows the Google C++ Style Guide.
 | 
			
		||||
 | 
			
		||||
*   **Component Structure:**
 | 
			
		||||
    *   **Standard Files:**
 | 
			
		||||
        ```
 | 
			
		||||
        components/[component_name]/
 | 
			
		||||
        ├── __init__.py          # Component configuration schema and code generation
 | 
			
		||||
        ├── [component].h        # C++ header file (if needed)
 | 
			
		||||
        ├── [component].cpp      # C++ implementation (if needed)
 | 
			
		||||
        └── [platform]/         # Platform-specific implementations
 | 
			
		||||
            ├── __init__.py      # Platform-specific configuration
 | 
			
		||||
            ├── [platform].h     # Platform C++ header
 | 
			
		||||
            └── [platform].cpp   # Platform C++ implementation
 | 
			
		||||
        ```
 | 
			
		||||
 | 
			
		||||
    *   **Component Metadata:**
 | 
			
		||||
        - `DEPENDENCIES`: List of required components
 | 
			
		||||
        - `AUTO_LOAD`: Components to automatically load
 | 
			
		||||
        - `CONFLICTS_WITH`: Incompatible components
 | 
			
		||||
        - `CODEOWNERS`: GitHub usernames responsible for maintenance
 | 
			
		||||
        - `MULTI_CONF`: Whether multiple instances are allowed
 | 
			
		||||
 | 
			
		||||
*   **Code Generation & Common Patterns:**
 | 
			
		||||
    *   **Configuration Schema Pattern:**
 | 
			
		||||
        ```python
 | 
			
		||||
        import esphome.codegen as cg
 | 
			
		||||
        import esphome.config_validation as cv
 | 
			
		||||
        from esphome.const import CONF_KEY, CONF_ID
 | 
			
		||||
 | 
			
		||||
        CONF_PARAM = "param"  # A constant that does not yet exist in esphome/const.py
 | 
			
		||||
 | 
			
		||||
        my_component_ns = cg.esphome_ns.namespace("my_component")
 | 
			
		||||
        MyComponent = my_component_ns.class_("MyComponent", cg.Component)
 | 
			
		||||
 | 
			
		||||
        CONFIG_SCHEMA = cv.Schema({
 | 
			
		||||
            cv.GenerateID(): cv.declare_id(MyComponent),
 | 
			
		||||
            cv.Required(CONF_KEY): cv.string,
 | 
			
		||||
            cv.Optional(CONF_PARAM, default=42): cv.int_,
 | 
			
		||||
        }).extend(cv.COMPONENT_SCHEMA)
 | 
			
		||||
 | 
			
		||||
        async def to_code(config):
 | 
			
		||||
            var = cg.new_Pvariable(config[CONF_ID])
 | 
			
		||||
            await cg.register_component(var, config)
 | 
			
		||||
            cg.add(var.set_key(config[CONF_KEY]))
 | 
			
		||||
            cg.add(var.set_param(config[CONF_PARAM]))
 | 
			
		||||
        ```
 | 
			
		||||
 | 
			
		||||
    *   **C++ Class Pattern:**
 | 
			
		||||
        ```cpp
 | 
			
		||||
        namespace esphome {
 | 
			
		||||
        namespace my_component {
 | 
			
		||||
 | 
			
		||||
        class MyComponent : public Component {
 | 
			
		||||
         public:
 | 
			
		||||
          void setup() override;
 | 
			
		||||
          void loop() override;
 | 
			
		||||
          void dump_config() override;
 | 
			
		||||
 | 
			
		||||
          void set_key(const std::string &key) { this->key_ = key; }
 | 
			
		||||
          void set_param(int param) { this->param_ = param; }
 | 
			
		||||
 | 
			
		||||
         protected:
 | 
			
		||||
          std::string key_;
 | 
			
		||||
          int param_{0};
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        }  // namespace my_component
 | 
			
		||||
        }  // namespace esphome
 | 
			
		||||
        ```
 | 
			
		||||
 | 
			
		||||
    *   **Common Component Examples:**
 | 
			
		||||
        - **Sensor:**
 | 
			
		||||
          ```python
 | 
			
		||||
          from esphome.components import sensor
 | 
			
		||||
          CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
 | 
			
		||||
          async def to_code(config):
 | 
			
		||||
              var = await sensor.new_sensor(config)
 | 
			
		||||
              await cg.register_component(var, config)
 | 
			
		||||
          ```
 | 
			
		||||
 | 
			
		||||
        - **Binary Sensor:**
 | 
			
		||||
          ```python
 | 
			
		||||
          from esphome.components import binary_sensor
 | 
			
		||||
          CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
 | 
			
		||||
          async def to_code(config):
 | 
			
		||||
              var = await binary_sensor.new_binary_sensor(config)
 | 
			
		||||
          ```
 | 
			
		||||
 | 
			
		||||
        - **Switch:**
 | 
			
		||||
          ```python
 | 
			
		||||
          from esphome.components import switch
 | 
			
		||||
          CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
 | 
			
		||||
          async def to_code(config):
 | 
			
		||||
              var = await switch.new_switch(config)
 | 
			
		||||
          ```
 | 
			
		||||
 | 
			
		||||
*   **Configuration Validation:**
 | 
			
		||||
    *   **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
 | 
			
		||||
    *   **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
 | 
			
		||||
    *   **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `cv.only_with_arduino`.
 | 
			
		||||
    *   **Schema Extensions:**
 | 
			
		||||
        ```python
 | 
			
		||||
        CONFIG_SCHEMA = cv.Schema({ ... })
 | 
			
		||||
         .extend(cv.COMPONENT_SCHEMA)
 | 
			
		||||
         .extend(uart.UART_DEVICE_SCHEMA)
 | 
			
		||||
         .extend(i2c.i2c_device_schema(0x48))
 | 
			
		||||
         .extend(spi.spi_device_schema(cs_pin_required=True))
 | 
			
		||||
        ```
 | 
			
		||||
 | 
			
		||||
## 5. Key Files & Entrypoints
 | 
			
		||||
 | 
			
		||||
*   **Main Entrypoint(s):** `esphome/__main__.py` is the main entrypoint for the ESPHome command-line interface.
 | 
			
		||||
*   **Configuration:**
 | 
			
		||||
    *   `pyproject.toml`: Defines the Python project metadata and dependencies.
 | 
			
		||||
    *   `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers.
 | 
			
		||||
    *   `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting.
 | 
			
		||||
*   **CI/CD Pipeline:** Defined in `.github/workflows`.
 | 
			
		||||
 | 
			
		||||
## 6. Development & Testing Workflow
 | 
			
		||||
 | 
			
		||||
*   **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`.
 | 
			
		||||
*   **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`.
 | 
			
		||||
*   **Testing:**
 | 
			
		||||
    *   **Python:** Run unit tests with `pytest`.
 | 
			
		||||
    *   **C++:** Use `clang-tidy` for static analysis.
 | 
			
		||||
    *   **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
 | 
			
		||||
        ```
 | 
			
		||||
        tests/
 | 
			
		||||
        ├── test_build_components/ # Base test configurations
 | 
			
		||||
        └── components/[component]/ # Component-specific tests
 | 
			
		||||
        ```
 | 
			
		||||
        Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
 | 
			
		||||
*   **Debugging and Troubleshooting:**
 | 
			
		||||
    *   **Debug Tools:**
 | 
			
		||||
        - `esphome config <file>.yaml` to validate configuration.
 | 
			
		||||
        - `esphome compile <file>.yaml` to compile without uploading.
 | 
			
		||||
        - Check the Dashboard for real-time logs.
 | 
			
		||||
        - Use component-specific debug logging.
 | 
			
		||||
    *   **Common Issues:**
 | 
			
		||||
        - **Import Errors**: Check component dependencies and `PYTHONPATH`.
 | 
			
		||||
        - **Validation Errors**: Review configuration schema definitions.
 | 
			
		||||
        - **Build Errors**: Check platform compatibility and library versions.
 | 
			
		||||
        - **Runtime Errors**: Review generated C++ code and component logic.
 | 
			
		||||
 | 
			
		||||
## 7. Specific Instructions for AI Collaboration
 | 
			
		||||
 | 
			
		||||
*   **Contribution Workflow (Pull Request Process):**
 | 
			
		||||
    1.  **Fork & Branch:** Create a new branch in your fork.
 | 
			
		||||
    2.  **Make Changes:** Adhere to all coding conventions and patterns.
 | 
			
		||||
    3.  **Test:** Create component tests for all supported platforms and run the full test suite locally.
 | 
			
		||||
    4.  **Lint:** Run `pre-commit` to ensure code is compliant.
 | 
			
		||||
    5.  **Commit:** Commit your changes. There is no strict format for commit messages.
 | 
			
		||||
    6.  **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made with the PULL_REQUEST_TEMPLATE.md template filled out correctly.
 | 
			
		||||
 | 
			
		||||
*   **Documentation Contributions:**
 | 
			
		||||
    *   Documentation is hosted in the separate `esphome/esphome-docs` repository.
 | 
			
		||||
    *   The contribution workflow is the same as for the codebase.
 | 
			
		||||
 | 
			
		||||
*   **Best Practices:**
 | 
			
		||||
    *   **Component Development:** Keep dependencies minimal, provide clear error messages, and write comprehensive docstrings and tests.
 | 
			
		||||
    *   **Code Generation:** Generate minimal and efficient C++ code. Validate all user inputs thoroughly. Support multiple platform variations.
 | 
			
		||||
    *   **Configuration Design:** Aim for simplicity with sensible defaults, while allowing for advanced customization.
 | 
			
		||||
 | 
			
		||||
*   **Security:** Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys.
 | 
			
		||||
 | 
			
		||||
*   **Dependencies & Build System Integration:**
 | 
			
		||||
    *   **Python:** When adding a new Python dependency, add it to the appropriate `requirements*.txt` file and `pyproject.toml`.
 | 
			
		||||
    *   **C++ / PlatformIO:** When adding a new C++ dependency, add it to `platformio.ini` and use `cg.add_library`.
 | 
			
		||||
    *   **Build Flags:** Use `cg.add_build_flag(...)` to add compiler flags.
 | 
			
		||||
@@ -1 +1 @@
 | 
			
		||||
0c2acbc16bfb7d63571dbe7042f94f683be25e4ca8a0f158a960a94adac4b931
 | 
			
		||||
07f621354fe1350ba51953c80273cd44a04aa44f15cc30bd7b8fe2a641427b7a
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
								
							@@ -26,7 +26,6 @@
 | 
			
		||||
- [ ] RP2040
 | 
			
		||||
- [ ] BK72xx
 | 
			
		||||
- [ ] RTL87xx
 | 
			
		||||
- [ ] nRF52840
 | 
			
		||||
 | 
			
		||||
## Example entry for `config.yaml`:
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
								
							@@ -1 +0,0 @@
 | 
			
		||||
../.ai/instructions.md
 | 
			
		||||
							
								
								
									
										3
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
								
							@@ -305,7 +305,8 @@ jobs:
 | 
			
		||||
              const { data: codeownersFile } = await github.rest.repos.getContent({
 | 
			
		||||
                owner,
 | 
			
		||||
                repo,
 | 
			
		||||
                path: 'CODEOWNERS',
 | 
			
		||||
                path: '.github/CODEOWNERS',
 | 
			
		||||
                ref: context.payload.pull_request.head.sha
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										264
									
								
								.github/workflows/codeowner-review-request.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										264
									
								
								.github/workflows/codeowner-review-request.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,264 +0,0 @@
 | 
			
		||||
# This workflow automatically requests reviews from codeowners when:
 | 
			
		||||
# 1. A PR is opened, reopened, or synchronized (updated)
 | 
			
		||||
# 2. A PR is marked as ready for review
 | 
			
		||||
#
 | 
			
		||||
# It reads the CODEOWNERS file and matches all changed files in the PR against
 | 
			
		||||
# the codeowner patterns, then requests reviews from the appropriate owners
 | 
			
		||||
# while avoiding duplicate requests for users who have already been requested
 | 
			
		||||
# or have already reviewed the PR.
 | 
			
		||||
 | 
			
		||||
name: Request Codeowner Reviews
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  # Needs to be pull_request_target to get write permissions
 | 
			
		||||
  pull_request_target:
 | 
			
		||||
    types: [opened, reopened, synchronize, ready_for_review]
 | 
			
		||||
 | 
			
		||||
permissions:
 | 
			
		||||
  pull-requests: write
 | 
			
		||||
  contents: read
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  request-codeowner-reviews:
 | 
			
		||||
    name: Run
 | 
			
		||||
    if: ${{ !github.event.pull_request.draft }}
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Request reviews from component codeowners
 | 
			
		||||
        uses: actions/github-script@v7.0.1
 | 
			
		||||
        with:
 | 
			
		||||
          script: |
 | 
			
		||||
            const owner = context.repo.owner;
 | 
			
		||||
            const repo = context.repo.repo;
 | 
			
		||||
            const pr_number = context.payload.pull_request.number;
 | 
			
		||||
 | 
			
		||||
            console.log(`Processing PR #${pr_number} for codeowner review requests`);
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
              // Get the list of changed files in this PR
 | 
			
		||||
              const { data: files } = await github.rest.pulls.listFiles({
 | 
			
		||||
                owner,
 | 
			
		||||
                repo,
 | 
			
		||||
                pull_number: pr_number
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              const changedFiles = files.map(file => file.filename);
 | 
			
		||||
              console.log(`Found ${changedFiles.length} changed files`);
 | 
			
		||||
 | 
			
		||||
              if (changedFiles.length === 0) {
 | 
			
		||||
                console.log('No changed files found, skipping codeowner review requests');
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // Fetch CODEOWNERS file from root
 | 
			
		||||
              const { data: codeownersFile } = await github.rest.repos.getContent({
 | 
			
		||||
                owner,
 | 
			
		||||
                repo,
 | 
			
		||||
                path: 'CODEOWNERS',
 | 
			
		||||
                ref: context.payload.pull_request.base.sha
 | 
			
		||||
              });
 | 
			
		||||
              const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
 | 
			
		||||
 | 
			
		||||
              // Parse CODEOWNERS file to extract all patterns and their owners
 | 
			
		||||
              const codeownersLines = codeownersContent.split('\n')
 | 
			
		||||
                .map(line => line.trim())
 | 
			
		||||
                .filter(line => line && !line.startsWith('#'));
 | 
			
		||||
 | 
			
		||||
              const codeownersPatterns = [];
 | 
			
		||||
 | 
			
		||||
              // Convert CODEOWNERS pattern to regex (robust glob handling)
 | 
			
		||||
              function globToRegex(pattern) {
 | 
			
		||||
                // Escape regex special characters except for glob wildcards
 | 
			
		||||
                let regexStr = pattern
 | 
			
		||||
                  .replace(/([.+^=!:${}()|[\]\\])/g, '\\$1') // escape regex chars
 | 
			
		||||
                  .replace(/\*\*/g, '.*') // globstar
 | 
			
		||||
                  .replace(/\*/g, '[^/]*') // single star
 | 
			
		||||
                  .replace(/\?/g, '.'); // question mark
 | 
			
		||||
                return new RegExp('^' + regexStr + '$');
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // Helper function to create comment body
 | 
			
		||||
              function createCommentBody(reviewersList, teamsList, matchedFileCount, isSuccessful = true) {
 | 
			
		||||
                const reviewerMentions = reviewersList.map(r => `@${r}`);
 | 
			
		||||
                const teamMentions = teamsList.map(t => `@${owner}/${t}`);
 | 
			
		||||
                const allMentions = [...reviewerMentions, ...teamMentions].join(', ');
 | 
			
		||||
 | 
			
		||||
                if (isSuccessful) {
 | 
			
		||||
                  return `👋 Hi there! I've automatically requested reviews from codeowners based on the files changed in this PR.\n\n${allMentions} - You've been requested to review this PR as codeowner(s) of ${matchedFileCount} file(s) that were modified. Thanks for your time! 🙏`;
 | 
			
		||||
                } else {
 | 
			
		||||
                  return `👋 Hi there! This PR modifies ${matchedFileCount} file(s) with codeowners.\n\n${allMentions} - As codeowner(s) of the affected files, your review would be appreciated! 🙏\n\n_Note: Automatic review request may have failed, but you're still welcome to review._`;
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              for (const line of codeownersLines) {
 | 
			
		||||
                const parts = line.split(/\s+/);
 | 
			
		||||
                if (parts.length < 2) continue;
 | 
			
		||||
 | 
			
		||||
                const pattern = parts[0];
 | 
			
		||||
                const owners = parts.slice(1);
 | 
			
		||||
 | 
			
		||||
                // Use robust glob-to-regex conversion
 | 
			
		||||
                const regex = globToRegex(pattern);
 | 
			
		||||
                codeownersPatterns.push({ pattern, regex, owners });
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              console.log(`Parsed ${codeownersPatterns.length} codeowner patterns`);
 | 
			
		||||
 | 
			
		||||
              // Match changed files against CODEOWNERS patterns
 | 
			
		||||
              const matchedOwners = new Set();
 | 
			
		||||
              const matchedTeams = new Set();
 | 
			
		||||
              const fileMatches = new Map(); // Track which files matched which patterns
 | 
			
		||||
 | 
			
		||||
              for (const file of changedFiles) {
 | 
			
		||||
                for (const { pattern, regex, owners } of codeownersPatterns) {
 | 
			
		||||
                  if (regex.test(file)) {
 | 
			
		||||
                    console.log(`File '${file}' matches pattern '${pattern}' with owners: ${owners.join(', ')}`);
 | 
			
		||||
 | 
			
		||||
                    if (!fileMatches.has(file)) {
 | 
			
		||||
                      fileMatches.set(file, []);
 | 
			
		||||
                    }
 | 
			
		||||
                    fileMatches.get(file).push({ pattern, owners });
 | 
			
		||||
 | 
			
		||||
                    // Add owners to the appropriate set (remove @ prefix)
 | 
			
		||||
                    for (const owner of owners) {
 | 
			
		||||
                      const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner;
 | 
			
		||||
                      if (cleanOwner.includes('/')) {
 | 
			
		||||
                        // Team mention (org/team-name)
 | 
			
		||||
                        const teamName = cleanOwner.split('/')[1];
 | 
			
		||||
                        matchedTeams.add(teamName);
 | 
			
		||||
                      } else {
 | 
			
		||||
                        // Individual user
 | 
			
		||||
                        matchedOwners.add(cleanOwner);
 | 
			
		||||
                      }
 | 
			
		||||
                    }
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              if (matchedOwners.size === 0 && matchedTeams.size === 0) {
 | 
			
		||||
                console.log('No codeowners found for any changed files');
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // Remove the PR author from reviewers
 | 
			
		||||
              const prAuthor = context.payload.pull_request.user.login;
 | 
			
		||||
              matchedOwners.delete(prAuthor);
 | 
			
		||||
 | 
			
		||||
              // Get current reviewers to avoid duplicate requests (but still mention them)
 | 
			
		||||
              const { data: prData } = await github.rest.pulls.get({
 | 
			
		||||
                owner,
 | 
			
		||||
                repo,
 | 
			
		||||
                pull_number: pr_number
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              const currentReviewers = new Set();
 | 
			
		||||
              const currentTeams = new Set();
 | 
			
		||||
 | 
			
		||||
              if (prData.requested_reviewers) {
 | 
			
		||||
                prData.requested_reviewers.forEach(reviewer => {
 | 
			
		||||
                  currentReviewers.add(reviewer.login);
 | 
			
		||||
                });
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              if (prData.requested_teams) {
 | 
			
		||||
                prData.requested_teams.forEach(team => {
 | 
			
		||||
                  currentTeams.add(team.slug);
 | 
			
		||||
                });
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // Check for completed reviews to avoid re-requesting users who have already reviewed
 | 
			
		||||
              const { data: reviews } = await github.rest.pulls.listReviews({
 | 
			
		||||
                owner,
 | 
			
		||||
                repo,
 | 
			
		||||
                pull_number: pr_number
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              const reviewedUsers = new Set();
 | 
			
		||||
              reviews.forEach(review => {
 | 
			
		||||
                reviewedUsers.add(review.user.login);
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              // Remove only users who have already submitted reviews (not just requested reviewers)
 | 
			
		||||
              reviewedUsers.forEach(reviewer => {
 | 
			
		||||
                matchedOwners.delete(reviewer);
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              // For teams, we'll still remove already requested teams to avoid API errors
 | 
			
		||||
              currentTeams.forEach(team => {
 | 
			
		||||
                matchedTeams.delete(team);
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              const reviewersList = Array.from(matchedOwners);
 | 
			
		||||
              const teamsList = Array.from(matchedTeams);
 | 
			
		||||
 | 
			
		||||
              if (reviewersList.length === 0 && teamsList.length === 0) {
 | 
			
		||||
                console.log('No eligible reviewers found (all may already be requested or reviewed)');
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              const totalReviewers = reviewersList.length + teamsList.length;
 | 
			
		||||
              console.log(`Requesting reviews from ${reviewersList.length} users and ${teamsList.length} teams for ${fileMatches.size} matched files`);
 | 
			
		||||
 | 
			
		||||
              // Request reviews
 | 
			
		||||
              try {
 | 
			
		||||
                const requestParams = {
 | 
			
		||||
                  owner,
 | 
			
		||||
                  repo,
 | 
			
		||||
                  pull_number: pr_number
 | 
			
		||||
                };
 | 
			
		||||
 | 
			
		||||
                // Filter out users who are already requested reviewers for the API call
 | 
			
		||||
                const newReviewers = reviewersList.filter(reviewer => !currentReviewers.has(reviewer));
 | 
			
		||||
                const newTeams = teamsList.filter(team => !currentTeams.has(team));
 | 
			
		||||
 | 
			
		||||
                if (newReviewers.length > 0) {
 | 
			
		||||
                  requestParams.reviewers = newReviewers;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (newTeams.length > 0) {
 | 
			
		||||
                  requestParams.team_reviewers = newTeams;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Only make the API call if there are new reviewers to request
 | 
			
		||||
                if (newReviewers.length > 0 || newTeams.length > 0) {
 | 
			
		||||
                  await github.rest.pulls.requestReviewers(requestParams);
 | 
			
		||||
                  console.log(`Successfully requested reviews from ${newReviewers.length} new users and ${newTeams.length} new teams`);
 | 
			
		||||
                } else {
 | 
			
		||||
                  console.log('All codeowners are already requested reviewers or have reviewed');
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Add a comment to the PR mentioning what happened (include all matched codeowners)
 | 
			
		||||
                const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true);
 | 
			
		||||
 | 
			
		||||
                await github.rest.issues.createComment({
 | 
			
		||||
                  owner,
 | 
			
		||||
                  repo,
 | 
			
		||||
                  issue_number: pr_number,
 | 
			
		||||
                  body: commentBody
 | 
			
		||||
                });
 | 
			
		||||
              } catch (error) {
 | 
			
		||||
                if (error.status === 422) {
 | 
			
		||||
                  console.log('Some reviewers may already be requested or unavailable:', error.message);
 | 
			
		||||
 | 
			
		||||
                  // Try to add a comment even if review request failed
 | 
			
		||||
                  const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false);
 | 
			
		||||
 | 
			
		||||
                  try {
 | 
			
		||||
                    await github.rest.issues.createComment({
 | 
			
		||||
                      owner,
 | 
			
		||||
                      repo,
 | 
			
		||||
                      issue_number: pr_number,
 | 
			
		||||
                      body: commentBody
 | 
			
		||||
                    });
 | 
			
		||||
                  } catch (commentError) {
 | 
			
		||||
                    console.log('Failed to add comment:', commentError.message);
 | 
			
		||||
                  }
 | 
			
		||||
                } else {
 | 
			
		||||
                  throw error;
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
              console.log('Failed to process codeowner review requests:', error.message);
 | 
			
		||||
              console.error(error);
 | 
			
		||||
            }
 | 
			
		||||
							
								
								
									
										147
									
								
								.github/workflows/external-component-bot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										147
									
								
								.github/workflows/external-component-bot.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,147 +0,0 @@
 | 
			
		||||
name: Add External Component Comment
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  pull_request_target:
 | 
			
		||||
    types: [opened, synchronize]
 | 
			
		||||
 | 
			
		||||
permissions:
 | 
			
		||||
  contents: read       #  Needed to fetch PR details
 | 
			
		||||
  issues: write        #  Needed to create and update comments (PR comments are managed via the issues REST API)
 | 
			
		||||
  pull-requests: write  # also needed?
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  external-comment:
 | 
			
		||||
    name: External component comment
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Add external component comment
 | 
			
		||||
        uses: actions/github-script@v7.0.1
 | 
			
		||||
        with:
 | 
			
		||||
          github-token: ${{ secrets.GITHUB_TOKEN }}
 | 
			
		||||
          script: |
 | 
			
		||||
            // Generate external component usage instructions
 | 
			
		||||
            function generateExternalComponentInstructions(prNumber, componentNames, owner, repo) {
 | 
			
		||||
                let source;
 | 
			
		||||
                if (owner === 'esphome' && repo === 'esphome')
 | 
			
		||||
                    source = `github://pr#${prNumber}`;
 | 
			
		||||
                else
 | 
			
		||||
                    source = `github://${owner}/${repo}@pull/${prNumber}/head`;
 | 
			
		||||
                return `To use the changes from this PR as an external component, add the following to your ESPHome configuration YAML file:
 | 
			
		||||
 | 
			
		||||
            \`\`\`yaml
 | 
			
		||||
            external_components:
 | 
			
		||||
              - source: ${source}
 | 
			
		||||
                components: [${componentNames.join(', ')}]
 | 
			
		||||
                refresh: 1h
 | 
			
		||||
            \`\`\``;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Generate repo clone instructions
 | 
			
		||||
            function generateRepoInstructions(prNumber, owner, repo, branch) {
 | 
			
		||||
                return `To use the changes in this PR:
 | 
			
		||||
 | 
			
		||||
            \`\`\`bash
 | 
			
		||||
            # Clone the repository:
 | 
			
		||||
            git clone https://github.com/${owner}/${repo}
 | 
			
		||||
            cd ${repo}
 | 
			
		||||
 | 
			
		||||
            # Checkout the PR branch:
 | 
			
		||||
            git fetch origin pull/${prNumber}/head:${branch}
 | 
			
		||||
            git checkout ${branch}
 | 
			
		||||
 | 
			
		||||
            # Install the development version:
 | 
			
		||||
            script/setup
 | 
			
		||||
 | 
			
		||||
            # Activate the development version:
 | 
			
		||||
            source venv/bin/activate
 | 
			
		||||
            \`\`\`
 | 
			
		||||
 | 
			
		||||
            Now you can run \`esphome\` as usual to test the changes in this PR.
 | 
			
		||||
            `;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            async function createComment(octokit, owner, repo, prNumber, esphomeChanges, componentChanges) {
 | 
			
		||||
                const commentMarker = "<!-- This comment was generated automatically by a GitHub workflow. -->";
 | 
			
		||||
                let commentBody;
 | 
			
		||||
                if (esphomeChanges.length === 1) {
 | 
			
		||||
                    commentBody = generateExternalComponentInstructions(prNumber, componentChanges, owner, repo);
 | 
			
		||||
                } else {
 | 
			
		||||
                    commentBody = generateRepoInstructions(prNumber, owner, repo, context.payload.pull_request.head.ref);
 | 
			
		||||
                }
 | 
			
		||||
                commentBody += `\n\n---\n(Added by the PR bot)\n\n${commentMarker}`;
 | 
			
		||||
 | 
			
		||||
                // Check for existing bot comment
 | 
			
		||||
                const comments = await github.rest.issues.listComments({
 | 
			
		||||
                    owner: owner,
 | 
			
		||||
                    repo: repo,
 | 
			
		||||
                    issue_number: prNumber,
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                const botComment = comments.data.find(comment =>
 | 
			
		||||
                    comment.body.includes(commentMarker)
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                if (botComment && botComment.body === commentBody) {
 | 
			
		||||
                    // No changes in the comment, do nothing
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (botComment) {
 | 
			
		||||
                    // Update existing comment
 | 
			
		||||
                    await github.rest.issues.updateComment({
 | 
			
		||||
                        owner: owner,
 | 
			
		||||
                        repo: repo,
 | 
			
		||||
                        comment_id: botComment.id,
 | 
			
		||||
                        body: commentBody,
 | 
			
		||||
                    });
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Create new comment
 | 
			
		||||
                    await github.rest.issues.createComment({
 | 
			
		||||
                        owner: owner,
 | 
			
		||||
                        repo: repo,
 | 
			
		||||
                        issue_number: prNumber,
 | 
			
		||||
                        body: commentBody,
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) {
 | 
			
		||||
                const changedFiles = await github.rest.pulls.listFiles({
 | 
			
		||||
                    owner: owner,
 | 
			
		||||
                    repo: repo,
 | 
			
		||||
                    pull_number: prNumber,
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                const esphomeChanges = changedFiles.data
 | 
			
		||||
                    .filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/'))
 | 
			
		||||
                    .map(file => {
 | 
			
		||||
                        const match = file.filename.match(/esphome\/([^/]+)/);
 | 
			
		||||
                        return match ? match[1] : null;
 | 
			
		||||
                    })
 | 
			
		||||
                    .filter(it => it !== null);
 | 
			
		||||
 | 
			
		||||
                if (esphomeChanges.length === 0) {
 | 
			
		||||
                    return {esphomeChanges: [], componentChanges: []};
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const uniqueEsphomeChanges = [...new Set(esphomeChanges)];
 | 
			
		||||
                const componentChanges = changedFiles.data
 | 
			
		||||
                    .filter(file => file.filename.startsWith('esphome/components/'))
 | 
			
		||||
                    .map(file => {
 | 
			
		||||
                        const match = file.filename.match(/esphome\/components\/([^/]+)\//);
 | 
			
		||||
                        return match ? match[1] : null;
 | 
			
		||||
                    })
 | 
			
		||||
                    .filter(it => it !== null);
 | 
			
		||||
 | 
			
		||||
                return {esphomeChanges: uniqueEsphomeChanges, componentChanges: [...new Set(componentChanges)]};
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Start of main code.
 | 
			
		||||
 | 
			
		||||
            const prNumber = context.payload.pull_request.number;
 | 
			
		||||
            const {owner, repo} = context.repo;
 | 
			
		||||
 | 
			
		||||
            const {esphomeChanges, componentChanges} = await getEsphomeAndComponentChanges(github, owner, repo, prNumber);
 | 
			
		||||
            if (componentChanges.length !== 0) {
 | 
			
		||||
                await createComment(github, owner, repo, prNumber, esphomeChanges, componentChanges);
 | 
			
		||||
            }
 | 
			
		||||
							
								
								
									
										119
									
								
								.github/workflows/issue-codeowner-notify.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										119
									
								
								.github/workflows/issue-codeowner-notify.yml
									
									
									
									
										vendored
									
									
								
							@@ -1,119 +0,0 @@
 | 
			
		||||
# This workflow automatically notifies codeowners when an issue is labeled with component labels.
 | 
			
		||||
# It reads the CODEOWNERS file to find the maintainers for the labeled components
 | 
			
		||||
# and posts a comment mentioning them to ensure they're aware of the issue.
 | 
			
		||||
 | 
			
		||||
name: Notify Issue Codeowners
 | 
			
		||||
 | 
			
		||||
on:
 | 
			
		||||
  issues:
 | 
			
		||||
    types: [labeled]
 | 
			
		||||
 | 
			
		||||
permissions:
 | 
			
		||||
  issues: write
 | 
			
		||||
  contents: read
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  notify-codeowners:
 | 
			
		||||
    name: Run
 | 
			
		||||
    if: ${{ startsWith(github.event.label.name, format('component{0} ', ':')) }}
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Notify codeowners for component issues
 | 
			
		||||
        uses: actions/github-script@v7.0.1
 | 
			
		||||
        with:
 | 
			
		||||
          script: |
 | 
			
		||||
            const owner = context.repo.owner;
 | 
			
		||||
            const repo = context.repo.repo;
 | 
			
		||||
            const issue_number = context.payload.issue.number;
 | 
			
		||||
            const labelName = context.payload.label.name;
 | 
			
		||||
 | 
			
		||||
            console.log(`Processing issue #${issue_number} with label: ${labelName}`);
 | 
			
		||||
 | 
			
		||||
            // Extract component name from label
 | 
			
		||||
            const componentName = labelName.replace('component: ', '');
 | 
			
		||||
            console.log(`Component: ${componentName}`);
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
              // Fetch CODEOWNERS file from root
 | 
			
		||||
              const { data: codeownersFile } = await github.rest.repos.getContent({
 | 
			
		||||
                owner,
 | 
			
		||||
                repo,
 | 
			
		||||
                path: 'CODEOWNERS'
 | 
			
		||||
              });
 | 
			
		||||
              const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8');
 | 
			
		||||
 | 
			
		||||
              // Parse CODEOWNERS file to extract component mappings
 | 
			
		||||
              const codeownersLines = codeownersContent.split('\n')
 | 
			
		||||
                .map(line => line.trim())
 | 
			
		||||
                .filter(line => line && !line.startsWith('#'));
 | 
			
		||||
 | 
			
		||||
              let componentOwners = null;
 | 
			
		||||
 | 
			
		||||
              for (const line of codeownersLines) {
 | 
			
		||||
                const parts = line.split(/\s+/);
 | 
			
		||||
                if (parts.length < 2) continue;
 | 
			
		||||
 | 
			
		||||
                const pattern = parts[0];
 | 
			
		||||
                const owners = parts.slice(1);
 | 
			
		||||
 | 
			
		||||
                // Look for component patterns: esphome/components/{component}/*
 | 
			
		||||
                const componentMatch = pattern.match(/^esphome\/components\/([^\/]+)\/\*$/);
 | 
			
		||||
                if (componentMatch && componentMatch[1] === componentName) {
 | 
			
		||||
                  componentOwners = owners;
 | 
			
		||||
                  break;
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              if (!componentOwners) {
 | 
			
		||||
                console.log(`No codeowners found for component: ${componentName}`);
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              console.log(`Found codeowners for '${componentName}': ${componentOwners.join(', ')}`);
 | 
			
		||||
 | 
			
		||||
              // Separate users and teams
 | 
			
		||||
              const userOwners = [];
 | 
			
		||||
              const teamOwners = [];
 | 
			
		||||
 | 
			
		||||
              for (const owner of componentOwners) {
 | 
			
		||||
                const cleanOwner = owner.startsWith('@') ? owner.slice(1) : owner;
 | 
			
		||||
                if (cleanOwner.includes('/')) {
 | 
			
		||||
                  // Team mention (org/team-name)
 | 
			
		||||
                  teamOwners.push(`@${cleanOwner}`);
 | 
			
		||||
                } else {
 | 
			
		||||
                  // Individual user
 | 
			
		||||
                  userOwners.push(`@${cleanOwner}`);
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // Remove issue author from mentions to avoid self-notification
 | 
			
		||||
              const issueAuthor = context.payload.issue.user.login;
 | 
			
		||||
              const filteredUserOwners = userOwners.filter(mention =>
 | 
			
		||||
                mention !== `@${issueAuthor}`
 | 
			
		||||
              );
 | 
			
		||||
 | 
			
		||||
              const allMentions = [...filteredUserOwners, ...teamOwners];
 | 
			
		||||
 | 
			
		||||
              if (allMentions.length === 0) {
 | 
			
		||||
                console.log('No codeowners to notify (issue author is the only codeowner)');
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              // Create comment body
 | 
			
		||||
              const mentionString = allMentions.join(', ');
 | 
			
		||||
              const commentBody = `👋 Hey ${mentionString}!\n\nThis issue has been labeled with \`component: ${componentName}\` and you've been identified as a codeowner of this component. Please take a look when you have a chance!\n\nThanks for maintaining this component! 🙏`;
 | 
			
		||||
 | 
			
		||||
              // Post comment
 | 
			
		||||
              await github.rest.issues.createComment({
 | 
			
		||||
                owner,
 | 
			
		||||
                repo,
 | 
			
		||||
                issue_number: issue_number,
 | 
			
		||||
                body: commentBody
 | 
			
		||||
              });
 | 
			
		||||
 | 
			
		||||
              console.log(`Successfully notified codeowners: ${mentionString}`);
 | 
			
		||||
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
              console.log('Failed to process codeowner notifications:', error.message);
 | 
			
		||||
              console.error(error);
 | 
			
		||||
            }
 | 
			
		||||
@@ -11,7 +11,7 @@ ci:
 | 
			
		||||
repos:
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    # Ruff version.
 | 
			
		||||
    rev: v0.12.4
 | 
			
		||||
    rev: v0.12.3
 | 
			
		||||
    hooks:
 | 
			
		||||
      # Run the linter.
 | 
			
		||||
      - id: ruff
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
pyproject.toml @esphome/core
 | 
			
		||||
esphome/*.py @esphome/core
 | 
			
		||||
esphome/core/* @esphome/core
 | 
			
		||||
.github/** @esphome/core
 | 
			
		||||
 | 
			
		||||
# Integrations
 | 
			
		||||
esphome/components/a01nyub/* @MrSuicideParrot
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ project and be sure to join us on [Discord](https://discord.gg/KhAMKrd).
 | 
			
		||||
 | 
			
		||||
**See also:**
 | 
			
		||||
 | 
			
		||||
[Documentation](https://esphome.io) -- [Issues](https://github.com/esphome/esphome/issues) -- [Feature requests](https://github.com/orgs/esphome/discussions)
 | 
			
		||||
[Documentation](https://esphome.io) -- [Issues](https://github.com/esphome/issues/issues) -- [Feature requests](https://github.com/esphome/feature-requests/issues)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1381,7 +1381,7 @@ message BluetoothLERawAdvertisement {
 | 
			
		||||
  sint32 rssi = 2;
 | 
			
		||||
  uint32 address_type = 3;
 | 
			
		||||
 | 
			
		||||
  bytes data = 4 [(fixed_array_size) = 62];
 | 
			
		||||
  bytes data = 4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
message BluetoothLERawAdvertisementsResponse {
 | 
			
		||||
 
 | 
			
		||||
@@ -202,8 +202,7 @@ void APIConnection::loop() {
 | 
			
		||||
  } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && !this->flags_.remove) {
 | 
			
		||||
    // Only send ping if we're not disconnecting
 | 
			
		||||
    ESP_LOGVV(TAG, "Sending keepalive PING");
 | 
			
		||||
    PingRequest req;
 | 
			
		||||
    this->flags_.sent_ping = this->send_message(req, PingRequest::MESSAGE_TYPE);
 | 
			
		||||
    this->flags_.sent_ping = this->send_message(PingRequest());
 | 
			
		||||
    if (!this->flags_.sent_ping) {
 | 
			
		||||
      // If we can't send the ping request directly (tx_buffer full),
 | 
			
		||||
      // schedule it at the front of the batch so it will be sent with priority
 | 
			
		||||
@@ -252,7 +251,7 @@ void APIConnection::loop() {
 | 
			
		||||
      resp.entity_id = it.entity_id;
 | 
			
		||||
      resp.attribute = it.attribute.value();
 | 
			
		||||
      resp.once = it.once;
 | 
			
		||||
      if (this->send_message(resp, SubscribeHomeAssistantStateResponse::MESSAGE_TYPE)) {
 | 
			
		||||
      if (this->send_message(resp)) {
 | 
			
		||||
        state_subs_at_++;
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
@@ -1124,9 +1123,9 @@ bool APIConnection::send_bluetooth_le_advertisement(const BluetoothLEAdvertiseme
 | 
			
		||||
      manufacturer_data.legacy_data.assign(manufacturer_data.data.begin(), manufacturer_data.data.end());
 | 
			
		||||
      manufacturer_data.data.clear();
 | 
			
		||||
    }
 | 
			
		||||
    return this->send_message(resp, BluetoothLEAdvertisementResponse::MESSAGE_TYPE);
 | 
			
		||||
    return this->send_message(resp);
 | 
			
		||||
  }
 | 
			
		||||
  return this->send_message(msg, BluetoothLEAdvertisementResponse::MESSAGE_TYPE);
 | 
			
		||||
  return this->send_message(msg);
 | 
			
		||||
}
 | 
			
		||||
void APIConnection::bluetooth_device_request(const BluetoothDeviceRequest &msg) {
 | 
			
		||||
  bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
 | 
			
		||||
 
 | 
			
		||||
@@ -111,7 +111,7 @@ class APIConnection : public APIServerConnection {
 | 
			
		||||
  void send_homeassistant_service_call(const HomeassistantServiceResponse &call) {
 | 
			
		||||
    if (!this->flags_.service_call_subscription)
 | 
			
		||||
      return;
 | 
			
		||||
    this->send_message(call, HomeassistantServiceResponse::MESSAGE_TYPE);
 | 
			
		||||
    this->send_message(call);
 | 
			
		||||
  }
 | 
			
		||||
#ifdef USE_BLUETOOTH_PROXY
 | 
			
		||||
  void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override;
 | 
			
		||||
@@ -133,7 +133,7 @@ class APIConnection : public APIServerConnection {
 | 
			
		||||
#ifdef USE_HOMEASSISTANT_TIME
 | 
			
		||||
  void send_time_request() {
 | 
			
		||||
    GetTimeRequest req;
 | 
			
		||||
    this->send_message(req, GetTimeRequest::MESSAGE_TYPE);
 | 
			
		||||
    this->send_message(req);
 | 
			
		||||
  }
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -26,5 +26,4 @@ extend google.protobuf.MessageOptions {
 | 
			
		||||
 | 
			
		||||
extend google.protobuf.FieldOptions {
 | 
			
		||||
    optional string field_ifdef = 1042;
 | 
			
		||||
    optional uint32 fixed_array_size = 50007;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@
 | 
			
		||||
#include "api_pb2.h"
 | 
			
		||||
#include "esphome/core/log.h"
 | 
			
		||||
#include "esphome/core/helpers.h"
 | 
			
		||||
#include <cstring>
 | 
			
		||||
 | 
			
		||||
namespace esphome {
 | 
			
		||||
namespace api {
 | 
			
		||||
@@ -1917,15 +1916,13 @@ void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer buffer) const {
 | 
			
		||||
  buffer.encode_uint64(1, this->address);
 | 
			
		||||
  buffer.encode_sint32(2, this->rssi);
 | 
			
		||||
  buffer.encode_uint32(3, this->address_type);
 | 
			
		||||
  buffer.encode_bytes(4, this->data, this->data_len);
 | 
			
		||||
  buffer.encode_bytes(4, reinterpret_cast<const uint8_t *>(this->data.data()), this->data.size());
 | 
			
		||||
}
 | 
			
		||||
void BluetoothLERawAdvertisement::calculate_size(uint32_t &total_size) const {
 | 
			
		||||
  ProtoSize::add_uint64_field(total_size, 1, this->address);
 | 
			
		||||
  ProtoSize::add_sint32_field(total_size, 1, this->rssi);
 | 
			
		||||
  ProtoSize::add_uint32_field(total_size, 1, this->address_type);
 | 
			
		||||
  if (this->data_len != 0) {
 | 
			
		||||
    total_size += 1 + ProtoSize::varint(static_cast<uint32_t>(this->data_len)) + this->data_len;
 | 
			
		||||
  }
 | 
			
		||||
  ProtoSize::add_string_field(total_size, 1, this->data);
 | 
			
		||||
}
 | 
			
		||||
void BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer buffer) const {
 | 
			
		||||
  for (auto &it : this->advertisements) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1768,8 +1768,7 @@ class BluetoothLERawAdvertisement : public ProtoMessage {
 | 
			
		||||
  uint64_t address{0};
 | 
			
		||||
  int32_t rssi{0};
 | 
			
		||||
  uint32_t address_type{0};
 | 
			
		||||
  uint8_t data[62]{};
 | 
			
		||||
  uint8_t data_len{0};
 | 
			
		||||
  std::string data{};
 | 
			
		||||
  void encode(ProtoWriteBuffer buffer) const override;
 | 
			
		||||
  void calculate_size(uint32_t &total_size) const override;
 | 
			
		||||
#ifdef HAS_PROTO_MESSAGE_DUMP
 | 
			
		||||
 
 | 
			
		||||
@@ -3132,7 +3132,7 @@ void BluetoothLERawAdvertisement::dump_to(std::string &out) const {
 | 
			
		||||
  out.append("\n");
 | 
			
		||||
 | 
			
		||||
  out.append("  data: ");
 | 
			
		||||
  out.append(format_hex_pretty(this->data, this->data_len));
 | 
			
		||||
  out.append(format_hex_pretty(this->data));
 | 
			
		||||
  out.append("\n");
 | 
			
		||||
  out.append("}");
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -598,32 +598,32 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type,
 | 
			
		||||
 | 
			
		||||
void APIServerConnection::on_hello_request(const HelloRequest &msg) {
 | 
			
		||||
  HelloResponse ret = this->hello(msg);
 | 
			
		||||
  if (!this->send_message(ret, HelloResponse::MESSAGE_TYPE)) {
 | 
			
		||||
  if (!this->send_message(ret)) {
 | 
			
		||||
    this->on_fatal_error();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
void APIServerConnection::on_connect_request(const ConnectRequest &msg) {
 | 
			
		||||
  ConnectResponse ret = this->connect(msg);
 | 
			
		||||
  if (!this->send_message(ret, ConnectResponse::MESSAGE_TYPE)) {
 | 
			
		||||
  if (!this->send_message(ret)) {
 | 
			
		||||
    this->on_fatal_error();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) {
 | 
			
		||||
  DisconnectResponse ret = this->disconnect(msg);
 | 
			
		||||
  if (!this->send_message(ret, DisconnectResponse::MESSAGE_TYPE)) {
 | 
			
		||||
  if (!this->send_message(ret)) {
 | 
			
		||||
    this->on_fatal_error();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
void APIServerConnection::on_ping_request(const PingRequest &msg) {
 | 
			
		||||
  PingResponse ret = this->ping(msg);
 | 
			
		||||
  if (!this->send_message(ret, PingResponse::MESSAGE_TYPE)) {
 | 
			
		||||
  if (!this->send_message(ret)) {
 | 
			
		||||
    this->on_fatal_error();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) {
 | 
			
		||||
  if (this->check_connection_setup_()) {
 | 
			
		||||
    DeviceInfoResponse ret = this->device_info(msg);
 | 
			
		||||
    if (!this->send_message(ret, DeviceInfoResponse::MESSAGE_TYPE)) {
 | 
			
		||||
    if (!this->send_message(ret)) {
 | 
			
		||||
      this->on_fatal_error();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -657,7 +657,7 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc
 | 
			
		||||
void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) {
 | 
			
		||||
  if (this->check_connection_setup_()) {
 | 
			
		||||
    GetTimeResponse ret = this->get_time(msg);
 | 
			
		||||
    if (!this->send_message(ret, GetTimeResponse::MESSAGE_TYPE)) {
 | 
			
		||||
    if (!this->send_message(ret)) {
 | 
			
		||||
      this->on_fatal_error();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -673,7 +673,7 @@ void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest
 | 
			
		||||
void APIServerConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) {
 | 
			
		||||
  if (this->check_authenticated_()) {
 | 
			
		||||
    NoiseEncryptionSetKeyResponse ret = this->noise_encryption_set_key(msg);
 | 
			
		||||
    if (!this->send_message(ret, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE)) {
 | 
			
		||||
    if (!this->send_message(ret)) {
 | 
			
		||||
      this->on_fatal_error();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -867,7 +867,7 @@ void APIServerConnection::on_subscribe_bluetooth_connections_free_request(
 | 
			
		||||
    const SubscribeBluetoothConnectionsFreeRequest &msg) {
 | 
			
		||||
  if (this->check_authenticated_()) {
 | 
			
		||||
    BluetoothConnectionsFreeResponse ret = this->subscribe_bluetooth_connections_free(msg);
 | 
			
		||||
    if (!this->send_message(ret, BluetoothConnectionsFreeResponse::MESSAGE_TYPE)) {
 | 
			
		||||
    if (!this->send_message(ret)) {
 | 
			
		||||
      this->on_fatal_error();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@@ -899,7 +899,7 @@ void APIServerConnection::on_subscribe_voice_assistant_request(const SubscribeVo
 | 
			
		||||
void APIServerConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) {
 | 
			
		||||
  if (this->check_authenticated_()) {
 | 
			
		||||
    VoiceAssistantConfigurationResponse ret = this->voice_assistant_get_configuration(msg);
 | 
			
		||||
    if (!this->send_message(ret, VoiceAssistantConfigurationResponse::MESSAGE_TYPE)) {
 | 
			
		||||
    if (!this->send_message(ret)) {
 | 
			
		||||
      this->on_fatal_error();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -18,11 +18,11 @@ class APIServerConnectionBase : public ProtoService {
 | 
			
		||||
 public:
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
  bool send_message(const ProtoMessage &msg, uint8_t message_type) {
 | 
			
		||||
  template<typename T> bool send_message(const T &msg) {
 | 
			
		||||
#ifdef HAS_PROTO_MESSAGE_DUMP
 | 
			
		||||
    this->log_send_message_(msg.message_name(), msg.dump());
 | 
			
		||||
#endif
 | 
			
		||||
    return this->send_message_(msg, message_type);
 | 
			
		||||
    return this->send_message_(msg, T::MESSAGE_TYPE);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  virtual void on_hello_request(const HelloRequest &value){};
 | 
			
		||||
 
 | 
			
		||||
@@ -428,8 +428,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
 | 
			
		||||
      ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
 | 
			
		||||
      this->set_noise_psk(psk);
 | 
			
		||||
      for (auto &c : this->clients_) {
 | 
			
		||||
        DisconnectRequest req;
 | 
			
		||||
        c->send_message(req, DisconnectRequest::MESSAGE_TYPE);
 | 
			
		||||
        c->send_message(DisconnectRequest());
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
@@ -462,8 +461,7 @@ void APIServer::on_shutdown() {
 | 
			
		||||
 | 
			
		||||
  // Send disconnect requests to all connected clients
 | 
			
		||||
  for (auto &c : this->clients_) {
 | 
			
		||||
    DisconnectRequest req;
 | 
			
		||||
    if (!c->send_message(req, DisconnectRequest::MESSAGE_TYPE)) {
 | 
			
		||||
    if (!c->send_message(DisconnectRequest())) {
 | 
			
		||||
      // If we can't send the disconnect request directly (tx_buffer full),
 | 
			
		||||
      // schedule it at the front of the batch so it will be sent with priority
 | 
			
		||||
      c->schedule_message_front_(nullptr, &APIConnection::try_send_disconnect_request, DisconnectRequest::MESSAGE_TYPE,
 | 
			
		||||
 
 | 
			
		||||
@@ -16,9 +16,6 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
 | 
			
		||||
  template<typename T> static std::string value_to_string(T &&val) { return to_string(std::forward<T>(val)); }
 | 
			
		||||
 | 
			
		||||
  // Overloads for string types - needed because std::to_string doesn't support them
 | 
			
		||||
  static std::string value_to_string(char *val) {
 | 
			
		||||
    return val ? std::string(val) : std::string();
 | 
			
		||||
  }  // For lambdas returning char* (e.g., itoa)
 | 
			
		||||
  static std::string value_to_string(const char *val) { return std::string(val); }  // For lambdas returning .c_str()
 | 
			
		||||
  static std::string value_to_string(const std::string &val) { return val; }
 | 
			
		||||
  static std::string value_to_string(std::string &&val) { return std::move(val); }
 | 
			
		||||
 
 | 
			
		||||
@@ -86,7 +86,7 @@ ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(clie
 | 
			
		||||
#ifdef USE_API_SERVICES
 | 
			
		||||
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
 | 
			
		||||
  auto resp = service->encode_list_service_response();
 | 
			
		||||
  return this->client_->send_message(resp, ListEntitiesServicesResponse::MESSAGE_TYPE);
 | 
			
		||||
  return this->client_->send_message(resp);
 | 
			
		||||
}
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -75,7 +75,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
 | 
			
		||||
      resp.data.reserve(param->read.value_len);
 | 
			
		||||
      // Use bulk insert instead of individual push_backs
 | 
			
		||||
      resp.data.insert(resp.data.end(), param->read.value, param->read.value + param->read.value_len);
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTReadResponse::MESSAGE_TYPE);
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    case ESP_GATTC_WRITE_CHAR_EVT:
 | 
			
		||||
@@ -89,7 +89,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
 | 
			
		||||
      api::BluetoothGATTWriteResponse resp;
 | 
			
		||||
      resp.address = this->address_;
 | 
			
		||||
      resp.handle = param->write.handle;
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTWriteResponse::MESSAGE_TYPE);
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
 | 
			
		||||
@@ -103,7 +103,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
 | 
			
		||||
      api::BluetoothGATTNotifyResponse resp;
 | 
			
		||||
      resp.address = this->address_;
 | 
			
		||||
      resp.handle = param->unreg_for_notify.handle;
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE);
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
 | 
			
		||||
@@ -116,7 +116,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
 | 
			
		||||
      api::BluetoothGATTNotifyResponse resp;
 | 
			
		||||
      resp.address = this->address_;
 | 
			
		||||
      resp.handle = param->reg_for_notify.handle;
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE);
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    case ESP_GATTC_NOTIFY_EVT: {
 | 
			
		||||
@@ -128,7 +128,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
 | 
			
		||||
      resp.data.reserve(param->notify.value_len);
 | 
			
		||||
      // Use bulk insert instead of individual push_backs
 | 
			
		||||
      resp.data.insert(resp.data.end(), param->notify.value, param->notify.value + param->notify.value_len);
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyDataResponse::MESSAGE_TYPE);
 | 
			
		||||
      this->proxy_->get_api_connection()->send_message(resp);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    default:
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,6 @@
 | 
			
		||||
#include "esphome/core/log.h"
 | 
			
		||||
#include "esphome/core/macros.h"
 | 
			
		||||
#include "esphome/core/application.h"
 | 
			
		||||
#include <cstring>
 | 
			
		||||
 | 
			
		||||
#ifdef USE_ESP32
 | 
			
		||||
 | 
			
		||||
@@ -25,30 +24,9 @@ std::vector<uint64_t> get_128bit_uuid_vec(esp_bt_uuid_t uuid_source) {
 | 
			
		||||
                                   ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0])};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Batch size for BLE advertisements to maximize WiFi efficiency
 | 
			
		||||
// Each advertisement is up to 80 bytes when packaged (including protocol overhead)
 | 
			
		||||
// Most advertisements are 20-30 bytes, allowing even more to fit per packet
 | 
			
		||||
// 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
 | 
			
		||||
// This achieves ~97% WiFi MTU utilization while staying under the limit
 | 
			
		||||
static constexpr size_t FLUSH_BATCH_SIZE = 16;
 | 
			
		||||
 | 
			
		||||
// Verify BLE advertisement data array size matches the BLE specification (31 bytes adv + 31 bytes scan response)
 | 
			
		||||
static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62,
 | 
			
		||||
              "BLE advertisement data array size mismatch");
 | 
			
		||||
 | 
			
		||||
BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; }
 | 
			
		||||
 | 
			
		||||
void BluetoothProxy::setup() {
 | 
			
		||||
  // Pre-allocate response object
 | 
			
		||||
  this->response_ = std::make_unique<api::BluetoothLERawAdvertisementsResponse>();
 | 
			
		||||
 | 
			
		||||
  // Reserve capacity but start with size 0
 | 
			
		||||
  // Reserve 50% since we'll grow naturally and flush at FLUSH_BATCH_SIZE
 | 
			
		||||
  this->response_->advertisements.reserve(FLUSH_BATCH_SIZE / 2);
 | 
			
		||||
 | 
			
		||||
  // Don't pre-allocate pool - let it grow only if needed in busy environments
 | 
			
		||||
  // Many devices in quiet areas will never need the overflow pool
 | 
			
		||||
 | 
			
		||||
  this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) {
 | 
			
		||||
    if (this->api_connection_ != nullptr) {
 | 
			
		||||
      this->send_bluetooth_scanner_state_(state);
 | 
			
		||||
@@ -61,7 +39,7 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta
 | 
			
		||||
  resp.state = static_cast<api::enums::BluetoothScannerState>(state);
 | 
			
		||||
  resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE
 | 
			
		||||
                                               : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE;
 | 
			
		||||
  this->api_connection_->send_message(resp, api::BluetoothScannerStateResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(resp);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#ifdef USE_ESP32_BLE_DEVICE
 | 
			
		||||
@@ -72,72 +50,68 @@ bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device)
 | 
			
		||||
}
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
// Batch size for BLE advertisements to maximize WiFi efficiency
 | 
			
		||||
// Each advertisement is up to 80 bytes when packaged (including protocol overhead)
 | 
			
		||||
// Most advertisements are 20-30 bytes, allowing even more to fit per packet
 | 
			
		||||
// 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload
 | 
			
		||||
// This achieves ~97% WiFi MTU utilization while staying under the limit
 | 
			
		||||
static constexpr size_t FLUSH_BATCH_SIZE = 16;
 | 
			
		||||
 | 
			
		||||
namespace {
 | 
			
		||||
// Batch buffer in anonymous namespace to avoid guard variable (saves 8 bytes)
 | 
			
		||||
// This is initialized at program startup before any threads
 | 
			
		||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
 | 
			
		||||
std::vector<api::BluetoothLERawAdvertisement> batch_buffer;
 | 
			
		||||
}  // namespace
 | 
			
		||||
 | 
			
		||||
static std::vector<api::BluetoothLERawAdvertisement> &get_batch_buffer() { return batch_buffer; }
 | 
			
		||||
 | 
			
		||||
bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) {
 | 
			
		||||
  if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr)
 | 
			
		||||
    return false;
 | 
			
		||||
 | 
			
		||||
  auto &advertisements = this->response_->advertisements;
 | 
			
		||||
  // Get the batch buffer reference
 | 
			
		||||
  auto &batch_buffer = get_batch_buffer();
 | 
			
		||||
 | 
			
		||||
  // Reserve additional capacity if needed
 | 
			
		||||
  size_t new_size = batch_buffer.size() + count;
 | 
			
		||||
  if (batch_buffer.capacity() < new_size) {
 | 
			
		||||
    batch_buffer.reserve(new_size);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Add new advertisements to the batch buffer
 | 
			
		||||
  for (size_t i = 0; i < count; i++) {
 | 
			
		||||
    auto &result = scan_results[i];
 | 
			
		||||
    uint8_t length = result.adv_data_len + result.scan_rsp_len;
 | 
			
		||||
 | 
			
		||||
    // Check if we need to expand the vector
 | 
			
		||||
    if (this->advertisement_count_ >= advertisements.size()) {
 | 
			
		||||
      if (this->advertisement_pool_.empty()) {
 | 
			
		||||
        // No room in pool, need to allocate
 | 
			
		||||
        advertisements.emplace_back();
 | 
			
		||||
      } else {
 | 
			
		||||
        // Pull from pool
 | 
			
		||||
        advertisements.push_back(std::move(this->advertisement_pool_.back()));
 | 
			
		||||
        this->advertisement_pool_.pop_back();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Fill in the data directly at current position
 | 
			
		||||
    auto &adv = advertisements[this->advertisement_count_];
 | 
			
		||||
    batch_buffer.emplace_back();
 | 
			
		||||
    auto &adv = batch_buffer.back();
 | 
			
		||||
    adv.address = esp32_ble::ble_addr_to_uint64(result.bda);
 | 
			
		||||
    adv.rssi = result.rssi;
 | 
			
		||||
    adv.address_type = result.ble_addr_type;
 | 
			
		||||
    adv.data_len = length;
 | 
			
		||||
    std::memcpy(adv.data, result.ble_adv, length);
 | 
			
		||||
 | 
			
		||||
    this->advertisement_count_++;
 | 
			
		||||
    adv.data.assign(&result.ble_adv[0], &result.ble_adv[length]);
 | 
			
		||||
 | 
			
		||||
    ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0],
 | 
			
		||||
             result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    // Flush if we have reached FLUSH_BATCH_SIZE
 | 
			
		||||
    if (this->advertisement_count_ >= FLUSH_BATCH_SIZE) {
 | 
			
		||||
      this->flush_pending_advertisements();
 | 
			
		||||
    }
 | 
			
		||||
  // Only send if we've accumulated a good batch size to maximize batching efficiency
 | 
			
		||||
  // https://github.com/esphome/backlog/issues/21
 | 
			
		||||
  if (batch_buffer.size() >= FLUSH_BATCH_SIZE) {
 | 
			
		||||
    this->flush_pending_advertisements();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void BluetoothProxy::flush_pending_advertisements() {
 | 
			
		||||
  if (this->advertisement_count_ == 0 || !api::global_api_server->is_connected() || this->api_connection_ == nullptr)
 | 
			
		||||
  auto &batch_buffer = get_batch_buffer();
 | 
			
		||||
  if (batch_buffer.empty() || !api::global_api_server->is_connected() || this->api_connection_ == nullptr)
 | 
			
		||||
    return;
 | 
			
		||||
 | 
			
		||||
  auto &advertisements = this->response_->advertisements;
 | 
			
		||||
 | 
			
		||||
  // Return any items beyond advertisement_count_ to the pool
 | 
			
		||||
  if (advertisements.size() > this->advertisement_count_) {
 | 
			
		||||
    // Move unused items back to pool
 | 
			
		||||
    this->advertisement_pool_.insert(this->advertisement_pool_.end(),
 | 
			
		||||
                                     std::make_move_iterator(advertisements.begin() + this->advertisement_count_),
 | 
			
		||||
                                     std::make_move_iterator(advertisements.end()));
 | 
			
		||||
 | 
			
		||||
    // Resize to actual count
 | 
			
		||||
    advertisements.resize(this->advertisement_count_);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Send the message
 | 
			
		||||
  this->api_connection_->send_message(*this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE);
 | 
			
		||||
 | 
			
		||||
  // Reset count - existing items will be overwritten in next batch
 | 
			
		||||
  this->advertisement_count_ = 0;
 | 
			
		||||
  api::BluetoothLERawAdvertisementsResponse resp;
 | 
			
		||||
  resp.advertisements.swap(batch_buffer);
 | 
			
		||||
  this->api_connection_->send_message(resp);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#ifdef USE_ESP32_BLE_DEVICE
 | 
			
		||||
@@ -176,7 +150,7 @@ void BluetoothProxy::send_api_packet_(const esp32_ble_tracker::ESPBTDevice &devi
 | 
			
		||||
    manufacturer_data.data.assign(data.data.begin(), data.data.end());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  this->api_connection_->send_message(resp, api::BluetoothLEAdvertisementResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(resp);
 | 
			
		||||
}
 | 
			
		||||
#endif  // USE_ESP32_BLE_DEVICE
 | 
			
		||||
 | 
			
		||||
@@ -335,7 +309,7 @@ void BluetoothProxy::loop() {
 | 
			
		||||
        service_resp.characteristics.push_back(std::move(characteristic_resp));
 | 
			
		||||
      }
 | 
			
		||||
      resp.services.push_back(std::move(service_resp));
 | 
			
		||||
      this->api_connection_->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE);
 | 
			
		||||
      this->api_connection_->send_message(resp);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -486,7 +460,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
 | 
			
		||||
      call.success = ret == ESP_OK;
 | 
			
		||||
      call.error = ret;
 | 
			
		||||
 | 
			
		||||
      this->api_connection_->send_message(call, api::BluetoothDeviceClearCacheResponse::MESSAGE_TYPE);
 | 
			
		||||
      this->api_connection_->send_message(call);
 | 
			
		||||
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
@@ -608,7 +582,7 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui
 | 
			
		||||
  call.connected = connected;
 | 
			
		||||
  call.mtu = mtu;
 | 
			
		||||
  call.error = error;
 | 
			
		||||
  this->api_connection_->send_message(call, api::BluetoothDeviceConnectionResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(call);
 | 
			
		||||
}
 | 
			
		||||
void BluetoothProxy::send_connections_free() {
 | 
			
		||||
  if (this->api_connection_ == nullptr)
 | 
			
		||||
@@ -621,7 +595,7 @@ void BluetoothProxy::send_connections_free() {
 | 
			
		||||
      call.allocated.push_back(connection->address_);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  this->api_connection_->send_message(call, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(call);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void BluetoothProxy::send_gatt_services_done(uint64_t address) {
 | 
			
		||||
@@ -629,7 +603,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) {
 | 
			
		||||
    return;
 | 
			
		||||
  api::BluetoothGATTGetServicesDoneResponse call;
 | 
			
		||||
  call.address = address;
 | 
			
		||||
  this->api_connection_->send_message(call, api::BluetoothGATTGetServicesDoneResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(call);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) {
 | 
			
		||||
@@ -639,7 +613,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_
 | 
			
		||||
  call.address = address;
 | 
			
		||||
  call.handle = handle;
 | 
			
		||||
  call.error = error;
 | 
			
		||||
  this->api_connection_->send_message(call, api::BluetoothGATTWriteResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(call);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) {
 | 
			
		||||
@@ -648,7 +622,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_
 | 
			
		||||
  call.paired = paired;
 | 
			
		||||
  call.error = error;
 | 
			
		||||
 | 
			
		||||
  this->api_connection_->send_message(call, api::BluetoothDevicePairingResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(call);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) {
 | 
			
		||||
@@ -657,7 +631,7 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e
 | 
			
		||||
  call.success = success;
 | 
			
		||||
  call.error = error;
 | 
			
		||||
 | 
			
		||||
  this->api_connection_->send_message(call, api::BluetoothDeviceUnpairingResponse::MESSAGE_TYPE);
 | 
			
		||||
  this->api_connection_->send_message(call);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void BluetoothProxy::bluetooth_scanner_set_mode(bool active) {
 | 
			
		||||
 
 | 
			
		||||
@@ -145,14 +145,9 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com
 | 
			
		||||
  // Group 2: Container types (typically 12 bytes on 32-bit)
 | 
			
		||||
  std::vector<BluetoothConnection *> connections_{};
 | 
			
		||||
 | 
			
		||||
  // BLE advertisement batching
 | 
			
		||||
  std::vector<api::BluetoothLERawAdvertisement> advertisement_pool_;
 | 
			
		||||
  std::unique_ptr<api::BluetoothLERawAdvertisementsResponse> response_;
 | 
			
		||||
 | 
			
		||||
  // Group 3: 1-byte types grouped together
 | 
			
		||||
  bool active_;
 | 
			
		||||
  uint8_t advertisement_count_{0};
 | 
			
		||||
  // 2 bytes used, 2 bytes padding
 | 
			
		||||
  // 1 byte used, 3 bytes padding
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
extern BluetoothProxy *global_bluetooth_proxy;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,6 @@ from esphome.const import (
 | 
			
		||||
    CONF_MODE,
 | 
			
		||||
    CONF_NUMBER,
 | 
			
		||||
    CONF_ON_VALUE,
 | 
			
		||||
    CONF_SWITCH,
 | 
			
		||||
    CONF_TEXT,
 | 
			
		||||
    CONF_TRIGGER_ID,
 | 
			
		||||
    CONF_TYPE,
 | 
			
		||||
@@ -34,6 +33,7 @@ CONF_LABEL = "label"
 | 
			
		||||
CONF_MENU = "menu"
 | 
			
		||||
CONF_BACK = "back"
 | 
			
		||||
CONF_SELECT = "select"
 | 
			
		||||
CONF_SWITCH = "switch"
 | 
			
		||||
CONF_ON_TEXT = "on_text"
 | 
			
		||||
CONF_OFF_TEXT = "off_text"
 | 
			
		||||
CONF_VALUE_LAMBDA = "value_lambda"
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,7 @@ import esphome.final_validate as fv
 | 
			
		||||
from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed
 | 
			
		||||
from esphome.types import ConfigType
 | 
			
		||||
 | 
			
		||||
from .boards import BOARDS, STANDARD_BOARDS
 | 
			
		||||
from .boards import BOARDS
 | 
			
		||||
from .const import (  # noqa
 | 
			
		||||
    KEY_BOARD,
 | 
			
		||||
    KEY_COMPONENTS,
 | 
			
		||||
@@ -487,32 +487,25 @@ def _platform_is_platformio(value):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _detect_variant(value):
 | 
			
		||||
    board = value.get(CONF_BOARD)
 | 
			
		||||
    variant = value.get(CONF_VARIANT)
 | 
			
		||||
    if variant and board is None:
 | 
			
		||||
        # If variant is set, we can derive the board from it
 | 
			
		||||
        # variant has already been validated against the known set
 | 
			
		||||
        value = value.copy()
 | 
			
		||||
        value[CONF_BOARD] = STANDARD_BOARDS[variant]
 | 
			
		||||
    elif board in BOARDS:
 | 
			
		||||
        variant = variant or BOARDS[board][KEY_VARIANT]
 | 
			
		||||
        if variant != BOARDS[board][KEY_VARIANT]:
 | 
			
		||||
    board = value[CONF_BOARD]
 | 
			
		||||
    if board in BOARDS:
 | 
			
		||||
        variant = BOARDS[board][KEY_VARIANT]
 | 
			
		||||
        if CONF_VARIANT in value and variant != value[CONF_VARIANT]:
 | 
			
		||||
            raise cv.Invalid(
 | 
			
		||||
                f"Option '{CONF_VARIANT}' does not match selected board.",
 | 
			
		||||
                path=[CONF_VARIANT],
 | 
			
		||||
            )
 | 
			
		||||
        value = value.copy()
 | 
			
		||||
        value[CONF_VARIANT] = variant
 | 
			
		||||
    elif not variant:
 | 
			
		||||
        raise cv.Invalid(
 | 
			
		||||
            "This board is unknown, if you are sure you want to compile with this board selection, "
 | 
			
		||||
            f"override with option '{CONF_VARIANT}'",
 | 
			
		||||
            path=[CONF_BOARD],
 | 
			
		||||
        )
 | 
			
		||||
    else:
 | 
			
		||||
        if CONF_VARIANT not in value:
 | 
			
		||||
            raise cv.Invalid(
 | 
			
		||||
                "This board is unknown, if you are sure you want to compile with this board selection, "
 | 
			
		||||
                f"override with option '{CONF_VARIANT}'",
 | 
			
		||||
                path=[CONF_BOARD],
 | 
			
		||||
            )
 | 
			
		||||
        _LOGGER.warning(
 | 
			
		||||
            "This board is unknown; the specified variant '%s' will be used but this may not work as expected.",
 | 
			
		||||
            variant,
 | 
			
		||||
            "This board is unknown. Make sure the chosen chip component is correct.",
 | 
			
		||||
        )
 | 
			
		||||
    return value
 | 
			
		||||
 | 
			
		||||
@@ -683,7 +676,7 @@ CONF_PARTITIONS = "partitions"
 | 
			
		||||
CONFIG_SCHEMA = cv.All(
 | 
			
		||||
    cv.Schema(
 | 
			
		||||
        {
 | 
			
		||||
            cv.Optional(CONF_BOARD): cv.string_strict,
 | 
			
		||||
            cv.Required(CONF_BOARD): cv.string_strict,
 | 
			
		||||
            cv.Optional(CONF_CPU_FREQUENCY): cv.one_of(
 | 
			
		||||
                *FULL_CPU_FREQUENCIES, upper=True
 | 
			
		||||
            ),
 | 
			
		||||
@@ -698,7 +691,6 @@ CONFIG_SCHEMA = cv.All(
 | 
			
		||||
    _detect_variant,
 | 
			
		||||
    _set_default_framework,
 | 
			
		||||
    set_core_data,
 | 
			
		||||
    cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,30 +2,13 @@ from .const import (
 | 
			
		||||
    VARIANT_ESP32,
 | 
			
		||||
    VARIANT_ESP32C2,
 | 
			
		||||
    VARIANT_ESP32C3,
 | 
			
		||||
    VARIANT_ESP32C5,
 | 
			
		||||
    VARIANT_ESP32C6,
 | 
			
		||||
    VARIANT_ESP32H2,
 | 
			
		||||
    VARIANT_ESP32P4,
 | 
			
		||||
    VARIANT_ESP32S2,
 | 
			
		||||
    VARIANT_ESP32S3,
 | 
			
		||||
    VARIANTS,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
STANDARD_BOARDS = {
 | 
			
		||||
    VARIANT_ESP32: "esp32dev",
 | 
			
		||||
    VARIANT_ESP32C2: "esp32-c2-devkitm-1",
 | 
			
		||||
    VARIANT_ESP32C3: "esp32-c3-devkitm-1",
 | 
			
		||||
    VARIANT_ESP32C5: "esp32-c5-devkitc-1",
 | 
			
		||||
    VARIANT_ESP32C6: "esp32-c6-devkitm-1",
 | 
			
		||||
    VARIANT_ESP32H2: "esp32-h2-devkitm-1",
 | 
			
		||||
    VARIANT_ESP32P4: "esp32-p4-evboard",
 | 
			
		||||
    VARIANT_ESP32S2: "esp32-s2-kaluga-1",
 | 
			
		||||
    VARIANT_ESP32S3: "esp32-s3-devkitc-1",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# Make sure not missed here if a new variant added.
 | 
			
		||||
assert all(v in STANDARD_BOARDS for v in VARIANTS)
 | 
			
		||||
 | 
			
		||||
ESP32_BASE_PINS = {
 | 
			
		||||
    "TX": 1,
 | 
			
		||||
    "RX": 3,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,3 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from esphome import automation, pins
 | 
			
		||||
import esphome.codegen as cg
 | 
			
		||||
from esphome.components import i2c
 | 
			
		||||
@@ -10,7 +8,6 @@ from esphome.const import (
 | 
			
		||||
    CONF_CONTRAST,
 | 
			
		||||
    CONF_DATA_PINS,
 | 
			
		||||
    CONF_FREQUENCY,
 | 
			
		||||
    CONF_I2C,
 | 
			
		||||
    CONF_I2C_ID,
 | 
			
		||||
    CONF_ID,
 | 
			
		||||
    CONF_PIN,
 | 
			
		||||
@@ -23,9 +20,6 @@ from esphome.const import (
 | 
			
		||||
)
 | 
			
		||||
from esphome.core import CORE
 | 
			
		||||
from esphome.core.entity_helpers import setup_entity
 | 
			
		||||
import esphome.final_validate as fv
 | 
			
		||||
 | 
			
		||||
_LOGGER = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
DEPENDENCIES = ["esp32"]
 | 
			
		||||
 | 
			
		||||
@@ -119,12 +113,6 @@ ENUM_SPECIAL_EFFECT = {
 | 
			
		||||
    "SEPIA": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_SEPIA,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
camera_fb_location_t = cg.global_ns.enum("camera_fb_location_t")
 | 
			
		||||
ENUM_FB_LOCATION = {
 | 
			
		||||
    "PSRAM": cg.global_ns.CAMERA_FB_IN_PSRAM,
 | 
			
		||||
    "DRAM": cg.global_ns.CAMERA_FB_IN_DRAM,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# pin assignment
 | 
			
		||||
CONF_HREF_PIN = "href_pin"
 | 
			
		||||
CONF_PIXEL_CLOCK_PIN = "pixel_clock_pin"
 | 
			
		||||
@@ -155,7 +143,6 @@ CONF_MAX_FRAMERATE = "max_framerate"
 | 
			
		||||
CONF_IDLE_FRAMERATE = "idle_framerate"
 | 
			
		||||
# frame buffer
 | 
			
		||||
CONF_FRAME_BUFFER_COUNT = "frame_buffer_count"
 | 
			
		||||
CONF_FRAME_BUFFER_LOCATION = "frame_buffer_location"
 | 
			
		||||
 | 
			
		||||
# stream trigger
 | 
			
		||||
CONF_ON_STREAM_START = "on_stream_start"
 | 
			
		||||
@@ -237,9 +224,6 @@ CONFIG_SCHEMA = cv.All(
 | 
			
		||||
                cv.framerate, cv.Range(min=0, max=1)
 | 
			
		||||
            ),
 | 
			
		||||
            cv.Optional(CONF_FRAME_BUFFER_COUNT, default=1): cv.int_range(min=1, max=2),
 | 
			
		||||
            cv.Optional(CONF_FRAME_BUFFER_LOCATION, default="PSRAM"): cv.enum(
 | 
			
		||||
                ENUM_FB_LOCATION, upper=True
 | 
			
		||||
            ),
 | 
			
		||||
            cv.Optional(CONF_ON_STREAM_START): automation.validate_automation(
 | 
			
		||||
                {
 | 
			
		||||
                    cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
 | 
			
		||||
@@ -266,22 +250,6 @@ CONFIG_SCHEMA = cv.All(
 | 
			
		||||
    cv.has_exactly_one_key(CONF_I2C_PINS, CONF_I2C_ID),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _final_validate(config):
 | 
			
		||||
    if CONF_I2C_PINS not in config:
 | 
			
		||||
        return
 | 
			
		||||
    fconf = fv.full_config.get()
 | 
			
		||||
    if fconf.get(CONF_I2C):
 | 
			
		||||
        raise cv.Invalid(
 | 
			
		||||
            "The `i2c_pins:` config option is incompatible with an dedicated `i2c:` block, use `i2c_id` instead"
 | 
			
		||||
        )
 | 
			
		||||
    _LOGGER.warning(
 | 
			
		||||
        "The `i2c_pins:` config option is deprecated. Use `i2c_id:` with a dedicated `i2c:` definition instead."
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
FINAL_VALIDATE_SCHEMA = _final_validate
 | 
			
		||||
 | 
			
		||||
SETTERS = {
 | 
			
		||||
    # pin assignment
 | 
			
		||||
    CONF_DATA_PINS: "set_data_pins",
 | 
			
		||||
@@ -311,7 +279,6 @@ SETTERS = {
 | 
			
		||||
    CONF_WB_MODE: "set_wb_mode",
 | 
			
		||||
    # test pattern
 | 
			
		||||
    CONF_TEST_PATTERN: "set_test_pattern",
 | 
			
		||||
    CONF_FRAME_BUFFER_LOCATION: "set_frame_buffer_location",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -339,7 +306,6 @@ async def to_code(config):
 | 
			
		||||
    else:
 | 
			
		||||
        cg.add(var.set_idle_update_interval(1000 / config[CONF_IDLE_FRAMERATE]))
 | 
			
		||||
    cg.add(var.set_frame_buffer_count(config[CONF_FRAME_BUFFER_COUNT]))
 | 
			
		||||
    cg.add(var.set_frame_buffer_location(config[CONF_FRAME_BUFFER_LOCATION]))
 | 
			
		||||
    cg.add(var.set_frame_size(config[CONF_RESOLUTION]))
 | 
			
		||||
 | 
			
		||||
    cg.add_define("USE_CAMERA")
 | 
			
		||||
 
 | 
			
		||||
@@ -133,7 +133,6 @@ void ESP32Camera::dump_config() {
 | 
			
		||||
  ESP_LOGCONFIG(TAG,
 | 
			
		||||
                "  JPEG Quality: %u\n"
 | 
			
		||||
                "  Framebuffer Count: %u\n"
 | 
			
		||||
                "  Framebuffer Location: %s\n"
 | 
			
		||||
                "  Contrast: %d\n"
 | 
			
		||||
                "  Brightness: %d\n"
 | 
			
		||||
                "  Saturation: %d\n"
 | 
			
		||||
@@ -141,9 +140,8 @@ void ESP32Camera::dump_config() {
 | 
			
		||||
                "  Horizontal Mirror: %s\n"
 | 
			
		||||
                "  Special Effect: %u\n"
 | 
			
		||||
                "  White Balance Mode: %u",
 | 
			
		||||
                st.quality, conf.fb_count, this->config_.fb_location == CAMERA_FB_IN_PSRAM ? "PSRAM" : "DRAM",
 | 
			
		||||
                st.contrast, st.brightness, st.saturation, ONOFF(st.vflip), ONOFF(st.hmirror), st.special_effect,
 | 
			
		||||
                st.wb_mode);
 | 
			
		||||
                st.quality, conf.fb_count, st.contrast, st.brightness, st.saturation, ONOFF(st.vflip),
 | 
			
		||||
                ONOFF(st.hmirror), st.special_effect, st.wb_mode);
 | 
			
		||||
  // ESP_LOGCONFIG(TAG, "  Auto White Balance: %u", st.awb);
 | 
			
		||||
  // ESP_LOGCONFIG(TAG, "  Auto White Balance Gain: %u", st.awb_gain);
 | 
			
		||||
  ESP_LOGCONFIG(TAG,
 | 
			
		||||
@@ -352,9 +350,6 @@ void ESP32Camera::set_frame_buffer_count(uint8_t fb_count) {
 | 
			
		||||
  this->config_.fb_count = fb_count;
 | 
			
		||||
  this->set_frame_buffer_mode(fb_count > 1 ? CAMERA_GRAB_LATEST : CAMERA_GRAB_WHEN_EMPTY);
 | 
			
		||||
}
 | 
			
		||||
void ESP32Camera::set_frame_buffer_location(camera_fb_location_t fb_location) {
 | 
			
		||||
  this->config_.fb_location = fb_location;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* ---------------- public API (specific) ---------------- */
 | 
			
		||||
void ESP32Camera::add_image_callback(std::function<void(std::shared_ptr<camera::CameraImage>)> &&callback) {
 | 
			
		||||
 
 | 
			
		||||
@@ -152,7 +152,6 @@ class ESP32Camera : public camera::Camera {
 | 
			
		||||
  /* -- frame buffer */
 | 
			
		||||
  void set_frame_buffer_mode(camera_grab_mode_t mode);
 | 
			
		||||
  void set_frame_buffer_count(uint8_t fb_count);
 | 
			
		||||
  void set_frame_buffer_location(camera_fb_location_t fb_location);
 | 
			
		||||
 | 
			
		||||
  /* public API (derivated) */
 | 
			
		||||
  void setup() override;
 | 
			
		||||
 
 | 
			
		||||
@@ -29,21 +29,7 @@ CONFIG_SCHEMA = (
 | 
			
		||||
    .extend(
 | 
			
		||||
        {
 | 
			
		||||
            cv.Required(CONF_PIN): pins.gpio_input_pin_schema,
 | 
			
		||||
            # Interrupts are disabled by default for bk72xx, ln882x, and rtl87xx platforms
 | 
			
		||||
            # due to hardware limitations or lack of reliable interrupt support. This ensures
 | 
			
		||||
            # stable operation on these platforms. Future maintainers should verify platform
 | 
			
		||||
            # capabilities before changing this default behavior.
 | 
			
		||||
            cv.SplitDefault(
 | 
			
		||||
                CONF_USE_INTERRUPT,
 | 
			
		||||
                bk72xx=False,
 | 
			
		||||
                esp32=True,
 | 
			
		||||
                esp8266=True,
 | 
			
		||||
                host=True,
 | 
			
		||||
                ln882x=False,
 | 
			
		||||
                nrf52=True,
 | 
			
		||||
                rp2040=True,
 | 
			
		||||
                rtl87xx=False,
 | 
			
		||||
            ): cv.boolean,
 | 
			
		||||
            cv.Optional(CONF_USE_INTERRUPT, default=True): cv.boolean,
 | 
			
		||||
            cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum(
 | 
			
		||||
                INTERRUPT_TYPES, upper=True
 | 
			
		||||
            ),
 | 
			
		||||
 
 | 
			
		||||
@@ -36,8 +36,8 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub
 | 
			
		||||
 | 
			
		||||
#ifdef USE_I2S_LEGACY
 | 
			
		||||
#if SOC_I2S_SUPPORTS_ADC
 | 
			
		||||
  void set_adc_channel(adc_channel_t channel) {
 | 
			
		||||
    this->adc_channel_ = (adc1_channel_t) channel;
 | 
			
		||||
  void set_adc_channel(adc1_channel_t channel) {
 | 
			
		||||
    this->adc_channel_ = channel;
 | 
			
		||||
    this->adc_ = true;
 | 
			
		||||
  }
 | 
			
		||||
#endif
 | 
			
		||||
 
 | 
			
		||||
@@ -193,7 +193,7 @@ def validate_local_no_higher_than_global(value):
 | 
			
		||||
Logger = logger_ns.class_("Logger", cg.Component)
 | 
			
		||||
LoggerMessageTrigger = logger_ns.class_(
 | 
			
		||||
    "LoggerMessageTrigger",
 | 
			
		||||
    automation.Trigger.template(cg.uint8, cg.const_char_ptr, cg.const_char_ptr),
 | 
			
		||||
    automation.Trigger.template(cg.int_, cg.const_char_ptr, cg.const_char_ptr),
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -390,7 +390,7 @@ async def to_code(config):
 | 
			
		||||
        await automation.build_automation(
 | 
			
		||||
            trigger,
 | 
			
		||||
            [
 | 
			
		||||
                (cg.uint8, "level"),
 | 
			
		||||
                (cg.int_, "level"),
 | 
			
		||||
                (cg.const_char_ptr, "tag"),
 | 
			
		||||
                (cg.const_char_ptr, "message"),
 | 
			
		||||
            ],
 | 
			
		||||
 
 | 
			
		||||
@@ -192,7 +192,7 @@ class WidgetType:
 | 
			
		||||
 | 
			
		||||
class NumberType(WidgetType):
 | 
			
		||||
    def get_max(self, config: dict):
 | 
			
		||||
        return int(config.get(CONF_MAX_VALUE, 100))
 | 
			
		||||
        return int(config[CONF_MAX_VALUE] or 100)
 | 
			
		||||
 | 
			
		||||
    def get_min(self, config: dict):
 | 
			
		||||
        return int(config.get(CONF_MIN_VALUE, 0))
 | 
			
		||||
        return int(config[CONF_MIN_VALUE] or 0)
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,6 @@ from esphome.const import (
 | 
			
		||||
    CONF_VALUE,
 | 
			
		||||
    CONF_WIDTH,
 | 
			
		||||
)
 | 
			
		||||
from esphome.cpp_generator import IntLiteral
 | 
			
		||||
 | 
			
		||||
from ..automation import action_to_code
 | 
			
		||||
from ..defines import (
 | 
			
		||||
@@ -189,8 +188,6 @@ class MeterType(WidgetType):
 | 
			
		||||
            rotation = 90 + (360 - scale_conf[CONF_ANGLE_RANGE]) / 2
 | 
			
		||||
            if CONF_ROTATION in scale_conf:
 | 
			
		||||
                rotation = await lv_angle.process(scale_conf[CONF_ROTATION])
 | 
			
		||||
                if isinstance(rotation, IntLiteral):
 | 
			
		||||
                    rotation = int(str(rotation)) // 10
 | 
			
		||||
            with LocalVariable(
 | 
			
		||||
                "meter_var", "lv_meter_scale_t", lv_expr.meter_add_scale(var)
 | 
			
		||||
            ) as meter_var:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
from esphome.const import CONF_SWITCH
 | 
			
		||||
 | 
			
		||||
from ..defines import CONF_INDICATOR, CONF_KNOB, CONF_MAIN
 | 
			
		||||
from ..types import LvBoolean
 | 
			
		||||
from . import WidgetType
 | 
			
		||||
 | 
			
		||||
CONF_SWITCH = "switch"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SwitchType(WidgetType):
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
 
 | 
			
		||||
@@ -77,7 +77,6 @@ ALLOWED_CLIMATE_MODES = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ALLOWED_CLIMATE_PRESETS = {
 | 
			
		||||
    "NONE": ClimatePreset.CLIMATE_PRESET_NONE,
 | 
			
		||||
    "ECO": ClimatePreset.CLIMATE_PRESET_ECO,
 | 
			
		||||
    "BOOST": ClimatePreset.CLIMATE_PRESET_BOOST,
 | 
			
		||||
    "SLEEP": ClimatePreset.CLIMATE_PRESET_SLEEP,
 | 
			
		||||
 
 | 
			
		||||
@@ -204,7 +204,7 @@ def add_pio_file(component: str, key: str, data: str):
 | 
			
		||||
        cv.validate_id_name(key)
 | 
			
		||||
    except cv.Invalid as e:
 | 
			
		||||
        raise EsphomeError(
 | 
			
		||||
            f"[{component}] Invalid PIO key: {key}. Allowed characters: [{ascii_letters}{digits}_]\nPlease report an issue https://github.com/esphome/esphome/issues"
 | 
			
		||||
            f"[{component}] Invalid PIO key: {key}. Allowed characters: [{ascii_letters}{digits}_]\nPlease report an issue https://github.com/esphome/issues"
 | 
			
		||||
        ) from e
 | 
			
		||||
    CORE.data[KEY_RP2040][KEY_PIO_FILES][key] = data
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -200,7 +200,7 @@ AudioPipelineState AudioPipeline::process_state() {
 | 
			
		||||
      if ((this->read_task_handle_ != nullptr) || (this->decode_task_handle_ != nullptr)) {
 | 
			
		||||
        this->delete_tasks_();
 | 
			
		||||
        if (this->hard_stop_) {
 | 
			
		||||
          // Stop command was sent, so immediately end the playback
 | 
			
		||||
          // Stop command was sent, so immediately end of the playback
 | 
			
		||||
          this->speaker_->stop();
 | 
			
		||||
          this->hard_stop_ = false;
 | 
			
		||||
        } else {
 | 
			
		||||
@@ -210,25 +210,13 @@ AudioPipelineState AudioPipeline::process_state() {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    this->is_playing_ = false;
 | 
			
		||||
    if (!this->speaker_->is_running()) {
 | 
			
		||||
      return AudioPipelineState::STOPPED;
 | 
			
		||||
    } else {
 | 
			
		||||
      this->is_finishing_ = true;
 | 
			
		||||
    }
 | 
			
		||||
    return AudioPipelineState::STOPPED;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (this->pause_state_) {
 | 
			
		||||
    return AudioPipelineState::PAUSED;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (this->is_finishing_) {
 | 
			
		||||
    if (!this->speaker_->is_running()) {
 | 
			
		||||
      this->is_finishing_ = false;
 | 
			
		||||
    } else {
 | 
			
		||||
      return AudioPipelineState::PLAYING;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if ((this->read_task_handle_ == nullptr) && (this->decode_task_handle_ == nullptr)) {
 | 
			
		||||
    // No tasks are running, so the pipeline is stopped.
 | 
			
		||||
    xEventGroupClearBits(this->event_group_, EventGroupBits::PIPELINE_COMMAND_STOP);
 | 
			
		||||
 
 | 
			
		||||
@@ -114,7 +114,6 @@ class AudioPipeline {
 | 
			
		||||
 | 
			
		||||
  bool hard_stop_{false};
 | 
			
		||||
  bool is_playing_{false};
 | 
			
		||||
  bool is_finishing_{false};
 | 
			
		||||
  bool pause_state_{false};
 | 
			
		||||
  bool task_stack_in_psram_;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -35,27 +35,6 @@ void VoiceAssistant::setup() {
 | 
			
		||||
      temp_ring_buffer->write((void *) data.data(), data.size());
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
#ifdef USE_MEDIA_PLAYER
 | 
			
		||||
  if (this->media_player_ != nullptr) {
 | 
			
		||||
    this->media_player_->add_on_state_callback([this]() {
 | 
			
		||||
      switch (this->media_player_->state) {
 | 
			
		||||
        case media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING:
 | 
			
		||||
          if (this->media_player_response_state_ == MediaPlayerResponseState::URL_SENT) {
 | 
			
		||||
            // State changed to announcing after receiving the url
 | 
			
		||||
            this->media_player_response_state_ = MediaPlayerResponseState::PLAYING;
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          if (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING) {
 | 
			
		||||
            // No longer announcing the TTS response
 | 
			
		||||
            this->media_player_response_state_ = MediaPlayerResponseState::FINISHED;
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
#endif
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
float VoiceAssistant::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
 | 
			
		||||
@@ -244,15 +223,7 @@ void VoiceAssistant::loop() {
 | 
			
		||||
      msg.wake_word_phrase = this->wake_word_;
 | 
			
		||||
      this->wake_word_ = "";
 | 
			
		||||
 | 
			
		||||
      // Reset media player state tracking
 | 
			
		||||
#ifdef USE_MEDIA_PLAYER
 | 
			
		||||
      if (this->media_player_ != nullptr) {
 | 
			
		||||
        this->media_player_response_state_ = MediaPlayerResponseState::IDLE;
 | 
			
		||||
      }
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
      if (this->api_client_ == nullptr ||
 | 
			
		||||
          !this->api_client_->send_message(msg, api::VoiceAssistantRequest::MESSAGE_TYPE)) {
 | 
			
		||||
      if (this->api_client_ == nullptr || !this->api_client_->send_message(msg)) {
 | 
			
		||||
        ESP_LOGW(TAG, "Could not request start");
 | 
			
		||||
        this->error_trigger_->trigger("not-connected", "Could not request start");
 | 
			
		||||
        this->continuous_ = false;
 | 
			
		||||
@@ -274,7 +245,7 @@ void VoiceAssistant::loop() {
 | 
			
		||||
        if (this->audio_mode_ == AUDIO_MODE_API) {
 | 
			
		||||
          api::VoiceAssistantAudio msg;
 | 
			
		||||
          msg.data.assign((char *) this->send_buffer_, read_bytes);
 | 
			
		||||
          this->api_client_->send_message(msg, api::VoiceAssistantAudio::MESSAGE_TYPE);
 | 
			
		||||
          this->api_client_->send_message(msg);
 | 
			
		||||
        } else {
 | 
			
		||||
          if (!this->udp_socket_running_) {
 | 
			
		||||
            if (!this->start_udp_socket_()) {
 | 
			
		||||
@@ -343,17 +314,24 @@ void VoiceAssistant::loop() {
 | 
			
		||||
#endif
 | 
			
		||||
#ifdef USE_MEDIA_PLAYER
 | 
			
		||||
      if (this->media_player_ != nullptr) {
 | 
			
		||||
        playing = (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING);
 | 
			
		||||
        playing = (this->media_player_->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING);
 | 
			
		||||
 | 
			
		||||
        if (this->media_player_response_state_ == MediaPlayerResponseState::FINISHED) {
 | 
			
		||||
          this->media_player_response_state_ = MediaPlayerResponseState::IDLE;
 | 
			
		||||
        if (playing && this->media_player_wait_for_announcement_start_) {
 | 
			
		||||
          // Announcement has started playing, wait for it to finish
 | 
			
		||||
          this->media_player_wait_for_announcement_start_ = false;
 | 
			
		||||
          this->media_player_wait_for_announcement_end_ = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!playing && this->media_player_wait_for_announcement_end_) {
 | 
			
		||||
          // Announcement has finished playing
 | 
			
		||||
          this->media_player_wait_for_announcement_end_ = false;
 | 
			
		||||
          this->cancel_timeout("playing");
 | 
			
		||||
          ESP_LOGD(TAG, "Announcement finished playing");
 | 
			
		||||
          this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED);
 | 
			
		||||
 | 
			
		||||
          api::VoiceAssistantAnnounceFinished msg;
 | 
			
		||||
          msg.success = true;
 | 
			
		||||
          this->api_client_->send_message(msg, api::VoiceAssistantAnnounceFinished::MESSAGE_TYPE);
 | 
			
		||||
          this->api_client_->send_message(msg);
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
@@ -577,7 +555,7 @@ void VoiceAssistant::request_stop() {
 | 
			
		||||
      break;
 | 
			
		||||
    case State::AWAITING_RESPONSE:
 | 
			
		||||
      this->signal_stop_();
 | 
			
		||||
      break;
 | 
			
		||||
      // Fallthrough intended to stop a streaming TTS announcement that has potentially started
 | 
			
		||||
    case State::STREAMING_RESPONSE:
 | 
			
		||||
#ifdef USE_MEDIA_PLAYER
 | 
			
		||||
      // Stop any ongoing media player announcement
 | 
			
		||||
@@ -587,10 +565,6 @@ void VoiceAssistant::request_stop() {
 | 
			
		||||
            .set_announcement(true)
 | 
			
		||||
            .perform();
 | 
			
		||||
      }
 | 
			
		||||
      if (this->started_streaming_tts_) {
 | 
			
		||||
        // Haven't reached the TTS_END stage, so send the stop signal to HA.
 | 
			
		||||
        this->signal_stop_();
 | 
			
		||||
      }
 | 
			
		||||
#endif
 | 
			
		||||
      break;
 | 
			
		||||
    case State::RESPONSE_FINISHED:
 | 
			
		||||
@@ -606,7 +580,7 @@ void VoiceAssistant::signal_stop_() {
 | 
			
		||||
  ESP_LOGD(TAG, "Signaling stop");
 | 
			
		||||
  api::VoiceAssistantRequest msg;
 | 
			
		||||
  msg.start = false;
 | 
			
		||||
  this->api_client_->send_message(msg, api::VoiceAssistantRequest::MESSAGE_TYPE);
 | 
			
		||||
  this->api_client_->send_message(msg);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void VoiceAssistant::start_playback_timeout_() {
 | 
			
		||||
@@ -616,7 +590,7 @@ void VoiceAssistant::start_playback_timeout_() {
 | 
			
		||||
 | 
			
		||||
    api::VoiceAssistantAnnounceFinished msg;
 | 
			
		||||
    msg.success = true;
 | 
			
		||||
    this->api_client_->send_message(msg, api::VoiceAssistantAnnounceFinished::MESSAGE_TYPE);
 | 
			
		||||
    this->api_client_->send_message(msg);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -674,16 +648,13 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
 | 
			
		||||
      if (this->media_player_ != nullptr) {
 | 
			
		||||
        for (const auto &arg : msg.data) {
 | 
			
		||||
          if ((arg.name == "tts_start_streaming") && (arg.value == "1") && !this->tts_response_url_.empty()) {
 | 
			
		||||
            this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
 | 
			
		||||
 | 
			
		||||
            this->media_player_->make_call().set_media_url(this->tts_response_url_).set_announcement(true).perform();
 | 
			
		||||
 | 
			
		||||
            this->media_player_wait_for_announcement_start_ = true;
 | 
			
		||||
            this->media_player_wait_for_announcement_end_ = false;
 | 
			
		||||
            this->started_streaming_tts_ = true;
 | 
			
		||||
            this->start_playback_timeout_();
 | 
			
		||||
 | 
			
		||||
            tts_url_for_trigger = this->tts_response_url_;
 | 
			
		||||
            this->tts_response_url_.clear();  // Reset streaming URL
 | 
			
		||||
            this->set_state_(State::STREAMING_RESPONSE, State::STREAMING_RESPONSE);
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
@@ -742,22 +713,18 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
 | 
			
		||||
      this->defer([this, url]() {
 | 
			
		||||
#ifdef USE_MEDIA_PLAYER
 | 
			
		||||
        if ((this->media_player_ != nullptr) && (!this->started_streaming_tts_)) {
 | 
			
		||||
          this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
 | 
			
		||||
 | 
			
		||||
          this->media_player_->make_call().set_media_url(url).set_announcement(true).perform();
 | 
			
		||||
 | 
			
		||||
          this->media_player_wait_for_announcement_start_ = true;
 | 
			
		||||
          this->media_player_wait_for_announcement_end_ = false;
 | 
			
		||||
          // Start the playback timeout, as the media player state isn't immediately updated
 | 
			
		||||
          this->start_playback_timeout_();
 | 
			
		||||
        }
 | 
			
		||||
        this->started_streaming_tts_ = false;  // Helps indicate reaching the TTS_END stage
 | 
			
		||||
#endif
 | 
			
		||||
        this->tts_end_trigger_->trigger(url);
 | 
			
		||||
      });
 | 
			
		||||
      State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE;
 | 
			
		||||
      if (new_state != this->state_) {
 | 
			
		||||
        // Don't needlessly change the state. The intent progress stage may have already changed the state to streaming
 | 
			
		||||
        // response.
 | 
			
		||||
        this->set_state_(new_state, new_state);
 | 
			
		||||
      }
 | 
			
		||||
      this->set_state_(new_state, new_state);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    case api::enums::VOICE_ASSISTANT_RUN_END: {
 | 
			
		||||
@@ -908,9 +875,6 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg)
 | 
			
		||||
#ifdef USE_MEDIA_PLAYER
 | 
			
		||||
  if (this->media_player_ != nullptr) {
 | 
			
		||||
    this->tts_start_trigger_->trigger(msg.text);
 | 
			
		||||
 | 
			
		||||
    this->media_player_response_state_ = MediaPlayerResponseState::URL_SENT;
 | 
			
		||||
 | 
			
		||||
    if (!msg.preannounce_media_id.empty()) {
 | 
			
		||||
      this->media_player_->make_call().set_media_url(msg.preannounce_media_id).set_announcement(true).perform();
 | 
			
		||||
    }
 | 
			
		||||
@@ -922,6 +886,9 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg)
 | 
			
		||||
        .perform();
 | 
			
		||||
    this->continue_conversation_ = msg.start_conversation;
 | 
			
		||||
 | 
			
		||||
    this->media_player_wait_for_announcement_start_ = true;
 | 
			
		||||
    this->media_player_wait_for_announcement_end_ = false;
 | 
			
		||||
    // Start the playback timeout, as the media player state isn't immediately updated
 | 
			
		||||
    this->start_playback_timeout_();
 | 
			
		||||
 | 
			
		||||
    if (this->continuous_) {
 | 
			
		||||
 
 | 
			
		||||
@@ -90,15 +90,6 @@ struct Configuration {
 | 
			
		||||
  uint32_t max_active_wake_words;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
#ifdef USE_MEDIA_PLAYER
 | 
			
		||||
enum class MediaPlayerResponseState {
 | 
			
		||||
  IDLE,
 | 
			
		||||
  URL_SENT,
 | 
			
		||||
  PLAYING,
 | 
			
		||||
  FINISHED,
 | 
			
		||||
};
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
class VoiceAssistant : public Component {
 | 
			
		||||
 public:
 | 
			
		||||
  VoiceAssistant();
 | 
			
		||||
@@ -281,8 +272,8 @@ class VoiceAssistant : public Component {
 | 
			
		||||
  media_player::MediaPlayer *media_player_{nullptr};
 | 
			
		||||
  std::string tts_response_url_{""};
 | 
			
		||||
  bool started_streaming_tts_{false};
 | 
			
		||||
 | 
			
		||||
  MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE};
 | 
			
		||||
  bool media_player_wait_for_announcement_start_{false};
 | 
			
		||||
  bool media_player_wait_for_announcement_end_{false};
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
  bool local_output_{false};
 | 
			
		||||
 
 | 
			
		||||
@@ -1620,9 +1620,7 @@ void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMa
 | 
			
		||||
  request->send(404);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
static std::string get_event_type(event::Event *event) {
 | 
			
		||||
  return (event && event->last_event_type) ? *event->last_event_type : "";
 | 
			
		||||
}
 | 
			
		||||
static std::string get_event_type(event::Event *event) { return event->last_event_type ? *event->last_event_type : ""; }
 | 
			
		||||
 | 
			
		||||
std::string WebServer::event_state_json_generator(WebServer *web_server, void *source) {
 | 
			
		||||
  auto *event = static_cast<event::Event *>(source);
 | 
			
		||||
 
 | 
			
		||||
@@ -210,6 +210,13 @@ void WiFiComponent::loop() {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
void WiFiComponent::on_safe_shutdown() {
 | 
			
		||||
  if (this->has_sta()) {
 | 
			
		||||
    ESP_LOGD(TAG, "Disconnecting from WiFi...");
 | 
			
		||||
    this->wifi_disconnect_();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
 | 
			
		||||
 | 
			
		||||
bool WiFiComponent::has_ap() const { return this->has_ap_; }
 | 
			
		||||
 
 | 
			
		||||
@@ -263,6 +263,9 @@ class WiFiComponent : public Component {
 | 
			
		||||
  /// Reconnect WiFi if required.
 | 
			
		||||
  void loop() override;
 | 
			
		||||
 | 
			
		||||
  /// Safely shutdown WiFi before deep sleep
 | 
			
		||||
  void on_safe_shutdown() override;
 | 
			
		||||
 | 
			
		||||
  bool has_sta() const;
 | 
			
		||||
  bool has_ap() const;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,6 @@
 | 
			
		||||
#include "esphome/core/log.h"
 | 
			
		||||
#include "esphome/core/time.h"
 | 
			
		||||
#include "esphome/components/network/util.h"
 | 
			
		||||
#include "esphome/core/helpers.h"
 | 
			
		||||
 | 
			
		||||
#include <esp_wireguard.h>
 | 
			
		||||
#include <esp_wireguard_err.h>
 | 
			
		||||
@@ -43,10 +42,7 @@ void Wireguard::setup() {
 | 
			
		||||
 | 
			
		||||
  this->publish_enabled_state();
 | 
			
		||||
 | 
			
		||||
  {
 | 
			
		||||
    LwIPLock lock;
 | 
			
		||||
    this->wg_initialized_ = esp_wireguard_init(&(this->wg_config_), &(this->wg_ctx_));
 | 
			
		||||
  }
 | 
			
		||||
  this->wg_initialized_ = esp_wireguard_init(&(this->wg_config_), &(this->wg_ctx_));
 | 
			
		||||
 | 
			
		||||
  if (this->wg_initialized_ == ESP_OK) {
 | 
			
		||||
    ESP_LOGI(TAG, "Initialized");
 | 
			
		||||
@@ -253,10 +249,7 @@ void Wireguard::start_connection_() {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ESP_LOGD(TAG, "Starting connection");
 | 
			
		||||
  {
 | 
			
		||||
    LwIPLock lock;
 | 
			
		||||
    this->wg_connected_ = esp_wireguard_connect(&(this->wg_ctx_));
 | 
			
		||||
  }
 | 
			
		||||
  this->wg_connected_ = esp_wireguard_connect(&(this->wg_ctx_));
 | 
			
		||||
 | 
			
		||||
  if (this->wg_connected_ == ESP_OK) {
 | 
			
		||||
    ESP_LOGI(TAG, "Connection started");
 | 
			
		||||
@@ -287,10 +280,7 @@ void Wireguard::start_connection_() {
 | 
			
		||||
void Wireguard::stop_connection_() {
 | 
			
		||||
  if (this->wg_initialized_ == ESP_OK && this->wg_connected_ == ESP_OK) {
 | 
			
		||||
    ESP_LOGD(TAG, "Stopping connection");
 | 
			
		||||
    {
 | 
			
		||||
      LwIPLock lock;
 | 
			
		||||
      esp_wireguard_disconnect(&(this->wg_ctx_));
 | 
			
		||||
    }
 | 
			
		||||
    esp_wireguard_disconnect(&(this->wg_ctx_));
 | 
			
		||||
    this->wg_connected_ = ESP_FAIL;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -922,7 +922,6 @@ CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic"
 | 
			
		||||
CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic"
 | 
			
		||||
CONF_SWING_OFF_ACTION = "swing_off_action"
 | 
			
		||||
CONF_SWING_VERTICAL_ACTION = "swing_vertical_action"
 | 
			
		||||
CONF_SWITCH = "switch"
 | 
			
		||||
CONF_SWITCH_DATAPOINT = "switch_datapoint"
 | 
			
		||||
CONF_SWITCHES = "switches"
 | 
			
		||||
CONF_SYNC = "sync"
 | 
			
		||||
 
 | 
			
		||||
@@ -158,14 +158,14 @@ template<typename... Ts> class DelayAction : public Action<Ts...>, public Compon
 | 
			
		||||
  void play_complex(Ts... x) override {
 | 
			
		||||
    auto f = std::bind(&DelayAction<Ts...>::play_next_, this, x...);
 | 
			
		||||
    this->num_running_++;
 | 
			
		||||
    this->set_timeout("delay", this->delay_.value(x...), f);
 | 
			
		||||
    this->set_timeout(this->delay_.value(x...), f);
 | 
			
		||||
  }
 | 
			
		||||
  float get_setup_priority() const override { return setup_priority::HARDWARE; }
 | 
			
		||||
 | 
			
		||||
  void play(Ts... x) override { /* ignore - see play_complex */
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void stop() override { this->cancel_timeout("delay"); }
 | 
			
		||||
  void stop() override { this->cancel_timeout(""); }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
template<typename... Ts> class LambdaAction : public Action<Ts...> {
 | 
			
		||||
 
 | 
			
		||||
@@ -255,10 +255,10 @@ void Component::defer(const char *name, std::function<void()> &&f) {  // NOLINT
 | 
			
		||||
  App.scheduler.set_timeout(this, name, 0, std::move(f));
 | 
			
		||||
}
 | 
			
		||||
void Component::set_timeout(uint32_t timeout, std::function<void()> &&f) {  // NOLINT
 | 
			
		||||
  App.scheduler.set_timeout(this, static_cast<const char *>(nullptr), timeout, std::move(f));
 | 
			
		||||
  App.scheduler.set_timeout(this, "", timeout, std::move(f));
 | 
			
		||||
}
 | 
			
		||||
void Component::set_interval(uint32_t interval, std::function<void()> &&f) {  // NOLINT
 | 
			
		||||
  App.scheduler.set_interval(this, static_cast<const char *>(nullptr), interval, std::move(f));
 | 
			
		||||
  App.scheduler.set_interval(this, "", interval, std::move(f));
 | 
			
		||||
}
 | 
			
		||||
void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function<RetryResult(uint8_t)> &&f,
 | 
			
		||||
                          float backoff_increase_factor) {  // NOLINT
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
#pragma once
 | 
			
		||||
 | 
			
		||||
#if defined(USE_ESP32)
 | 
			
		||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
 | 
			
		||||
 | 
			
		||||
#include <atomic>
 | 
			
		||||
#include <cstddef>
 | 
			
		||||
@@ -78,4 +78,4 @@ template<class T, uint8_t SIZE> class EventPool {
 | 
			
		||||
 | 
			
		||||
}  // namespace esphome
 | 
			
		||||
 | 
			
		||||
#endif  // defined(USE_ESP32)
 | 
			
		||||
#endif  // defined(USE_ESP32) || defined(USE_LIBRETINY)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,17 @@
 | 
			
		||||
#pragma once
 | 
			
		||||
 | 
			
		||||
#if defined(USE_ESP32)
 | 
			
		||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
 | 
			
		||||
 | 
			
		||||
#include <atomic>
 | 
			
		||||
#include <cstddef>
 | 
			
		||||
 | 
			
		||||
#if defined(USE_ESP32)
 | 
			
		||||
#include <freertos/FreeRTOS.h>
 | 
			
		||||
#include <freertos/task.h>
 | 
			
		||||
#elif defined(USE_LIBRETINY)
 | 
			
		||||
#include <FreeRTOS.h>
 | 
			
		||||
#include <task.h>
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Lock-free queue for single-producer single-consumer scenarios.
 | 
			
		||||
@@ -143,4 +148,4 @@ template<class T, uint8_t SIZE> class NotifyingLockFreeQueue : public LockFreeQu
 | 
			
		||||
 | 
			
		||||
}  // namespace esphome
 | 
			
		||||
 | 
			
		||||
#endif  // defined(USE_ESP32)
 | 
			
		||||
#endif  // defined(USE_ESP32) || defined(USE_LIBRETINY)
 | 
			
		||||
 
 | 
			
		||||
@@ -8,15 +8,12 @@
 | 
			
		||||
#include <algorithm>
 | 
			
		||||
#include <cinttypes>
 | 
			
		||||
#include <cstring>
 | 
			
		||||
#include <limits>
 | 
			
		||||
 | 
			
		||||
namespace esphome {
 | 
			
		||||
 | 
			
		||||
static const char *const TAG = "scheduler";
 | 
			
		||||
 | 
			
		||||
static const uint32_t MAX_LOGICALLY_DELETED_ITEMS = 10;
 | 
			
		||||
// Half the 32-bit range - used to detect rollovers vs normal time progression
 | 
			
		||||
static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits<uint32_t>::max() / 2;
 | 
			
		||||
 | 
			
		||||
// Uncomment to debug scheduler
 | 
			
		||||
// #define ESPHOME_DEBUG_SCHEDULER
 | 
			
		||||
@@ -94,8 +91,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
 | 
			
		||||
  }
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
  // Get fresh timestamp for new timer/interval - ensures accurate scheduling
 | 
			
		||||
  const auto now = this->millis_64_(millis());  // Fresh millis() call
 | 
			
		||||
  const auto now = this->millis_64_(millis());
 | 
			
		||||
 | 
			
		||||
  // Type-specific setup
 | 
			
		||||
  if (type == SchedulerItem::INTERVAL) {
 | 
			
		||||
@@ -224,8 +220,7 @@ optional<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
 | 
			
		||||
  if (this->empty_())
 | 
			
		||||
    return {};
 | 
			
		||||
  auto &item = this->items_[0];
 | 
			
		||||
  // Convert the fresh timestamp from caller (usually Application::loop()) to 64-bit
 | 
			
		||||
  const auto now_64 = this->millis_64_(now);  // 'now' from parameter - fresh from caller
 | 
			
		||||
  const auto now_64 = this->millis_64_(now);
 | 
			
		||||
  if (item->next_execution_ < now_64)
 | 
			
		||||
    return 0;
 | 
			
		||||
  return item->next_execution_ - now_64;
 | 
			
		||||
@@ -264,8 +259,7 @@ void HOT Scheduler::call(uint32_t now) {
 | 
			
		||||
  }
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
  // Convert the fresh timestamp from main loop to 64-bit for scheduler operations
 | 
			
		||||
  const auto now_64 = this->millis_64_(now);  // 'now' from parameter - fresh from Application::loop()
 | 
			
		||||
  const auto now_64 = this->millis_64_(now);
 | 
			
		||||
  this->process_to_add();
 | 
			
		||||
 | 
			
		||||
#ifdef ESPHOME_DEBUG_SCHEDULER
 | 
			
		||||
@@ -274,13 +268,8 @@ void HOT Scheduler::call(uint32_t now) {
 | 
			
		||||
  if (now_64 - last_print > 2000) {
 | 
			
		||||
    last_print = now_64;
 | 
			
		||||
    std::vector<std::unique_ptr<SchedulerItem>> old_items;
 | 
			
		||||
#if !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY)
 | 
			
		||||
    ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%u, %" PRIu32 ")", this->items_.size(), now_64,
 | 
			
		||||
             this->millis_major_, this->last_millis_.load(std::memory_order_relaxed));
 | 
			
		||||
#else
 | 
			
		||||
    ESP_LOGD(TAG, "Items: count=%zu, now=%" PRIu64 " (%u, %" PRIu32 ")", this->items_.size(), now_64,
 | 
			
		||||
             this->millis_major_, this->last_millis_);
 | 
			
		||||
#endif
 | 
			
		||||
    while (!this->empty_()) {
 | 
			
		||||
      std::unique_ptr<SchedulerItem> item;
 | 
			
		||||
      {
 | 
			
		||||
@@ -453,7 +442,7 @@ bool HOT Scheduler::cancel_item_(Component *component, bool is_static_string, co
 | 
			
		||||
// Helper to cancel items by name - must be called with lock held
 | 
			
		||||
bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_cstr, SchedulerItem::Type type) {
 | 
			
		||||
  // Early return if name is invalid - no items to cancel
 | 
			
		||||
  if (name_cstr == nullptr) {
 | 
			
		||||
  if (name_cstr == nullptr || name_cstr[0] == '\0') {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -494,111 +483,16 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
uint64_t Scheduler::millis_64_(uint32_t now) {
 | 
			
		||||
  // THREAD SAFETY NOTE:
 | 
			
		||||
  // This function can be called from multiple threads simultaneously on ESP32/LibreTiny.
 | 
			
		||||
  // On single-threaded platforms (ESP8266, RP2040), atomics are not needed.
 | 
			
		||||
  //
 | 
			
		||||
  // IMPORTANT: Always pass fresh millis() values to this function. The implementation
 | 
			
		||||
  // handles out-of-order timestamps between threads, but minimizing time differences
 | 
			
		||||
  // helps maintain accuracy.
 | 
			
		||||
  //
 | 
			
		||||
  // The implementation handles the 32-bit rollover (every 49.7 days) by:
 | 
			
		||||
  // 1. Using a lock when detecting rollover to ensure atomic update
 | 
			
		||||
  // 2. Restricting normal updates to forward movement within the same epoch
 | 
			
		||||
  // This prevents race conditions at the rollover boundary without requiring
 | 
			
		||||
  // 64-bit atomics or locking on every call.
 | 
			
		||||
 | 
			
		||||
#ifdef USE_LIBRETINY
 | 
			
		||||
  // LibreTiny: Multi-threaded but lacks atomic operation support
 | 
			
		||||
  // TODO: If LibreTiny ever adds atomic support, remove this entire block and
 | 
			
		||||
  // let it fall through to the atomic-based implementation below
 | 
			
		||||
  // We need to use a lock when near the rollover boundary to prevent races
 | 
			
		||||
  uint32_t last = this->last_millis_;
 | 
			
		||||
 | 
			
		||||
  // Define a safe window around the rollover point (10 seconds)
 | 
			
		||||
  // This covers any reasonable scheduler delays or thread preemption
 | 
			
		||||
  static const uint32_t ROLLOVER_WINDOW = 10000;  // 10 seconds in milliseconds
 | 
			
		||||
 | 
			
		||||
  // Check if we're near the rollover boundary (close to std::numeric_limits<uint32_t>::max() or just past 0)
 | 
			
		||||
  bool near_rollover = (last > (std::numeric_limits<uint32_t>::max() - ROLLOVER_WINDOW)) || (now < ROLLOVER_WINDOW);
 | 
			
		||||
 | 
			
		||||
  if (near_rollover || (now < last && (last - now) > HALF_MAX_UINT32)) {
 | 
			
		||||
    // Near rollover or detected a rollover - need lock for safety
 | 
			
		||||
    LockGuard guard{this->lock_};
 | 
			
		||||
    // Re-read with lock held
 | 
			
		||||
    last = this->last_millis_;
 | 
			
		||||
 | 
			
		||||
    if (now < last && (last - now) > HALF_MAX_UINT32) {
 | 
			
		||||
      // True rollover detected (happens every ~49.7 days)
 | 
			
		||||
      this->millis_major_++;
 | 
			
		||||
#ifdef ESPHOME_DEBUG_SCHEDULER
 | 
			
		||||
      ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
 | 
			
		||||
#endif
 | 
			
		||||
    }
 | 
			
		||||
    // Update last_millis_ while holding lock
 | 
			
		||||
    this->last_millis_ = now;
 | 
			
		||||
  } else if (now > last) {
 | 
			
		||||
    // Normal case: Not near rollover and time moved forward
 | 
			
		||||
    // Update without lock. While this may cause minor races (microseconds of
 | 
			
		||||
    // backwards time movement), they're acceptable because:
 | 
			
		||||
    // 1. The scheduler operates at millisecond resolution, not microsecond
 | 
			
		||||
    // 2. We've already prevented the critical rollover race condition
 | 
			
		||||
    // 3. Any backwards movement is orders of magnitude smaller than scheduler delays
 | 
			
		||||
    this->last_millis_ = now;
 | 
			
		||||
  }
 | 
			
		||||
  // If now <= last and we're not near rollover, don't update
 | 
			
		||||
  // This minimizes backwards time movement
 | 
			
		||||
 | 
			
		||||
#elif !defined(USE_ESP8266) && !defined(USE_RP2040)
 | 
			
		||||
  // Multi-threaded platforms with atomic support (ESP32)
 | 
			
		||||
  uint32_t last = this->last_millis_.load(std::memory_order_relaxed);
 | 
			
		||||
 | 
			
		||||
  // If we might be near a rollover (large backwards jump), take the lock for the entire operation
 | 
			
		||||
  // This ensures rollover detection and last_millis_ update are atomic together
 | 
			
		||||
  if (now < last && (last - now) > HALF_MAX_UINT32) {
 | 
			
		||||
    // Potential rollover - need lock for atomic rollover detection + update
 | 
			
		||||
    LockGuard guard{this->lock_};
 | 
			
		||||
    // Re-read with lock held
 | 
			
		||||
    last = this->last_millis_.load(std::memory_order_relaxed);
 | 
			
		||||
 | 
			
		||||
    if (now < last && (last - now) > HALF_MAX_UINT32) {
 | 
			
		||||
      // True rollover detected (happens every ~49.7 days)
 | 
			
		||||
      this->millis_major_++;
 | 
			
		||||
#ifdef ESPHOME_DEBUG_SCHEDULER
 | 
			
		||||
      ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
 | 
			
		||||
#endif
 | 
			
		||||
    }
 | 
			
		||||
    // Update last_millis_ while holding lock to prevent races
 | 
			
		||||
    this->last_millis_.store(now, std::memory_order_relaxed);
 | 
			
		||||
  } else {
 | 
			
		||||
    // Normal case: Try lock-free update, but only allow forward movement within same epoch
 | 
			
		||||
    // This prevents accidentally moving backwards across a rollover boundary
 | 
			
		||||
    while (now > last && (now - last) < HALF_MAX_UINT32) {
 | 
			
		||||
      if (this->last_millis_.compare_exchange_weak(last, now, std::memory_order_relaxed)) {
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      // last is automatically updated by compare_exchange_weak if it fails
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
#else
 | 
			
		||||
  // Single-threaded platforms (ESP8266, RP2040): No atomics needed
 | 
			
		||||
  uint32_t last = this->last_millis_;
 | 
			
		||||
 | 
			
		||||
  // Check for rollover
 | 
			
		||||
  if (now < last && (last - now) > HALF_MAX_UINT32) {
 | 
			
		||||
  // Check for rollover by comparing with last value
 | 
			
		||||
  if (now < this->last_millis_) {
 | 
			
		||||
    // Detected rollover (happens every ~49.7 days)
 | 
			
		||||
    this->millis_major_++;
 | 
			
		||||
#ifdef ESPHOME_DEBUG_SCHEDULER
 | 
			
		||||
    ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
 | 
			
		||||
    ESP_LOGD(TAG, "Incrementing scheduler major at %" PRIu64 "ms",
 | 
			
		||||
             now + (static_cast<uint64_t>(this->millis_major_) << 32));
 | 
			
		||||
#endif
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Only update if time moved forward
 | 
			
		||||
  if (now > last) {
 | 
			
		||||
    this->last_millis_ = now;
 | 
			
		||||
  }
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
  this->last_millis_ = now;
 | 
			
		||||
  // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time
 | 
			
		||||
  return now + (static_cast<uint64_t>(this->millis_major_) << 32);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,9 +4,6 @@
 | 
			
		||||
#include <memory>
 | 
			
		||||
#include <cstring>
 | 
			
		||||
#include <deque>
 | 
			
		||||
#if !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY)
 | 
			
		||||
#include <atomic>
 | 
			
		||||
#endif
 | 
			
		||||
 | 
			
		||||
#include "esphome/core/component.h"
 | 
			
		||||
#include "esphome/core/helpers.h"
 | 
			
		||||
@@ -55,12 +52,8 @@ class Scheduler {
 | 
			
		||||
                 std::function<RetryResult(uint8_t)> func, float backoff_increase_factor = 1.0f);
 | 
			
		||||
  bool cancel_retry(Component *component, const std::string &name);
 | 
			
		||||
 | 
			
		||||
  // Calculate when the next scheduled item should run
 | 
			
		||||
  // @param now Fresh timestamp from millis() - must not be stale/cached
 | 
			
		||||
  optional<uint32_t> next_schedule_in(uint32_t now);
 | 
			
		||||
 | 
			
		||||
  // Execute all scheduled items that are ready
 | 
			
		||||
  // @param now Fresh timestamp from millis() - must not be stale/cached
 | 
			
		||||
  void call(uint32_t now);
 | 
			
		||||
 | 
			
		||||
  void process_to_add();
 | 
			
		||||
@@ -121,17 +114,16 @@ class Scheduler {
 | 
			
		||||
        name_is_dynamic = false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!name) {
 | 
			
		||||
        // nullptr case - no name provided
 | 
			
		||||
      if (!name || !name[0]) {
 | 
			
		||||
        name_.static_name = nullptr;
 | 
			
		||||
      } else if (make_copy) {
 | 
			
		||||
        // Make a copy for dynamic strings (including empty strings)
 | 
			
		||||
        // Make a copy for dynamic strings
 | 
			
		||||
        size_t len = strlen(name);
 | 
			
		||||
        name_.dynamic_name = new char[len + 1];
 | 
			
		||||
        memcpy(name_.dynamic_name, name, len + 1);
 | 
			
		||||
        name_is_dynamic = true;
 | 
			
		||||
      } else {
 | 
			
		||||
        // Use static string directly (including empty strings)
 | 
			
		||||
        // Use static string directly
 | 
			
		||||
        name_.static_name = name;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@@ -211,14 +203,7 @@ class Scheduler {
 | 
			
		||||
  // Both platforms save 40 bytes of RAM by excluding this
 | 
			
		||||
  std::deque<std::unique_ptr<SchedulerItem>> defer_queue_;  // FIFO queue for defer() calls
 | 
			
		||||
#endif
 | 
			
		||||
#if !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY)
 | 
			
		||||
  // Multi-threaded platforms with atomic support: last_millis_ needs atomic for lock-free updates
 | 
			
		||||
  std::atomic<uint32_t> last_millis_{0};
 | 
			
		||||
#else
 | 
			
		||||
  // Platforms without atomic support or single-threaded platforms
 | 
			
		||||
  uint32_t last_millis_{0};
 | 
			
		||||
#endif
 | 
			
		||||
  // millis_major_ is protected by lock when incrementing
 | 
			
		||||
  uint16_t millis_major_{0};
 | 
			
		||||
  uint32_t to_remove_{0};
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -138,7 +138,7 @@ lib_deps =
 | 
			
		||||
    WiFi                                 ; wifi,web_server_base,ethernet (Arduino built-in)
 | 
			
		||||
    Update                               ; ota,web_server_base (Arduino built-in)
 | 
			
		||||
    ${common:arduino.lib_deps}
 | 
			
		||||
    ESP32Async/AsyncTCP@3.4.5            ; async_tcp
 | 
			
		||||
    ESP32Async/AsyncTCP@3.4.4            ; async_tcp
 | 
			
		||||
    NetworkClientSecure                  ; http_request,nextion (Arduino built-in)
 | 
			
		||||
    HTTPClient                           ; http_request,nextion (Arduino built-in)
 | 
			
		||||
    ESPmDNS                              ; mdns (Arduino built-in)
 | 
			
		||||
 
 | 
			
		||||
@@ -27,8 +27,8 @@ dynamic = ["dependencies", "optional-dependencies", "version"]
 | 
			
		||||
[project.urls]
 | 
			
		||||
"Documentation"           = "https://esphome.io"
 | 
			
		||||
"Source Code"             = "https://github.com/esphome/esphome"
 | 
			
		||||
"Bug Tracker"             = "https://github.com/esphome/esphome/issues"
 | 
			
		||||
"Feature Request Tracker" = "https://github.com/orgs/esphome/discussions"
 | 
			
		||||
"Bug Tracker"             = "https://github.com/esphome/issues/issues"
 | 
			
		||||
"Feature Request Tracker" = "https://github.com/esphome/feature-requests/issues"
 | 
			
		||||
"Discord"                 = "https://discord.gg/KhAMKrd"
 | 
			
		||||
"Forum"                   = "https://community.home-assistant.io/c/esphome"
 | 
			
		||||
"Twitter"                 = "https://twitter.com/esphome_"
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,7 @@ platformio==6.1.18  # When updating platformio, also update /docker/Dockerfile
 | 
			
		||||
esptool==4.9.0
 | 
			
		||||
click==8.1.7
 | 
			
		||||
esphome-dashboard==20250514.0
 | 
			
		||||
aioesphomeapi==37.0.1
 | 
			
		||||
aioesphomeapi==36.0.0
 | 
			
		||||
zeroconf==0.147.0
 | 
			
		||||
puremagic==1.30
 | 
			
		||||
ruamel.yaml==0.18.14 # dashboard_import
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
pylint==3.3.7
 | 
			
		||||
flake8==7.3.0  # also change in .pre-commit-config.yaml when updating
 | 
			
		||||
ruff==0.12.4  # also change in .pre-commit-config.yaml when updating
 | 
			
		||||
ruff==0.12.3  # also change in .pre-commit-config.yaml when updating
 | 
			
		||||
pyupgrade==3.20.0  # also change in .pre-commit-config.yaml when updating
 | 
			
		||||
pre-commit
 | 
			
		||||
 | 
			
		||||
@@ -8,7 +8,7 @@ pre-commit
 | 
			
		||||
pytest==8.4.1
 | 
			
		||||
pytest-cov==6.2.1
 | 
			
		||||
pytest-mock==3.14.1
 | 
			
		||||
pytest-asyncio==1.1.0
 | 
			
		||||
pytest-xdist==3.8.0
 | 
			
		||||
pytest-asyncio==1.0.0
 | 
			
		||||
pytest-xdist==3.7.0
 | 
			
		||||
asyncmock==0.4.2
 | 
			
		||||
hypothesis==6.92.1
 | 
			
		||||
 
 | 
			
		||||
@@ -313,18 +313,13 @@ def validate_field_type(field_type: int, field_name: str = "") -> None:
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_field_type_info(field: descriptor.FieldDescriptorProto) -> TypeInfo:
 | 
			
		||||
    """Create the appropriate TypeInfo instance for a field, handling repeated fields and custom options."""
 | 
			
		||||
def get_type_info_for_field(field: descriptor.FieldDescriptorProto) -> TypeInfo:
 | 
			
		||||
    """Get the appropriate TypeInfo for a field, handling repeated fields.
 | 
			
		||||
 | 
			
		||||
    Also validates that the field type is supported.
 | 
			
		||||
    """
 | 
			
		||||
    if field.label == 3:  # repeated
 | 
			
		||||
        return RepeatedTypeInfo(field)
 | 
			
		||||
 | 
			
		||||
    # Check for fixed_array_size option on bytes fields
 | 
			
		||||
    if (
 | 
			
		||||
        field.type == 12
 | 
			
		||||
        and (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None
 | 
			
		||||
    ):
 | 
			
		||||
        return FixedArrayBytesType(field, fixed_size)
 | 
			
		||||
 | 
			
		||||
    validate_field_type(field.type, field.name)
 | 
			
		||||
    return TYPE_INFO[field.type](field)
 | 
			
		||||
 | 
			
		||||
@@ -608,85 +603,6 @@ class BytesType(TypeInfo):
 | 
			
		||||
        return self.calculate_field_id_size() + 8  # field ID + 8 bytes typical bytes
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FixedArrayBytesType(TypeInfo):
 | 
			
		||||
    """Special type for fixed-size byte arrays."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, field: descriptor.FieldDescriptorProto, size: int) -> None:
 | 
			
		||||
        super().__init__(field)
 | 
			
		||||
        self.array_size = size
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def cpp_type(self) -> str:
 | 
			
		||||
        return "uint8_t"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def default_value(self) -> str:
 | 
			
		||||
        return "{}"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def reference_type(self) -> str:
 | 
			
		||||
        return f"uint8_t (&)[{self.array_size}]"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def const_reference_type(self) -> str:
 | 
			
		||||
        return f"const uint8_t (&)[{self.array_size}]"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def public_content(self) -> list[str]:
 | 
			
		||||
        # Add both the array and length fields
 | 
			
		||||
        return [
 | 
			
		||||
            f"uint8_t {self.field_name}[{self.array_size}]{{}};",
 | 
			
		||||
            f"uint8_t {self.field_name}_len{{0}};",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def decode_length_content(self) -> str:
 | 
			
		||||
        o = f"case {self.number}: {{\n"
 | 
			
		||||
        o += "  const std::string &data_str = value.as_string();\n"
 | 
			
		||||
        o += f"  this->{self.field_name}_len = data_str.size();\n"
 | 
			
		||||
        o += f"  if (this->{self.field_name}_len > {self.array_size}) {{\n"
 | 
			
		||||
        o += f"    this->{self.field_name}_len = {self.array_size};\n"
 | 
			
		||||
        o += "  }\n"
 | 
			
		||||
        o += f"  memcpy(this->{self.field_name}, data_str.data(), this->{self.field_name}_len);\n"
 | 
			
		||||
        o += "  break;\n"
 | 
			
		||||
        o += "}"
 | 
			
		||||
        return o
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def encode_content(self) -> str:
 | 
			
		||||
        return f"buffer.encode_bytes({self.number}, this->{self.field_name}, this->{self.field_name}_len);"
 | 
			
		||||
 | 
			
		||||
    def dump(self, name: str) -> str:
 | 
			
		||||
        o = f"out.append(format_hex_pretty({name}, {name}_len));"
 | 
			
		||||
        return o
 | 
			
		||||
 | 
			
		||||
    def get_size_calculation(self, name: str, force: bool = False) -> str:
 | 
			
		||||
        # Use the actual length stored in the _len field
 | 
			
		||||
        length_field = f"this->{self.field_name}_len"
 | 
			
		||||
        field_id_size = self.calculate_field_id_size()
 | 
			
		||||
 | 
			
		||||
        if force:
 | 
			
		||||
            # For repeated fields, always calculate size
 | 
			
		||||
            return f"total_size += {field_id_size} + ProtoSize::varint(static_cast<uint32_t>({length_field})) + {length_field};"
 | 
			
		||||
        else:
 | 
			
		||||
            # For non-repeated fields, skip if length is 0 (matching encode_string behavior)
 | 
			
		||||
            return (
 | 
			
		||||
                f"if ({length_field} != 0) {{\n"
 | 
			
		||||
                f"  total_size += {field_id_size} + ProtoSize::varint(static_cast<uint32_t>({length_field})) + {length_field};\n"
 | 
			
		||||
                f"}}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def get_estimated_size(self) -> int:
 | 
			
		||||
        # Estimate based on typical BLE advertisement size
 | 
			
		||||
        return (
 | 
			
		||||
            self.calculate_field_id_size() + 1 + 31
 | 
			
		||||
        )  # field ID + length byte + typical 31 bytes
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def wire_type(self) -> WireType:
 | 
			
		||||
        return WireType.LENGTH_DELIMITED
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@register_type(13)
 | 
			
		||||
class UInt32Type(TypeInfo):
 | 
			
		||||
    cpp_type = "uint32_t"
 | 
			
		||||
@@ -832,16 +748,6 @@ class SInt64Type(TypeInfo):
 | 
			
		||||
class RepeatedTypeInfo(TypeInfo):
 | 
			
		||||
    def __init__(self, field: descriptor.FieldDescriptorProto) -> None:
 | 
			
		||||
        super().__init__(field)
 | 
			
		||||
        # For repeated fields, we need to get the base type info
 | 
			
		||||
        # but we can't call create_field_type_info as it would cause recursion
 | 
			
		||||
        # So we extract just the type creation logic
 | 
			
		||||
        if (
 | 
			
		||||
            field.type == 12
 | 
			
		||||
            and (fixed_size := get_field_opt(field, pb.fixed_array_size)) is not None
 | 
			
		||||
        ):
 | 
			
		||||
            self._ti: TypeInfo = FixedArrayBytesType(field, fixed_size)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        validate_field_type(field.type, field.name)
 | 
			
		||||
        self._ti: TypeInfo = TYPE_INFO[field.type](field)
 | 
			
		||||
 | 
			
		||||
@@ -1145,7 +1051,7 @@ def calculate_message_estimated_size(desc: descriptor.DescriptorProto) -> int:
 | 
			
		||||
    total_size = 0
 | 
			
		||||
 | 
			
		||||
    for field in desc.field:
 | 
			
		||||
        ti = create_field_type_info(field)
 | 
			
		||||
        ti = get_type_info_for_field(field)
 | 
			
		||||
 | 
			
		||||
        # Add estimated size for this field
 | 
			
		||||
        total_size += ti.get_estimated_size()
 | 
			
		||||
@@ -1213,7 +1119,10 @@ def build_message_type(
 | 
			
		||||
        public_content.append("#endif")
 | 
			
		||||
 | 
			
		||||
    for field in desc.field:
 | 
			
		||||
        ti = create_field_type_info(field)
 | 
			
		||||
        if field.label == 3:
 | 
			
		||||
            ti = RepeatedTypeInfo(field)
 | 
			
		||||
        else:
 | 
			
		||||
            ti = TYPE_INFO[field.type](field)
 | 
			
		||||
 | 
			
		||||
        # Skip field declarations for fields that are in the base class
 | 
			
		||||
        # but include their encode/decode logic
 | 
			
		||||
@@ -1418,17 +1327,6 @@ def get_opt(
 | 
			
		||||
    return desc.options.Extensions[opt]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_field_opt(
 | 
			
		||||
    field: descriptor.FieldDescriptorProto,
 | 
			
		||||
    opt: descriptor.FieldOptions,
 | 
			
		||||
    default: Any = None,
 | 
			
		||||
) -> Any:
 | 
			
		||||
    """Get the option from a field descriptor."""
 | 
			
		||||
    if not field.options.HasExtension(opt):
 | 
			
		||||
        return default
 | 
			
		||||
    return field.options.Extensions[opt]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_base_class(desc: descriptor.DescriptorProto) -> str | None:
 | 
			
		||||
    """Get the base_class option from a message descriptor."""
 | 
			
		||||
    if not desc.options.HasExtension(pb.base_class):
 | 
			
		||||
@@ -1495,7 +1393,6 @@ def build_base_class(
 | 
			
		||||
    base_class_name: str,
 | 
			
		||||
    common_fields: list[descriptor.FieldDescriptorProto],
 | 
			
		||||
    messages: list[descriptor.DescriptorProto],
 | 
			
		||||
    message_source_map: dict[str, int],
 | 
			
		||||
) -> tuple[str, str, str]:
 | 
			
		||||
    """Build the base class definition and implementation."""
 | 
			
		||||
    public_content = []
 | 
			
		||||
@@ -1504,7 +1401,7 @@ def build_base_class(
 | 
			
		||||
    # For base classes, we only declare the fields but don't handle encode/decode
 | 
			
		||||
    # The derived classes will handle encoding/decoding with their specific field numbers
 | 
			
		||||
    for field in common_fields:
 | 
			
		||||
        ti = create_field_type_info(field)
 | 
			
		||||
        ti = get_type_info_for_field(field)
 | 
			
		||||
 | 
			
		||||
        # Only add field declarations, not encode/decode logic
 | 
			
		||||
        protected_content.extend(ti.protected_content)
 | 
			
		||||
@@ -1512,7 +1409,7 @@ def build_base_class(
 | 
			
		||||
 | 
			
		||||
    # Determine if any message using this base class needs decoding
 | 
			
		||||
    needs_decode = any(
 | 
			
		||||
        message_source_map.get(msg.name, SOURCE_BOTH) in (SOURCE_BOTH, SOURCE_CLIENT)
 | 
			
		||||
        get_opt(msg, pb.source, SOURCE_BOTH) in (SOURCE_BOTH, SOURCE_CLIENT)
 | 
			
		||||
        for msg in messages
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@@ -1544,7 +1441,6 @@ def build_base_class(
 | 
			
		||||
 | 
			
		||||
def generate_base_classes(
 | 
			
		||||
    base_class_groups: dict[str, list[descriptor.DescriptorProto]],
 | 
			
		||||
    message_source_map: dict[str, int],
 | 
			
		||||
) -> tuple[str, str, str]:
 | 
			
		||||
    """Generate all base classes."""
 | 
			
		||||
    all_headers = []
 | 
			
		||||
@@ -1558,7 +1454,7 @@ def generate_base_classes(
 | 
			
		||||
        if common_fields:
 | 
			
		||||
            # Generate base class
 | 
			
		||||
            header, cpp, dump_cpp = build_base_class(
 | 
			
		||||
                base_class_name, common_fields, messages, message_source_map
 | 
			
		||||
                base_class_name, common_fields, messages
 | 
			
		||||
            )
 | 
			
		||||
            all_headers.append(header)
 | 
			
		||||
            all_cpp.append(cpp)
 | 
			
		||||
@@ -1569,7 +1465,6 @@ def generate_base_classes(
 | 
			
		||||
 | 
			
		||||
def build_service_message_type(
 | 
			
		||||
    mt: descriptor.DescriptorProto,
 | 
			
		||||
    message_source_map: dict[str, int],
 | 
			
		||||
) -> tuple[str, str] | None:
 | 
			
		||||
    """Builds the service message type."""
 | 
			
		||||
    snake = camel_to_snake(mt.name)
 | 
			
		||||
@@ -1577,7 +1472,7 @@ def build_service_message_type(
 | 
			
		||||
    if id_ is None:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    source: int = message_source_map.get(mt.name, SOURCE_BOTH)
 | 
			
		||||
    source: int = get_opt(mt, pb.source, 0)
 | 
			
		||||
 | 
			
		||||
    ifdef: str | None = get_opt(mt, pb.ifdef)
 | 
			
		||||
    log: bool = get_opt(mt, pb.log, True)
 | 
			
		||||
@@ -1648,7 +1543,6 @@ namespace api {
 | 
			
		||||
    #include "api_pb2.h"
 | 
			
		||||
    #include "esphome/core/log.h"
 | 
			
		||||
    #include "esphome/core/helpers.h"
 | 
			
		||||
    #include <cstring>
 | 
			
		||||
 | 
			
		||||
namespace esphome {
 | 
			
		||||
namespace api {
 | 
			
		||||
@@ -1717,9 +1611,7 @@ namespace api {
 | 
			
		||||
 | 
			
		||||
    # Generate base classes
 | 
			
		||||
    if base_class_fields:
 | 
			
		||||
        base_headers, base_cpp, base_dump_cpp = generate_base_classes(
 | 
			
		||||
            base_class_groups, message_source_map
 | 
			
		||||
        )
 | 
			
		||||
        base_headers, base_cpp, base_dump_cpp = generate_base_classes(base_class_groups)
 | 
			
		||||
        content += base_headers
 | 
			
		||||
        cpp += base_cpp
 | 
			
		||||
        dump_cpp += base_dump_cpp
 | 
			
		||||
@@ -1821,12 +1713,13 @@ static const char *const TAG = "api.service";
 | 
			
		||||
    hpp += " public:\n"
 | 
			
		||||
    hpp += "#endif\n\n"
 | 
			
		||||
 | 
			
		||||
    # Add non-template send_message method
 | 
			
		||||
    hpp += "  bool send_message(const ProtoMessage &msg, uint8_t message_type) {\n"
 | 
			
		||||
    # Add generic send_message method
 | 
			
		||||
    hpp += "  template<typename T>\n"
 | 
			
		||||
    hpp += "  bool send_message(const T &msg) {\n"
 | 
			
		||||
    hpp += "#ifdef HAS_PROTO_MESSAGE_DUMP\n"
 | 
			
		||||
    hpp += "    this->log_send_message_(msg.message_name(), msg.dump());\n"
 | 
			
		||||
    hpp += "#endif\n"
 | 
			
		||||
    hpp += "    return this->send_message_(msg, message_type);\n"
 | 
			
		||||
    hpp += "    return this->send_message_(msg, T::MESSAGE_TYPE);\n"
 | 
			
		||||
    hpp += "  }\n\n"
 | 
			
		||||
 | 
			
		||||
    # Add logging helper method implementation to cpp
 | 
			
		||||
@@ -1837,7 +1730,7 @@ static const char *const TAG = "api.service";
 | 
			
		||||
    cpp += "#endif\n\n"
 | 
			
		||||
 | 
			
		||||
    for mt in file.message_type:
 | 
			
		||||
        obj = build_service_message_type(mt, message_source_map)
 | 
			
		||||
        obj = build_service_message_type(mt)
 | 
			
		||||
        if obj is None:
 | 
			
		||||
            continue
 | 
			
		||||
        hout, cout = obj
 | 
			
		||||
@@ -1912,9 +1805,7 @@ static const char *const TAG = "api.service";
 | 
			
		||||
                handler_body = f"this->{func}(msg);\n"
 | 
			
		||||
            else:
 | 
			
		||||
                handler_body = f"{ret} ret = this->{func}(msg);\n"
 | 
			
		||||
                handler_body += (
 | 
			
		||||
                    f"if (!this->send_message(ret, {ret}::MESSAGE_TYPE)) {{\n"
 | 
			
		||||
                )
 | 
			
		||||
                handler_body += "if (!this->send_message(ret)) {\n"
 | 
			
		||||
                handler_body += "  this->on_fatal_error();\n"
 | 
			
		||||
                handler_body += "}\n"
 | 
			
		||||
 | 
			
		||||
@@ -1927,7 +1818,7 @@ static const char *const TAG = "api.service";
 | 
			
		||||
                body += f"this->{func}(msg);\n"
 | 
			
		||||
            else:
 | 
			
		||||
                body += f"{ret} ret = this->{func}(msg);\n"
 | 
			
		||||
                body += f"if (!this->send_message(ret, {ret}::MESSAGE_TYPE)) {{\n"
 | 
			
		||||
                body += "if (!this->send_message(ret)) {\n"
 | 
			
		||||
                body += "  this->on_fatal_error();\n"
 | 
			
		||||
                body += "}\n"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -31,7 +31,6 @@ BASE = """
 | 
			
		||||
pyproject.toml @esphome/core
 | 
			
		||||
esphome/*.py @esphome/core
 | 
			
		||||
esphome/core/* @esphome/core
 | 
			
		||||
.github/** @esphome/core
 | 
			
		||||
 | 
			
		||||
# Integrations
 | 
			
		||||
""".strip()
 | 
			
		||||
 
 | 
			
		||||
@@ -241,9 +241,6 @@ def lint_ext_check(fname):
 | 
			
		||||
        "docker/ha-addon-rootfs/**",
 | 
			
		||||
        "docker/*.py",
 | 
			
		||||
        "script/*",
 | 
			
		||||
        "CLAUDE.md",
 | 
			
		||||
        "GEMINI.md",
 | 
			
		||||
        ".github/copilot-instructions.md",
 | 
			
		||||
    ]
 | 
			
		||||
)
 | 
			
		||||
def lint_executable_bit(fname):
 | 
			
		||||
 
 | 
			
		||||
@@ -42,3 +42,18 @@ def test_deep_sleep_run_duration_dictionary(generate_main):
 | 
			
		||||
        "    .gpio_cause = 30000,\n"
 | 
			
		||||
        "});"
 | 
			
		||||
    ) in main_cpp
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_deep_sleep_with_wifi_esp8266(generate_main):
 | 
			
		||||
    """
 | 
			
		||||
    When deep sleep is configured with WiFi on ESP8266, WiFi component should be included.
 | 
			
		||||
    """
 | 
			
		||||
    main_cpp = generate_main("tests/component_tests/deep_sleep/test_deep_sleep_esp8266_wifi.yaml")
 | 
			
		||||
 | 
			
		||||
    # Verify WiFi component is registered
 | 
			
		||||
    assert "wifi = new wifi::WiFiComponent();" in main_cpp
 | 
			
		||||
    assert "App.register_component(wifi);" in main_cpp
 | 
			
		||||
 | 
			
		||||
    # Verify deep sleep component is registered
 | 
			
		||||
    assert "deep_sleep_1 = new deep_sleep::DeepSleepComponent();" in main_cpp
 | 
			
		||||
    assert "App.register_component(deep_sleep_1);" in main_cpp
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,24 @@
 | 
			
		||||
---
 | 
			
		||||
esphome:
 | 
			
		||||
  name: test_esp8266_wifi_deep_sleep
 | 
			
		||||
 | 
			
		||||
esp8266:
 | 
			
		||||
  board: esp12e
 | 
			
		||||
 | 
			
		||||
wifi:
 | 
			
		||||
  ssid: "test"
 | 
			
		||||
  password: "testtest"
 | 
			
		||||
 | 
			
		||||
api:
 | 
			
		||||
  actions:
 | 
			
		||||
    - action: goto_sleep
 | 
			
		||||
      variables:
 | 
			
		||||
        duration_ms: int
 | 
			
		||||
      then:
 | 
			
		||||
        - deep_sleep.enter:
 | 
			
		||||
            id: deep_sleep_1
 | 
			
		||||
            sleep_duration: !lambda 'return duration_ms;'
 | 
			
		||||
 | 
			
		||||
deep_sleep:
 | 
			
		||||
  id: deep_sleep_1
 | 
			
		||||
  run_duration: 10s
 | 
			
		||||
@@ -1,73 +0,0 @@
 | 
			
		||||
"""
 | 
			
		||||
Test ESP32 configuration
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from typing import Any
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from esphome.components.esp32 import VARIANTS
 | 
			
		||||
import esphome.config_validation as cv
 | 
			
		||||
from esphome.const import PlatformFramework
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def test_esp32_config(set_core_config) -> None:
 | 
			
		||||
    set_core_config(PlatformFramework.ESP32_IDF)
 | 
			
		||||
 | 
			
		||||
    from esphome.components.esp32 import CONFIG_SCHEMA
 | 
			
		||||
    from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_FRIENDLY
 | 
			
		||||
 | 
			
		||||
    # Example ESP32 configuration
 | 
			
		||||
    config = {
 | 
			
		||||
        "board": "esp32dev",
 | 
			
		||||
        "variant": VARIANT_ESP32,
 | 
			
		||||
        "cpu_frequency": "240MHz",
 | 
			
		||||
        "flash_size": "4MB",
 | 
			
		||||
        "framework": {
 | 
			
		||||
            "type": "esp-idf",
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # Check if the variant is valid
 | 
			
		||||
    config = CONFIG_SCHEMA(config)
 | 
			
		||||
    assert config["variant"] == VARIANT_ESP32
 | 
			
		||||
 | 
			
		||||
    # Check that defining a variant sets the board name correctly
 | 
			
		||||
    for variant in VARIANTS:
 | 
			
		||||
        config = CONFIG_SCHEMA(
 | 
			
		||||
            {
 | 
			
		||||
                "variant": variant,
 | 
			
		||||
            }
 | 
			
		||||
        )
 | 
			
		||||
        assert VARIANT_FRIENDLY[variant].lower() in config["board"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.parametrize(
 | 
			
		||||
    ("config", "error_match"),
 | 
			
		||||
    [
 | 
			
		||||
        pytest.param(
 | 
			
		||||
            {"flash_size": "4MB"},
 | 
			
		||||
            r"This board is unknown, if you are sure you want to compile with this board selection, override with option 'variant' @ data\['board'\]",
 | 
			
		||||
            id="unknown_board_config",
 | 
			
		||||
        ),
 | 
			
		||||
        pytest.param(
 | 
			
		||||
            {"variant": "esp32xx"},
 | 
			
		||||
            r"Unknown value 'ESP32XX', did you mean 'ESP32', 'ESP32S3', 'ESP32S2'\? for dictionary value @ data\['variant'\]",
 | 
			
		||||
            id="unknown_variant_config",
 | 
			
		||||
        ),
 | 
			
		||||
        pytest.param(
 | 
			
		||||
            {"variant": "esp32s3", "board": "esp32dev"},
 | 
			
		||||
            r"Option 'variant' does not match selected board. @ data\['variant'\]",
 | 
			
		||||
            id="mismatched_board_variant_config",
 | 
			
		||||
        ),
 | 
			
		||||
    ],
 | 
			
		||||
)
 | 
			
		||||
def test_esp32_configuration_errors(
 | 
			
		||||
    config: Any,
 | 
			
		||||
    error_match: str,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test detection of invalid configuration."""
 | 
			
		||||
    from esphome.components.esp32 import CONFIG_SCHEMA
 | 
			
		||||
 | 
			
		||||
    with pytest.raises(cv.Invalid, match=error_match):
 | 
			
		||||
        CONFIG_SCHEMA(config)
 | 
			
		||||
@@ -22,7 +22,6 @@ esp32_camera:
 | 
			
		||||
  power_down_pin: 1
 | 
			
		||||
  resolution: 640x480
 | 
			
		||||
  jpeg_quality: 10
 | 
			
		||||
  frame_buffer_location: PSRAM
 | 
			
		||||
  on_image:
 | 
			
		||||
    then:
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +0,0 @@
 | 
			
		||||
logger:
 | 
			
		||||
  id: logger_id
 | 
			
		||||
  level: DEBUG
 | 
			
		||||
  on_message:
 | 
			
		||||
    - level: DEBUG
 | 
			
		||||
      then:
 | 
			
		||||
        - lambda: |-
 | 
			
		||||
            ESP_LOGD("test", "Got message level %d: %s - %s", level, tag, message);
 | 
			
		||||
    - level: WARN
 | 
			
		||||
      then:
 | 
			
		||||
        - lambda: |-
 | 
			
		||||
            ESP_LOGW("test", "Warning level %d from %s", level, tag);
 | 
			
		||||
    - level: ERROR
 | 
			
		||||
      then:
 | 
			
		||||
        - lambda: |-
 | 
			
		||||
            // Test that level is uint8_t by using it in calculations
 | 
			
		||||
            uint8_t adjusted_level = level + 1;
 | 
			
		||||
            ESP_LOGE("test", "Error with adjusted level %d", adjusted_level);
 | 
			
		||||
@@ -60,28 +60,5 @@ api:
 | 
			
		||||
            data:
 | 
			
		||||
              value: !lambda 'return input_float;'
 | 
			
		||||
 | 
			
		||||
    # Service that tests char* lambda functionality (e.g., from itoa or sprintf)
 | 
			
		||||
    - action: test_char_ptr_lambda
 | 
			
		||||
      variables:
 | 
			
		||||
        input_number: int
 | 
			
		||||
        input_string: string
 | 
			
		||||
      then:
 | 
			
		||||
        # Log the input to verify service was called
 | 
			
		||||
        - logger.log:
 | 
			
		||||
            format: "Service called with number for char* test: %d"
 | 
			
		||||
            args: [input_number]
 | 
			
		||||
 | 
			
		||||
        # Test that char* lambdas work correctly
 | 
			
		||||
        # This would fail in issue #9628 with "invalid conversion from 'char*' to 'long long unsigned int'"
 | 
			
		||||
        - homeassistant.event:
 | 
			
		||||
            event: esphome.test_char_ptr_lambda
 | 
			
		||||
            data:
 | 
			
		||||
              # Test snprintf returning char*
 | 
			
		||||
              decimal_value: !lambda 'static char buffer[20]; snprintf(buffer, sizeof(buffer), "%d", input_number); return buffer;'
 | 
			
		||||
              # Test strdup returning char* (dynamically allocated)
 | 
			
		||||
              string_copy: !lambda 'return strdup(input_string.c_str());'
 | 
			
		||||
              # Test string literal (const char*)
 | 
			
		||||
              literal: !lambda 'return "test literal";'
 | 
			
		||||
 | 
			
		||||
logger:
 | 
			
		||||
  level: DEBUG
 | 
			
		||||
 
 | 
			
		||||
@@ -1,24 +0,0 @@
 | 
			
		||||
esphome:
 | 
			
		||||
  name: test-delay-action
 | 
			
		||||
 | 
			
		||||
host:
 | 
			
		||||
api:
 | 
			
		||||
  actions:
 | 
			
		||||
    - action: start_delay_then_restart
 | 
			
		||||
      then:
 | 
			
		||||
        - logger.log: "Starting first script execution"
 | 
			
		||||
        - script.execute: test_delay_script
 | 
			
		||||
        - delay: 250ms  # Give first script time to start delay
 | 
			
		||||
        - logger.log: "Restarting script (should cancel first delay)"
 | 
			
		||||
        - script.execute: test_delay_script
 | 
			
		||||
 | 
			
		||||
logger:
 | 
			
		||||
  level: DEBUG
 | 
			
		||||
 | 
			
		||||
script:
 | 
			
		||||
  - id: test_delay_script
 | 
			
		||||
    mode: restart
 | 
			
		||||
    then:
 | 
			
		||||
      - logger.log: "Script started, beginning delay"
 | 
			
		||||
      - delay: 500ms  # Long enough that it won't complete before restart
 | 
			
		||||
      - logger.log: "Delay completed successfully"
 | 
			
		||||
@@ -1,207 +0,0 @@
 | 
			
		||||
esphome:
 | 
			
		||||
  name: scheduler-retry-test
 | 
			
		||||
  on_boot:
 | 
			
		||||
    priority: -100
 | 
			
		||||
    then:
 | 
			
		||||
      - logger.log: "Starting scheduler retry tests"
 | 
			
		||||
      # Run all tests sequentially with delays
 | 
			
		||||
      - script.execute: run_all_tests
 | 
			
		||||
 | 
			
		||||
host:
 | 
			
		||||
api:
 | 
			
		||||
logger:
 | 
			
		||||
  level: VERBOSE
 | 
			
		||||
 | 
			
		||||
globals:
 | 
			
		||||
  - id: simple_retry_counter
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
  - id: backoff_retry_counter
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
  - id: immediate_done_counter
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
  - id: cancel_retry_counter
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
  - id: empty_name_retry_counter
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
  - id: script_retry_counter
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
  - id: multiple_same_name_counter
 | 
			
		||||
    type: int
 | 
			
		||||
    initial_value: '0'
 | 
			
		||||
 | 
			
		||||
sensor:
 | 
			
		||||
  - platform: template
 | 
			
		||||
    name: Test Sensor
 | 
			
		||||
    id: test_sensor
 | 
			
		||||
    lambda: return 1.0;
 | 
			
		||||
    update_interval: never
 | 
			
		||||
 | 
			
		||||
script:
 | 
			
		||||
  - id: run_all_tests
 | 
			
		||||
    then:
 | 
			
		||||
      # Test 1: Simple retry
 | 
			
		||||
      - logger.log: "=== Test 1: Simple retry ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          auto *component = id(test_sensor);
 | 
			
		||||
          App.scheduler.set_retry(component, "simple_retry", 50, 3,
 | 
			
		||||
            [](uint8_t retry_countdown) {
 | 
			
		||||
              id(simple_retry_counter)++;
 | 
			
		||||
              ESP_LOGI("test", "Simple retry attempt %d (countdown=%d)",
 | 
			
		||||
                       id(simple_retry_counter), retry_countdown);
 | 
			
		||||
 | 
			
		||||
              if (id(simple_retry_counter) >= 2) {
 | 
			
		||||
                ESP_LOGI("test", "Simple retry succeeded on attempt %d", id(simple_retry_counter));
 | 
			
		||||
                return RetryResult::DONE;
 | 
			
		||||
              }
 | 
			
		||||
              return RetryResult::RETRY;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
      # Test 2: Backoff retry
 | 
			
		||||
      - logger.log: "=== Test 2: Retry with backoff ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          auto *component = id(test_sensor);
 | 
			
		||||
          static uint32_t backoff_start_time = 0;
 | 
			
		||||
          static uint32_t last_attempt_time = 0;
 | 
			
		||||
 | 
			
		||||
          backoff_start_time = millis();
 | 
			
		||||
          last_attempt_time = backoff_start_time;
 | 
			
		||||
 | 
			
		||||
          App.scheduler.set_retry(component, "backoff_retry", 50, 4,
 | 
			
		||||
            [](uint8_t retry_countdown) {
 | 
			
		||||
              id(backoff_retry_counter)++;
 | 
			
		||||
              uint32_t now = millis();
 | 
			
		||||
              uint32_t interval = now - last_attempt_time;
 | 
			
		||||
              last_attempt_time = now;
 | 
			
		||||
 | 
			
		||||
              ESP_LOGI("test", "Backoff retry attempt %d (countdown=%d, interval=%dms)",
 | 
			
		||||
                       id(backoff_retry_counter), retry_countdown, interval);
 | 
			
		||||
 | 
			
		||||
              if (id(backoff_retry_counter) == 1) {
 | 
			
		||||
                ESP_LOGI("test", "First call was immediate");
 | 
			
		||||
              } else if (id(backoff_retry_counter) == 2) {
 | 
			
		||||
                ESP_LOGI("test", "Second call interval: %dms (expected ~50ms)", interval);
 | 
			
		||||
              } else if (id(backoff_retry_counter) == 3) {
 | 
			
		||||
                ESP_LOGI("test", "Third call interval: %dms (expected ~100ms)", interval);
 | 
			
		||||
              } else if (id(backoff_retry_counter) == 4) {
 | 
			
		||||
                ESP_LOGI("test", "Fourth call interval: %dms (expected ~200ms)", interval);
 | 
			
		||||
                ESP_LOGI("test", "Backoff retry completed");
 | 
			
		||||
                return RetryResult::DONE;
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return RetryResult::RETRY;
 | 
			
		||||
            }, 2.0f);
 | 
			
		||||
 | 
			
		||||
      # Test 3: Immediate done
 | 
			
		||||
      - logger.log: "=== Test 3: Immediate done ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          auto *component = id(test_sensor);
 | 
			
		||||
          App.scheduler.set_retry(component, "immediate_done", 50, 5,
 | 
			
		||||
            [](uint8_t retry_countdown) {
 | 
			
		||||
              id(immediate_done_counter)++;
 | 
			
		||||
              ESP_LOGI("test", "Immediate done retry called (countdown=%d)", retry_countdown);
 | 
			
		||||
              return RetryResult::DONE;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
      # Test 4: Cancel retry
 | 
			
		||||
      - logger.log: "=== Test 4: Cancel retry ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          auto *component = id(test_sensor);
 | 
			
		||||
          App.scheduler.set_retry(component, "cancel_test", 25, 10,
 | 
			
		||||
            [](uint8_t retry_countdown) {
 | 
			
		||||
              id(cancel_retry_counter)++;
 | 
			
		||||
              ESP_LOGI("test", "Cancel test retry attempt %d", id(cancel_retry_counter));
 | 
			
		||||
              return RetryResult::RETRY;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
          // Cancel it after 100ms
 | 
			
		||||
          App.scheduler.set_timeout(component, "cancel_timer", 100, []() {
 | 
			
		||||
            bool cancelled = App.scheduler.cancel_retry(id(test_sensor), "cancel_test");
 | 
			
		||||
            ESP_LOGI("test", "Retry cancellation result: %s", cancelled ? "true" : "false");
 | 
			
		||||
            ESP_LOGI("test", "Cancel retry ran %d times before cancellation", id(cancel_retry_counter));
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
      # Test 5: Empty name retry
 | 
			
		||||
      - logger.log: "=== Test 5: Empty name retry ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          auto *component = id(test_sensor);
 | 
			
		||||
          App.scheduler.set_retry(component, "", 50, 5,
 | 
			
		||||
            [](uint8_t retry_countdown) {
 | 
			
		||||
              id(empty_name_retry_counter)++;
 | 
			
		||||
              ESP_LOGI("test", "Empty name retry attempt %d", id(empty_name_retry_counter));
 | 
			
		||||
              return RetryResult::RETRY;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
          // Try to cancel after 75ms
 | 
			
		||||
          App.scheduler.set_timeout(component, "empty_cancel_timer", 75, []() {
 | 
			
		||||
            bool cancelled = App.scheduler.cancel_retry(id(test_sensor), "");
 | 
			
		||||
            ESP_LOGI("test", "Empty name retry cancel result: %s",
 | 
			
		||||
                     cancelled ? "true" : "false");
 | 
			
		||||
            ESP_LOGI("test", "Empty name retry ran %d times", id(empty_name_retry_counter));
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
      # Test 6: Component method
 | 
			
		||||
      - logger.log: "=== Test 6: Component::set_retry method ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          class TestRetryComponent : public Component {
 | 
			
		||||
          public:
 | 
			
		||||
            void test_retry() {
 | 
			
		||||
              this->set_retry(50, 3,
 | 
			
		||||
                [](uint8_t retry_countdown) {
 | 
			
		||||
                  id(script_retry_counter)++;
 | 
			
		||||
                  ESP_LOGI("test", "Component retry attempt %d", id(script_retry_counter));
 | 
			
		||||
                  if (id(script_retry_counter) >= 2) {
 | 
			
		||||
                    return RetryResult::DONE;
 | 
			
		||||
                  }
 | 
			
		||||
                  return RetryResult::RETRY;
 | 
			
		||||
                }, 1.5f);
 | 
			
		||||
            }
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          static TestRetryComponent test_component;
 | 
			
		||||
          test_component.test_retry();
 | 
			
		||||
 | 
			
		||||
      # Test 7: Multiple same name
 | 
			
		||||
      - logger.log: "=== Test 7: Multiple retries with same name ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          auto *component = id(test_sensor);
 | 
			
		||||
 | 
			
		||||
          // Set first retry
 | 
			
		||||
          App.scheduler.set_retry(component, "duplicate_retry", 100, 5,
 | 
			
		||||
            [](uint8_t retry_countdown) {
 | 
			
		||||
              id(multiple_same_name_counter) += 1;
 | 
			
		||||
              ESP_LOGI("test", "First duplicate retry - should not run");
 | 
			
		||||
              return RetryResult::RETRY;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
          // Set second retry with same name (should cancel first)
 | 
			
		||||
          App.scheduler.set_retry(component, "duplicate_retry", 50, 3,
 | 
			
		||||
            [](uint8_t retry_countdown) {
 | 
			
		||||
              id(multiple_same_name_counter) += 10;
 | 
			
		||||
              ESP_LOGI("test", "Second duplicate retry attempt (counter=%d)",
 | 
			
		||||
                       id(multiple_same_name_counter));
 | 
			
		||||
              if (id(multiple_same_name_counter) >= 20) {
 | 
			
		||||
                return RetryResult::DONE;
 | 
			
		||||
              }
 | 
			
		||||
              return RetryResult::RETRY;
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
      # Wait for all tests to complete before reporting
 | 
			
		||||
      - delay: 500ms
 | 
			
		||||
 | 
			
		||||
      # Final report
 | 
			
		||||
      - logger.log: "=== Retry Test Results ==="
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          ESP_LOGI("test", "Simple retry counter: %d (expected 2)", id(simple_retry_counter));
 | 
			
		||||
          ESP_LOGI("test", "Backoff retry counter: %d (expected 4)", id(backoff_retry_counter));
 | 
			
		||||
          ESP_LOGI("test", "Immediate done counter: %d (expected 1)", id(immediate_done_counter));
 | 
			
		||||
          ESP_LOGI("test", "Cancel retry counter: %d (expected ~3-4)", id(cancel_retry_counter));
 | 
			
		||||
          ESP_LOGI("test", "Empty name retry counter: %d (expected 1-2)", id(empty_name_retry_counter));
 | 
			
		||||
          ESP_LOGI("test", "Component retry counter: %d (expected 2)", id(script_retry_counter));
 | 
			
		||||
          ESP_LOGI("test", "Multiple same name counter: %d (expected 20+)", id(multiple_same_name_counter));
 | 
			
		||||
          ESP_LOGI("test", "All retry tests completed");
 | 
			
		||||
@@ -4,7 +4,9 @@ esphome:
 | 
			
		||||
    priority: -100
 | 
			
		||||
    then:
 | 
			
		||||
      - logger.log: "Starting scheduler string tests"
 | 
			
		||||
  debug_scheduler: true  # Enable scheduler debug logging
 | 
			
		||||
  platformio_options:
 | 
			
		||||
    build_flags:
 | 
			
		||||
      - "-DESPHOME_DEBUG_SCHEDULER"  # Enable scheduler debug logging
 | 
			
		||||
 | 
			
		||||
host:
 | 
			
		||||
api:
 | 
			
		||||
@@ -30,12 +32,6 @@ globals:
 | 
			
		||||
  - id: results_reported
 | 
			
		||||
    type: bool
 | 
			
		||||
    initial_value: 'false'
 | 
			
		||||
  - id: edge_tests_done
 | 
			
		||||
    type: bool
 | 
			
		||||
    initial_value: 'false'
 | 
			
		||||
  - id: empty_cancel_failed
 | 
			
		||||
    type: bool
 | 
			
		||||
    initial_value: 'false'
 | 
			
		||||
 | 
			
		||||
script:
 | 
			
		||||
  - id: test_static_strings
 | 
			
		||||
@@ -151,106 +147,12 @@ script:
 | 
			
		||||
          static TestDynamicDeferComponent test_dynamic_defer_component;
 | 
			
		||||
          test_dynamic_defer_component.test_dynamic_defer();
 | 
			
		||||
 | 
			
		||||
  - id: test_cancellation_edge_cases
 | 
			
		||||
    then:
 | 
			
		||||
      - logger.log: "Testing cancellation edge cases"
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          auto *component1 = id(test_sensor1);
 | 
			
		||||
          // Use a different component for empty string tests to avoid interference
 | 
			
		||||
          auto *component2 = id(test_sensor2);
 | 
			
		||||
 | 
			
		||||
          // Test 12: Cancel with empty string - regression test for issue #9599
 | 
			
		||||
          // First create a timeout with empty name on component2 to avoid interference
 | 
			
		||||
          App.scheduler.set_timeout(component2, "", 500, []() {
 | 
			
		||||
            ESP_LOGE("test", "ERROR: Empty name timeout fired - it should have been cancelled!");
 | 
			
		||||
            id(empty_cancel_failed) = true;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          // Now cancel it - this should work after our fix
 | 
			
		||||
          bool cancelled_empty = App.scheduler.cancel_timeout(component2, "");
 | 
			
		||||
          ESP_LOGI("test", "Cancel empty string result: %s (should be true)", cancelled_empty ? "true" : "false");
 | 
			
		||||
          if (!cancelled_empty) {
 | 
			
		||||
            ESP_LOGE("test", "ERROR: Failed to cancel empty string timeout!");
 | 
			
		||||
            id(empty_cancel_failed) = true;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Test 13: Cancel non-existent timeout
 | 
			
		||||
          bool cancelled_nonexistent = App.scheduler.cancel_timeout(component1, "does_not_exist");
 | 
			
		||||
          ESP_LOGI("test", "Cancel non-existent timeout result: %s",
 | 
			
		||||
                   cancelled_nonexistent ? "true (unexpected!)" : "false (expected)");
 | 
			
		||||
 | 
			
		||||
          // Test 14: Multiple timeouts with same name - only last should execute
 | 
			
		||||
          for (int i = 0; i < 5; i++) {
 | 
			
		||||
            App.scheduler.set_timeout(component1, "duplicate_timeout", 200 + i*10, [i]() {
 | 
			
		||||
              ESP_LOGI("test", "Duplicate timeout %d fired", i);
 | 
			
		||||
              id(timeout_counter) += 1;
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
          ESP_LOGI("test", "Created 5 timeouts with same name 'duplicate_timeout'");
 | 
			
		||||
 | 
			
		||||
          // Test 15: Multiple intervals with same name - only last should run
 | 
			
		||||
          for (int i = 0; i < 3; i++) {
 | 
			
		||||
            App.scheduler.set_interval(component1, "duplicate_interval", 300, [i]() {
 | 
			
		||||
              ESP_LOGI("test", "Duplicate interval %d fired", i);
 | 
			
		||||
              id(interval_counter) += 10; // Large increment to detect multiple
 | 
			
		||||
              // Cancel after first execution
 | 
			
		||||
              App.scheduler.cancel_interval(id(test_sensor1), "duplicate_interval");
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
          ESP_LOGI("test", "Created 3 intervals with same name 'duplicate_interval'");
 | 
			
		||||
 | 
			
		||||
          // Test 16: Cancel with nullptr protection (via empty const char*)
 | 
			
		||||
          const char* null_name = "";
 | 
			
		||||
          App.scheduler.set_timeout(component2, null_name, 600, []() {
 | 
			
		||||
            ESP_LOGE("test", "ERROR: Const char* empty timeout fired - should have been cancelled!");
 | 
			
		||||
            id(empty_cancel_failed) = true;
 | 
			
		||||
          });
 | 
			
		||||
          bool cancelled_const_empty = App.scheduler.cancel_timeout(component2, null_name);
 | 
			
		||||
          ESP_LOGI("test", "Cancel const char* empty result: %s (should be true)",
 | 
			
		||||
                   cancelled_const_empty ? "true" : "false");
 | 
			
		||||
          if (!cancelled_const_empty) {
 | 
			
		||||
            ESP_LOGE("test", "ERROR: Failed to cancel const char* empty timeout!");
 | 
			
		||||
            id(empty_cancel_failed) = true;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Test 17: Rapid create/cancel/create with same name
 | 
			
		||||
          App.scheduler.set_timeout(component1, "rapid_test", 5000, []() {
 | 
			
		||||
            ESP_LOGI("test", "First rapid timeout - should not fire");
 | 
			
		||||
            id(timeout_counter) += 100;
 | 
			
		||||
          });
 | 
			
		||||
          App.scheduler.cancel_timeout(component1, "rapid_test");
 | 
			
		||||
          App.scheduler.set_timeout(component1, "rapid_test", 250, []() {
 | 
			
		||||
            ESP_LOGI("test", "Second rapid timeout - should fire");
 | 
			
		||||
            id(timeout_counter) += 1;
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          // Test 18: Cancel all with a specific name (multiple instances)
 | 
			
		||||
          // Create multiple with same name
 | 
			
		||||
          App.scheduler.set_timeout(component1, "multi_cancel", 300, []() {
 | 
			
		||||
            ESP_LOGI("test", "Multi-cancel timeout 1");
 | 
			
		||||
          });
 | 
			
		||||
          App.scheduler.set_timeout(component1, "multi_cancel", 350, []() {
 | 
			
		||||
            ESP_LOGI("test", "Multi-cancel timeout 2");
 | 
			
		||||
          });
 | 
			
		||||
          App.scheduler.set_timeout(component1, "multi_cancel", 400, []() {
 | 
			
		||||
            ESP_LOGI("test", "Multi-cancel timeout 3 - only this should fire");
 | 
			
		||||
            id(timeout_counter) += 1;
 | 
			
		||||
          });
 | 
			
		||||
          // Note: Each set_timeout with same name cancels the previous one automatically
 | 
			
		||||
 | 
			
		||||
  - id: report_results
 | 
			
		||||
    then:
 | 
			
		||||
      - lambda: |-
 | 
			
		||||
          ESP_LOGI("test", "Final results - Timeouts: %d, Intervals: %d",
 | 
			
		||||
                   id(timeout_counter), id(interval_counter));
 | 
			
		||||
 | 
			
		||||
          // Check if empty string cancellation test passed
 | 
			
		||||
          if (id(empty_cancel_failed)) {
 | 
			
		||||
            ESP_LOGE("test", "ERROR: Empty string cancellation test FAILED!");
 | 
			
		||||
          } else {
 | 
			
		||||
            ESP_LOGI("test", "Empty string cancellation test PASSED");
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
sensor:
 | 
			
		||||
  - platform: template
 | 
			
		||||
    name: Test Sensor 1
 | 
			
		||||
@@ -287,23 +189,12 @@ interval:
 | 
			
		||||
            - delay: 0.2s
 | 
			
		||||
            - script.execute: test_dynamic_strings
 | 
			
		||||
 | 
			
		||||
  # Run cancellation edge case tests after dynamic tests
 | 
			
		||||
  - interval: 0.2s
 | 
			
		||||
    then:
 | 
			
		||||
      - if:
 | 
			
		||||
          condition:
 | 
			
		||||
            lambda: 'return id(dynamic_tests_done) && !id(edge_tests_done);'
 | 
			
		||||
          then:
 | 
			
		||||
            - lambda: 'id(edge_tests_done) = true;'
 | 
			
		||||
            - delay: 0.5s
 | 
			
		||||
            - script.execute: test_cancellation_edge_cases
 | 
			
		||||
 | 
			
		||||
  # Report results after all tests
 | 
			
		||||
  - interval: 0.2s
 | 
			
		||||
    then:
 | 
			
		||||
      - if:
 | 
			
		||||
          condition:
 | 
			
		||||
            lambda: 'return id(edge_tests_done) && !id(results_reported);'
 | 
			
		||||
            lambda: 'return id(dynamic_tests_done) && !id(results_reported);'
 | 
			
		||||
          then:
 | 
			
		||||
            - lambda: 'id(results_reported) = true;'
 | 
			
		||||
            - delay: 1s
 | 
			
		||||
 
 | 
			
		||||
@@ -19,17 +19,15 @@ async def test_api_string_lambda(
 | 
			
		||||
    """Test TemplatableStringValue works with lambdas that return different types."""
 | 
			
		||||
    loop = asyncio.get_running_loop()
 | 
			
		||||
 | 
			
		||||
    # Track log messages for all four service calls
 | 
			
		||||
    # Track log messages for all three service calls
 | 
			
		||||
    string_called_future = loop.create_future()
 | 
			
		||||
    int_called_future = loop.create_future()
 | 
			
		||||
    float_called_future = loop.create_future()
 | 
			
		||||
    char_ptr_called_future = loop.create_future()
 | 
			
		||||
 | 
			
		||||
    # Patterns to match in logs - confirms the lambdas compiled and executed
 | 
			
		||||
    string_pattern = re.compile(r"Service called with string: STRING_FROM_LAMBDA")
 | 
			
		||||
    int_pattern = re.compile(r"Service called with int: 42")
 | 
			
		||||
    float_pattern = re.compile(r"Service called with float: 3\.14")
 | 
			
		||||
    char_ptr_pattern = re.compile(r"Service called with number for char\* test: 123")
 | 
			
		||||
 | 
			
		||||
    def check_output(line: str) -> None:
 | 
			
		||||
        """Check log output for expected messages."""
 | 
			
		||||
@@ -39,8 +37,6 @@ async def test_api_string_lambda(
 | 
			
		||||
            int_called_future.set_result(True)
 | 
			
		||||
        if not float_called_future.done() and float_pattern.search(line):
 | 
			
		||||
            float_called_future.set_result(True)
 | 
			
		||||
        if not char_ptr_called_future.done() and char_ptr_pattern.search(line):
 | 
			
		||||
            char_ptr_called_future.set_result(True)
 | 
			
		||||
 | 
			
		||||
    # Run with log monitoring
 | 
			
		||||
    async with (
 | 
			
		||||
@@ -69,28 +65,17 @@ async def test_api_string_lambda(
 | 
			
		||||
        )
 | 
			
		||||
        assert float_service is not None, "test_float_lambda service not found"
 | 
			
		||||
 | 
			
		||||
        char_ptr_service = next(
 | 
			
		||||
            (s for s in services if s.name == "test_char_ptr_lambda"), None
 | 
			
		||||
        )
 | 
			
		||||
        assert char_ptr_service is not None, "test_char_ptr_lambda service not found"
 | 
			
		||||
 | 
			
		||||
        # Execute all four services to test different lambda return types
 | 
			
		||||
        # Execute all three services to test different lambda return types
 | 
			
		||||
        client.execute_service(string_service, {"input_string": "STRING_FROM_LAMBDA"})
 | 
			
		||||
        client.execute_service(int_service, {"input_number": 42})
 | 
			
		||||
        client.execute_service(float_service, {"input_float": 3.14})
 | 
			
		||||
        client.execute_service(
 | 
			
		||||
            char_ptr_service, {"input_number": 123, "input_string": "test_string"}
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for all service log messages
 | 
			
		||||
        # This confirms the lambdas compiled successfully and executed
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(
 | 
			
		||||
                asyncio.gather(
 | 
			
		||||
                    string_called_future,
 | 
			
		||||
                    int_called_future,
 | 
			
		||||
                    float_called_future,
 | 
			
		||||
                    char_ptr_called_future,
 | 
			
		||||
                    string_called_future, int_called_future, float_called_future
 | 
			
		||||
                ),
 | 
			
		||||
                timeout=5.0,
 | 
			
		||||
            )
 | 
			
		||||
 
 | 
			
		||||
@@ -1,91 +0,0 @@
 | 
			
		||||
"""Test ESPHome automations functionality."""
 | 
			
		||||
 | 
			
		||||
from __future__ import annotations
 | 
			
		||||
 | 
			
		||||
import asyncio
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from .types import APIClientConnectedFactory, RunCompiledFunction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.asyncio
 | 
			
		||||
async def test_delay_action_cancellation(
 | 
			
		||||
    yaml_config: str,
 | 
			
		||||
    run_compiled: RunCompiledFunction,
 | 
			
		||||
    api_client_connected: APIClientConnectedFactory,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that delay actions can be properly cancelled when script restarts."""
 | 
			
		||||
    loop = asyncio.get_running_loop()
 | 
			
		||||
 | 
			
		||||
    # Track log messages with timestamps
 | 
			
		||||
    log_entries: list[tuple[float, str]] = []
 | 
			
		||||
    script_starts: list[float] = []
 | 
			
		||||
    delay_completions: list[float] = []
 | 
			
		||||
    script_restart_logged = False
 | 
			
		||||
    test_started_time = None
 | 
			
		||||
 | 
			
		||||
    # Patterns to match
 | 
			
		||||
    test_start_pattern = re.compile(r"Starting first script execution")
 | 
			
		||||
    script_start_pattern = re.compile(r"Script started, beginning delay")
 | 
			
		||||
    restart_pattern = re.compile(r"Restarting script \(should cancel first delay\)")
 | 
			
		||||
    delay_complete_pattern = re.compile(r"Delay completed successfully")
 | 
			
		||||
 | 
			
		||||
    # Future to track when we can check results
 | 
			
		||||
    second_script_started = loop.create_future()
 | 
			
		||||
 | 
			
		||||
    def check_output(line: str) -> None:
 | 
			
		||||
        """Check log output for expected messages."""
 | 
			
		||||
        nonlocal script_restart_logged, test_started_time
 | 
			
		||||
 | 
			
		||||
        current_time = loop.time()
 | 
			
		||||
        log_entries.append((current_time, line))
 | 
			
		||||
 | 
			
		||||
        if test_start_pattern.search(line):
 | 
			
		||||
            test_started_time = current_time
 | 
			
		||||
        elif script_start_pattern.search(line) and test_started_time:
 | 
			
		||||
            script_starts.append(current_time)
 | 
			
		||||
            if len(script_starts) == 2 and not second_script_started.done():
 | 
			
		||||
                second_script_started.set_result(True)
 | 
			
		||||
        elif restart_pattern.search(line):
 | 
			
		||||
            script_restart_logged = True
 | 
			
		||||
        elif delay_complete_pattern.search(line):
 | 
			
		||||
            delay_completions.append(current_time)
 | 
			
		||||
 | 
			
		||||
    async with (
 | 
			
		||||
        run_compiled(yaml_config, line_callback=check_output),
 | 
			
		||||
        api_client_connected() as client,
 | 
			
		||||
    ):
 | 
			
		||||
        # Get services
 | 
			
		||||
        entities, services = await client.list_entities_services()
 | 
			
		||||
 | 
			
		||||
        # Find our test service
 | 
			
		||||
        test_service = next(
 | 
			
		||||
            (s for s in services if s.name == "start_delay_then_restart"), None
 | 
			
		||||
        )
 | 
			
		||||
        assert test_service is not None, "start_delay_then_restart service not found"
 | 
			
		||||
 | 
			
		||||
        # Execute the test sequence
 | 
			
		||||
        client.execute_service(test_service, {})
 | 
			
		||||
 | 
			
		||||
        # Wait for the second script to start
 | 
			
		||||
        await asyncio.wait_for(second_script_started, timeout=5.0)
 | 
			
		||||
 | 
			
		||||
        # Wait for potential delay completion
 | 
			
		||||
        await asyncio.sleep(0.75)  # Original delay was 500ms
 | 
			
		||||
 | 
			
		||||
        # Check results
 | 
			
		||||
        assert len(script_starts) == 2, (
 | 
			
		||||
            f"Script should have started twice, but started {len(script_starts)} times"
 | 
			
		||||
        )
 | 
			
		||||
        assert script_restart_logged, "Script restart was not logged"
 | 
			
		||||
 | 
			
		||||
        # Verify we got exactly one completion and it happened ~500ms after the second start
 | 
			
		||||
        assert len(delay_completions) == 1, (
 | 
			
		||||
            f"Expected 1 delay completion, got {len(delay_completions)}"
 | 
			
		||||
        )
 | 
			
		||||
        time_from_second_start = delay_completions[0] - script_starts[1]
 | 
			
		||||
        assert 0.4 < time_from_second_start < 0.6, (
 | 
			
		||||
            f"Delay completed {time_from_second_start:.3f}s after second start, expected ~0.5s"
 | 
			
		||||
        )
 | 
			
		||||
@@ -103,14 +103,13 @@ async def test_scheduler_heap_stress(
 | 
			
		||||
 | 
			
		||||
        # Wait for all callbacks to execute (should be quick, but give more time for scheduling)
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(test_complete_future, timeout=10.0)
 | 
			
		||||
            await asyncio.wait_for(test_complete_future, timeout=60.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            # Report how many we got
 | 
			
		||||
            missing_ids = sorted(set(range(1000)) - executed_callbacks)
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Stress test timed out. Only {len(executed_callbacks)} of "
 | 
			
		||||
                f"1000 callbacks executed. Missing IDs: "
 | 
			
		||||
                f"{missing_ids[:20]}... (total missing: {len(missing_ids)})"
 | 
			
		||||
                f"{sorted(set(range(1000)) - executed_callbacks)[:10]}..."
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Verify all callbacks executed
 | 
			
		||||
 
 | 
			
		||||
@@ -1,234 +0,0 @@
 | 
			
		||||
"""Test scheduler retry functionality."""
 | 
			
		||||
 | 
			
		||||
import asyncio
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
import pytest
 | 
			
		||||
 | 
			
		||||
from .types import APIClientConnectedFactory, RunCompiledFunction
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@pytest.mark.asyncio
 | 
			
		||||
async def test_scheduler_retry_test(
 | 
			
		||||
    yaml_config: str,
 | 
			
		||||
    run_compiled: RunCompiledFunction,
 | 
			
		||||
    api_client_connected: APIClientConnectedFactory,
 | 
			
		||||
) -> None:
 | 
			
		||||
    """Test that scheduler retry functionality works correctly."""
 | 
			
		||||
    # Track test progress
 | 
			
		||||
    simple_retry_done = asyncio.Event()
 | 
			
		||||
    backoff_retry_done = asyncio.Event()
 | 
			
		||||
    immediate_done_done = asyncio.Event()
 | 
			
		||||
    cancel_retry_done = asyncio.Event()
 | 
			
		||||
    empty_name_retry_done = asyncio.Event()
 | 
			
		||||
    component_retry_done = asyncio.Event()
 | 
			
		||||
    multiple_name_done = asyncio.Event()
 | 
			
		||||
    test_complete = asyncio.Event()
 | 
			
		||||
 | 
			
		||||
    # Track retry counts
 | 
			
		||||
    simple_retry_count = 0
 | 
			
		||||
    backoff_retry_count = 0
 | 
			
		||||
    immediate_done_count = 0
 | 
			
		||||
    cancel_retry_count = 0
 | 
			
		||||
    empty_name_retry_count = 0
 | 
			
		||||
    component_retry_count = 0
 | 
			
		||||
    multiple_name_count = 0
 | 
			
		||||
 | 
			
		||||
    # Track specific test results
 | 
			
		||||
    cancel_result = None
 | 
			
		||||
    empty_cancel_result = None
 | 
			
		||||
    backoff_intervals = []
 | 
			
		||||
 | 
			
		||||
    def on_log_line(line: str) -> None:
 | 
			
		||||
        nonlocal simple_retry_count, backoff_retry_count, immediate_done_count
 | 
			
		||||
        nonlocal cancel_retry_count, empty_name_retry_count, component_retry_count
 | 
			
		||||
        nonlocal multiple_name_count, cancel_result, empty_cancel_result
 | 
			
		||||
 | 
			
		||||
        # Strip ANSI color codes
 | 
			
		||||
        clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
 | 
			
		||||
 | 
			
		||||
        # Simple retry test
 | 
			
		||||
        if "Simple retry attempt" in clean_line:
 | 
			
		||||
            if match := re.search(r"Simple retry attempt (\d+)", clean_line):
 | 
			
		||||
                simple_retry_count = int(match.group(1))
 | 
			
		||||
 | 
			
		||||
        elif "Simple retry succeeded on attempt" in clean_line:
 | 
			
		||||
            simple_retry_done.set()
 | 
			
		||||
 | 
			
		||||
        # Backoff retry test
 | 
			
		||||
        elif "Backoff retry attempt" in clean_line:
 | 
			
		||||
            if match := re.search(
 | 
			
		||||
                r"Backoff retry attempt (\d+).*interval=(\d+)ms", clean_line
 | 
			
		||||
            ):
 | 
			
		||||
                backoff_retry_count = int(match.group(1))
 | 
			
		||||
                interval = int(match.group(2))
 | 
			
		||||
                if backoff_retry_count > 1:  # Skip first (immediate) call
 | 
			
		||||
                    backoff_intervals.append(interval)
 | 
			
		||||
 | 
			
		||||
        elif "Backoff retry completed" in clean_line:
 | 
			
		||||
            backoff_retry_done.set()
 | 
			
		||||
 | 
			
		||||
        # Immediate done test
 | 
			
		||||
        elif "Immediate done retry called" in clean_line:
 | 
			
		||||
            immediate_done_count += 1
 | 
			
		||||
            immediate_done_done.set()
 | 
			
		||||
 | 
			
		||||
        # Cancel retry test
 | 
			
		||||
        elif "Cancel test retry attempt" in clean_line:
 | 
			
		||||
            cancel_retry_count += 1
 | 
			
		||||
 | 
			
		||||
        elif "Retry cancellation result:" in clean_line:
 | 
			
		||||
            cancel_result = "true" in clean_line
 | 
			
		||||
            cancel_retry_done.set()
 | 
			
		||||
 | 
			
		||||
        # Empty name retry test
 | 
			
		||||
        elif "Empty name retry attempt" in clean_line:
 | 
			
		||||
            if match := re.search(r"Empty name retry attempt (\d+)", clean_line):
 | 
			
		||||
                empty_name_retry_count = int(match.group(1))
 | 
			
		||||
 | 
			
		||||
        elif "Empty name retry cancel result:" in clean_line:
 | 
			
		||||
            empty_cancel_result = "true" in clean_line
 | 
			
		||||
 | 
			
		||||
        elif "Empty name retry ran" in clean_line:
 | 
			
		||||
            empty_name_retry_done.set()
 | 
			
		||||
 | 
			
		||||
        # Component retry test
 | 
			
		||||
        elif "Component retry attempt" in clean_line:
 | 
			
		||||
            if match := re.search(r"Component retry attempt (\d+)", clean_line):
 | 
			
		||||
                component_retry_count = int(match.group(1))
 | 
			
		||||
                if component_retry_count >= 2:
 | 
			
		||||
                    component_retry_done.set()
 | 
			
		||||
 | 
			
		||||
        # Multiple same name test
 | 
			
		||||
        elif "Second duplicate retry attempt" in clean_line:
 | 
			
		||||
            if match := re.search(r"counter=(\d+)", clean_line):
 | 
			
		||||
                multiple_name_count = int(match.group(1))
 | 
			
		||||
                if multiple_name_count >= 20:
 | 
			
		||||
                    multiple_name_done.set()
 | 
			
		||||
 | 
			
		||||
        # Test completion
 | 
			
		||||
        elif "All retry tests completed" in clean_line:
 | 
			
		||||
            test_complete.set()
 | 
			
		||||
 | 
			
		||||
    async with (
 | 
			
		||||
        run_compiled(yaml_config, line_callback=on_log_line),
 | 
			
		||||
        api_client_connected() as client,
 | 
			
		||||
    ):
 | 
			
		||||
        # Verify we can connect
 | 
			
		||||
        device_info = await client.device_info()
 | 
			
		||||
        assert device_info is not None
 | 
			
		||||
        assert device_info.name == "scheduler-retry-test"
 | 
			
		||||
 | 
			
		||||
        # Wait for simple retry test
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(simple_retry_done.wait(), timeout=1.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Simple retry test did not complete. Count: {simple_retry_count}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        assert simple_retry_count == 2, (
 | 
			
		||||
            f"Expected 2 simple retry attempts, got {simple_retry_count}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for backoff retry test
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(backoff_retry_done.wait(), timeout=3.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Backoff retry test did not complete. Count: {backoff_retry_count}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        assert backoff_retry_count == 4, (
 | 
			
		||||
            f"Expected 4 backoff retry attempts, got {backoff_retry_count}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Verify backoff intervals (allowing for timing variations)
 | 
			
		||||
        assert len(backoff_intervals) >= 2, (
 | 
			
		||||
            f"Expected at least 2 intervals, got {len(backoff_intervals)}"
 | 
			
		||||
        )
 | 
			
		||||
        if len(backoff_intervals) >= 3:
 | 
			
		||||
            # First interval should be ~50ms
 | 
			
		||||
            assert 30 <= backoff_intervals[0] <= 70, (
 | 
			
		||||
                f"First interval {backoff_intervals[0]}ms not ~50ms"
 | 
			
		||||
            )
 | 
			
		||||
            # Second interval should be ~100ms (50ms * 2.0)
 | 
			
		||||
            assert 80 <= backoff_intervals[1] <= 120, (
 | 
			
		||||
                f"Second interval {backoff_intervals[1]}ms not ~100ms"
 | 
			
		||||
            )
 | 
			
		||||
            # Third interval should be ~200ms (100ms * 2.0)
 | 
			
		||||
            assert 180 <= backoff_intervals[2] <= 220, (
 | 
			
		||||
                f"Third interval {backoff_intervals[2]}ms not ~200ms"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Wait for immediate done test
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(immediate_done_done.wait(), timeout=3.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Immediate done test did not complete. Count: {immediate_done_count}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        assert immediate_done_count == 1, (
 | 
			
		||||
            f"Expected 1 immediate done call, got {immediate_done_count}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for cancel retry test
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(cancel_retry_done.wait(), timeout=2.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Cancel retry test did not complete. Count: {cancel_retry_count}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        assert cancel_result is True, "Retry cancellation should have succeeded"
 | 
			
		||||
        assert 2 <= cancel_retry_count <= 5, (
 | 
			
		||||
            f"Expected 2-5 cancel retry attempts before cancellation, got {cancel_retry_count}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for empty name retry test
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(empty_name_retry_done.wait(), timeout=1.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Empty name retry test did not complete. Count: {empty_name_retry_count}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Empty name retry should run at least once before being cancelled
 | 
			
		||||
        assert 1 <= empty_name_retry_count <= 2, (
 | 
			
		||||
            f"Expected 1-2 empty name retry attempts, got {empty_name_retry_count}"
 | 
			
		||||
        )
 | 
			
		||||
        assert empty_cancel_result is True, (
 | 
			
		||||
            "Empty name retry cancel should have succeeded"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for component retry test
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(component_retry_done.wait(), timeout=1.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Component retry test did not complete. Count: {component_retry_count}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        assert component_retry_count >= 2, (
 | 
			
		||||
            f"Expected at least 2 component retry attempts, got {component_retry_count}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for multiple same name test
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(multiple_name_done.wait(), timeout=1.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail(
 | 
			
		||||
                f"Multiple same name test did not complete. Count: {multiple_name_count}"
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Should be 20+ (only second retry should run)
 | 
			
		||||
        assert multiple_name_count >= 20, (
 | 
			
		||||
            f"Expected multiple name count >= 20 (second retry only), got {multiple_name_count}"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Wait for test completion
 | 
			
		||||
        try:
 | 
			
		||||
            await asyncio.wait_for(test_complete.wait(), timeout=1.0)
 | 
			
		||||
        except TimeoutError:
 | 
			
		||||
            pytest.fail("Test did not complete within timeout")
 | 
			
		||||
		Reference in New Issue
	
	Block a user