mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Merge remote-tracking branch 'upstream/dev' into 5_4_2
This commit is contained in:
		
							
								
								
									
										222
									
								
								.ai/instructions.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										222
									
								
								.ai/instructions.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,222 @@ | |||||||
|  | # 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
									
								
								.clang-tidy.hash
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.clang-tidy.hash
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | 0c2acbc16bfb7d63571dbe7042f94f683be25e4ca8a0f158a960a94adac4b931 | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| [run] | [run] | ||||||
| omit =  | omit = | ||||||
|     esphome/components/* |     esphome/components/* | ||||||
|     tests/integration/* |     tests/integration/* | ||||||
|   | |||||||
							
								
								
									
										92
									
								
								.github/ISSUE_TEMPLATE/bug_report.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								.github/ISSUE_TEMPLATE/bug_report.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | |||||||
|  | name: Report an issue with ESPHome | ||||||
|  | description: Report an issue with ESPHome. | ||||||
|  | body: | ||||||
|  |   - type: markdown | ||||||
|  |     attributes: | ||||||
|  |       value: | | ||||||
|  |         This issue form is for reporting bugs only! | ||||||
|  |  | ||||||
|  |         If you have a feature request or enhancement, please [request them here instead][fr]. | ||||||
|  |  | ||||||
|  |         [fr]: https://github.com/orgs/esphome/discussions | ||||||
|  |   - type: textarea | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |     id: problem | ||||||
|  |     attributes: | ||||||
|  |       label: The problem | ||||||
|  |       description: >- | ||||||
|  |         Describe the issue you are experiencing here to communicate to the | ||||||
|  |         maintainers. Tell us what you were trying to do and what happened. | ||||||
|  |  | ||||||
|  |         Provide a clear and concise description of what the problem is. | ||||||
|  |  | ||||||
|  |   - type: markdown | ||||||
|  |     attributes: | ||||||
|  |       value: | | ||||||
|  |         ## Environment | ||||||
|  |   - type: input | ||||||
|  |     id: version | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |     attributes: | ||||||
|  |       label: Which version of ESPHome has the issue? | ||||||
|  |       description: > | ||||||
|  |         ESPHome version like 1.19, 2025.6.0 or 2025.XX.X-dev. | ||||||
|  |   - type: dropdown | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |     id: installation | ||||||
|  |     attributes: | ||||||
|  |       label: What type of installation are you using? | ||||||
|  |       options: | ||||||
|  |         - Home Assistant Add-on | ||||||
|  |         - Docker | ||||||
|  |         - pip | ||||||
|  |   - type: dropdown | ||||||
|  |     validations: | ||||||
|  |       required: true | ||||||
|  |     id: platform | ||||||
|  |     attributes: | ||||||
|  |       label: What platform are you using? | ||||||
|  |       options: | ||||||
|  |         - ESP8266 | ||||||
|  |         - ESP32 | ||||||
|  |         - RP2040 | ||||||
|  |         - BK72XX | ||||||
|  |         - RTL87XX | ||||||
|  |         - LN882X | ||||||
|  |         - Host | ||||||
|  |         - Other | ||||||
|  |   - type: input | ||||||
|  |     id: component_name | ||||||
|  |     attributes: | ||||||
|  |       label: Component causing the issue | ||||||
|  |       description: > | ||||||
|  |         The name of the component or platform. For example, api/i2c or ultrasonic. | ||||||
|  |  | ||||||
|  |   - type: markdown | ||||||
|  |     attributes: | ||||||
|  |       value: | | ||||||
|  |         # Details | ||||||
|  |   - type: textarea | ||||||
|  |     id: config | ||||||
|  |     attributes: | ||||||
|  |       label: YAML Config | ||||||
|  |       description: | | ||||||
|  |         Include a complete YAML configuration file demonstrating the problem here. Preferably post the *entire* file - don't make assumptions about what is unimportant. However, if it's a large or complicated config then you will need to reduce it to the smallest possible file *that still demonstrates the problem*. If you don't provide enough information to *easily* reproduce the problem, it's unlikely your bug report will get any attention. Logs do not belong here, attach them below. | ||||||
|  |       render: yaml | ||||||
|  |   - type: textarea | ||||||
|  |     id: logs | ||||||
|  |     attributes: | ||||||
|  |       label: Anything in the logs that might be useful for us? | ||||||
|  |       description: For example, error message, or stack traces. Serial or USB logs are much more useful than WiFi logs. | ||||||
|  |       render: txt | ||||||
|  |   - type: textarea | ||||||
|  |     id: additional | ||||||
|  |     attributes: | ||||||
|  |       label: Additional information | ||||||
|  |       description: > | ||||||
|  |         If you have any additional information for us, use the field below. | ||||||
|  |         Please note, you can attach screenshots or screen recordings here, by | ||||||
|  |         dragging and dropping files in the field below. | ||||||
							
								
								
									
										26
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										26
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,15 +1,21 @@ | |||||||
| --- | --- | ||||||
| blank_issues_enabled: false | blank_issues_enabled: false | ||||||
| contact_links: | contact_links: | ||||||
|   - name: Issue Tracker |   - name: Report an issue with the ESPHome documentation | ||||||
|     url: https://github.com/esphome/issues |     url: https://github.com/esphome/esphome-docs/issues/new/choose | ||||||
|     about: Please create bug reports in the dedicated issue tracker. |     about: Report an issue with the ESPHome documentation. | ||||||
|   - name: Feature Request Tracker |   - name: Report an issue with the ESPHome web server | ||||||
|     url: https://github.com/esphome/feature-requests |     url: https://github.com/esphome/esphome-webserver/issues/new/choose | ||||||
|     about: | |     about: Report an issue with the ESPHome web server. | ||||||
|       Please create feature requests in the dedicated feature request tracker. |   - name: Report an issue with the ESPHome Builder / Dashboard | ||||||
|  |     url: https://github.com/esphome/dashboard/issues/new/choose | ||||||
|  |     about: Report an issue with the ESPHome Builder / Dashboard. | ||||||
|  |   - name: Report an issue with the ESPHome API client | ||||||
|  |     url: https://github.com/esphome/aioesphomeapi/issues/new/choose | ||||||
|  |     about: Report an issue with the ESPHome API client. | ||||||
|  |   - name: Make a Feature Request | ||||||
|  |     url: https://github.com/orgs/esphome/discussions | ||||||
|  |     about: Please create feature requests in the dedicated feature request tracker. | ||||||
|   - name: Frequently Asked Question |   - name: Frequently Asked Question | ||||||
|     url: https://esphome.io/guides/faq.html |     url: https://esphome.io/guides/faq.html | ||||||
|     about: | |     about: Please view the FAQ for common questions and what to include in a bug report. | ||||||
|       Please view the FAQ for common questions and what |  | ||||||
|       to include in a bug report. |  | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/PULL_REQUEST_TEMPLATE.md
									
									
									
									
										vendored
									
									
								
							| @@ -26,6 +26,7 @@ | |||||||
| - [ ] RP2040 | - [ ] RP2040 | ||||||
| - [ ] BK72xx | - [ ] BK72xx | ||||||
| - [ ] RTL87xx | - [ ] RTL87xx | ||||||
|  | - [ ] nRF52840 | ||||||
|  |  | ||||||
| ## Example entry for `config.yaml`: | ## Example entry for `config.yaml`: | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/actions/restore-python/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/restore-python/action.yml
									
									
									
									
										vendored
									
									
								
							| @@ -41,7 +41,7 @@ runs: | |||||||
|       shell: bash |       shell: bash | ||||||
|       run: | |       run: | | ||||||
|         python -m venv venv |         python -m venv venv | ||||||
|         ./venv/Scripts/activate |         source ./venv/Scripts/activate | ||||||
|         python --version |         python --version | ||||||
|         pip install -r requirements.txt -r requirements_test.txt |         pip install -r requirements.txt -r requirements_test.txt | ||||||
|         pip install -e . |         pip install -e . | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										Symbolic link
									
								
							
							
						
						
									
										1
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
										Symbolic link
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | ../.ai/instructions.md | ||||||
							
								
								
									
										9
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @@ -9,6 +9,9 @@ updates: | |||||||
|       # Hypotehsis is only used for testing and is updated quite often |       # Hypotehsis is only used for testing and is updated quite often | ||||||
|       - dependency-name: hypothesis |       - dependency-name: hypothesis | ||||||
|   - package-ecosystem: github-actions |   - package-ecosystem: github-actions | ||||||
|  |     labels: | ||||||
|  |       - "dependencies" | ||||||
|  |       - "github-actions" | ||||||
|     directory: "/" |     directory: "/" | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
| @@ -20,11 +23,17 @@ updates: | |||||||
|           - "docker/login-action" |           - "docker/login-action" | ||||||
|           - "docker/setup-buildx-action" |           - "docker/setup-buildx-action" | ||||||
|   - package-ecosystem: github-actions |   - package-ecosystem: github-actions | ||||||
|  |     labels: | ||||||
|  |       - "dependencies" | ||||||
|  |       - "github-actions" | ||||||
|     directory: "/.github/actions/build-image" |     directory: "/.github/actions/build-image" | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|   - package-ecosystem: github-actions |   - package-ecosystem: github-actions | ||||||
|  |     labels: | ||||||
|  |       - "dependencies" | ||||||
|  |       - "github-actions" | ||||||
|     directory: "/.github/actions/restore-python" |     directory: "/.github/actions/restore-python" | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
|   | |||||||
							
								
								
									
										517
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										517
									
								
								.github/workflows/auto-label-pr.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,517 @@ | |||||||
|  | name: Auto Label PR | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   # Runs only on pull_request_target due to having access to a App token. | ||||||
|  |   # This means PRs from forks will not be able to alter this workflow to get the tokens | ||||||
|  |   pull_request_target: | ||||||
|  |     types: [labeled, opened, reopened, synchronize, edited] | ||||||
|  |  | ||||||
|  | permissions: | ||||||
|  |   pull-requests: write | ||||||
|  |   contents: read | ||||||
|  |  | ||||||
|  | env: | ||||||
|  |   SMALL_PR_THRESHOLD: 30 | ||||||
|  |   MAX_LABELS: 15 | ||||||
|  |   TOO_BIG_THRESHOLD: 1000 | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   label: | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout | ||||||
|  |         uses: actions/checkout@v4.2.2 | ||||||
|  |  | ||||||
|  |       - name: Get changes | ||||||
|  |         id: changes | ||||||
|  |         env: | ||||||
|  |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |         run: | | ||||||
|  |           # Get PR number | ||||||
|  |           pr_number="${{ github.event.pull_request.number }}" | ||||||
|  |  | ||||||
|  |           # Get list of changed files using gh CLI | ||||||
|  |           files=$(gh pr diff $pr_number --name-only) | ||||||
|  |           echo "files<<EOF" >> $GITHUB_OUTPUT | ||||||
|  |           echo "$files" >> $GITHUB_OUTPUT | ||||||
|  |           echo "EOF" >> $GITHUB_OUTPUT | ||||||
|  |  | ||||||
|  |           # Get file stats (additions + deletions) using gh CLI | ||||||
|  |           stats=$(gh pr view $pr_number --json files --jq '.files | map(.additions + .deletions) | add') | ||||||
|  |           echo "total_changes=${stats:-0}" >> $GITHUB_OUTPUT | ||||||
|  |  | ||||||
|  |       - name: Generate a token | ||||||
|  |         id: generate-token | ||||||
|  |         uses: actions/create-github-app-token@v2 | ||||||
|  |         with: | ||||||
|  |           app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} | ||||||
|  |           private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} | ||||||
|  |  | ||||||
|  |       - name: Auto Label PR | ||||||
|  |         uses: actions/github-script@v7.0.1 | ||||||
|  |         with: | ||||||
|  |           github-token: ${{ steps.generate-token.outputs.token }} | ||||||
|  |           script: | | ||||||
|  |             const fs = require('fs'); | ||||||
|  |  | ||||||
|  |             const { owner, repo } = context.repo; | ||||||
|  |             const pr_number = context.issue.number; | ||||||
|  |  | ||||||
|  |             // Hidden marker to identify bot comments from this workflow | ||||||
|  |             const BOT_COMMENT_MARKER = '<!-- auto-label-pr-bot -->'; | ||||||
|  |  | ||||||
|  |             // Get current labels | ||||||
|  |             const { data: currentLabelsData } = await github.rest.issues.listLabelsOnIssue({ | ||||||
|  |               owner, | ||||||
|  |               repo, | ||||||
|  |               issue_number: pr_number | ||||||
|  |             }); | ||||||
|  |             const currentLabels = currentLabelsData.map(label => label.name); | ||||||
|  |  | ||||||
|  |             // Define managed labels that this workflow controls | ||||||
|  |             const managedLabels = currentLabels.filter(label => | ||||||
|  |               label.startsWith('component: ') || | ||||||
|  |               [ | ||||||
|  |                 'new-component', | ||||||
|  |                 'new-platform', | ||||||
|  |                 'new-target-platform', | ||||||
|  |                 'merging-to-release', | ||||||
|  |                 'merging-to-beta', | ||||||
|  |                 'core', | ||||||
|  |                 'small-pr', | ||||||
|  |                 'dashboard', | ||||||
|  |                 'github-actions', | ||||||
|  |                 'by-code-owner', | ||||||
|  |                 'has-tests', | ||||||
|  |                 'needs-tests', | ||||||
|  |                 'needs-docs', | ||||||
|  |                 'too-big', | ||||||
|  |                 'labeller-recheck' | ||||||
|  |               ].includes(label) | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             console.log('Current labels:', currentLabels.join(', ')); | ||||||
|  |             console.log('Managed labels:', managedLabels.join(', ')); | ||||||
|  |  | ||||||
|  |             // Get changed files | ||||||
|  |             const changedFiles = `${{ steps.changes.outputs.files }}`.split('\n').filter(f => f.length > 0); | ||||||
|  |             const totalChanges = parseInt('${{ steps.changes.outputs.total_changes }}') || 0; | ||||||
|  |  | ||||||
|  |             console.log('Changed files:', changedFiles.length); | ||||||
|  |             console.log('Total changes:', totalChanges); | ||||||
|  |  | ||||||
|  |             const labels = new Set(); | ||||||
|  |  | ||||||
|  |             // Fetch TARGET_PLATFORMS and PLATFORM_COMPONENTS from API | ||||||
|  |             let targetPlatforms = []; | ||||||
|  |             let platformComponents = []; | ||||||
|  |  | ||||||
|  |             try { | ||||||
|  |               const response = await fetch('https://data.esphome.io/components.json'); | ||||||
|  |               const componentsData = await response.json(); | ||||||
|  |  | ||||||
|  |               // Extract target platforms and platform components directly from API | ||||||
|  |               targetPlatforms = componentsData.target_platforms || []; | ||||||
|  |               platformComponents = componentsData.platform_components || []; | ||||||
|  |  | ||||||
|  |               console.log('Target platforms from API:', targetPlatforms.length, targetPlatforms); | ||||||
|  |               console.log('Platform components from API:', platformComponents.length, platformComponents); | ||||||
|  |             } catch (error) { | ||||||
|  |               console.log('Failed to fetch components data from API:', error.message); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Get environment variables | ||||||
|  |             const smallPrThreshold = parseInt('${{ env.SMALL_PR_THRESHOLD }}'); | ||||||
|  |             const maxLabels = parseInt('${{ env.MAX_LABELS }}'); | ||||||
|  |             const tooBigThreshold = parseInt('${{ env.TOO_BIG_THRESHOLD }}'); | ||||||
|  |  | ||||||
|  |             // Strategy: Merge to release or beta branch | ||||||
|  |             const baseRef = context.payload.pull_request.base.ref; | ||||||
|  |             if (baseRef !== 'dev') { | ||||||
|  |               if (baseRef === 'release') { | ||||||
|  |                 labels.add('merging-to-release'); | ||||||
|  |               } else if (baseRef === 'beta') { | ||||||
|  |                 labels.add('merging-to-beta'); | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               // When targeting non-dev branches, only use merge warning labels | ||||||
|  |               const finalLabels = Array.from(labels); | ||||||
|  |               console.log('Computed labels (merge branch only):', finalLabels.join(', ')); | ||||||
|  |  | ||||||
|  |               // Add new labels | ||||||
|  |               if (finalLabels.length > 0) { | ||||||
|  |                 console.log(`Adding labels: ${finalLabels.join(', ')}`); | ||||||
|  |                 await github.rest.issues.addLabels({ | ||||||
|  |                   owner, | ||||||
|  |                   repo, | ||||||
|  |                   issue_number: pr_number, | ||||||
|  |                   labels: finalLabels | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               // Remove old managed labels that are no longer needed | ||||||
|  |               const labelsToRemove = managedLabels.filter(label => | ||||||
|  |                 !finalLabels.includes(label) | ||||||
|  |               ); | ||||||
|  |  | ||||||
|  |               for (const label of labelsToRemove) { | ||||||
|  |                 console.log(`Removing label: ${label}`); | ||||||
|  |                 try { | ||||||
|  |                   await github.rest.issues.removeLabel({ | ||||||
|  |                     owner, | ||||||
|  |                     repo, | ||||||
|  |                     issue_number: pr_number, | ||||||
|  |                     name: label | ||||||
|  |                   }); | ||||||
|  |                 } catch (error) { | ||||||
|  |                   console.log(`Failed to remove label ${label}:`, error.message); | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               return; // Exit early, don't process other strategies | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: Component and Platform labeling | ||||||
|  |             const componentRegex = /^esphome\/components\/([^\/]+)\//; | ||||||
|  |             const targetPlatformRegex = new RegExp(`^esphome\/components\/(${targetPlatforms.join('|')})/`); | ||||||
|  |  | ||||||
|  |             for (const file of changedFiles) { | ||||||
|  |               // Check for component changes | ||||||
|  |               const componentMatch = file.match(componentRegex); | ||||||
|  |               if (componentMatch) { | ||||||
|  |                 const component = componentMatch[1]; | ||||||
|  |                 labels.add(`component: ${component}`); | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               // Check for target platform changes | ||||||
|  |               const platformMatch = file.match(targetPlatformRegex); | ||||||
|  |               if (platformMatch) { | ||||||
|  |                 const targetPlatform = platformMatch[1]; | ||||||
|  |                 labels.add(`platform: ${targetPlatform}`); | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Get PR files for new component/platform detection | ||||||
|  |             const { data: prFiles } = await github.rest.pulls.listFiles({ | ||||||
|  |               owner, | ||||||
|  |               repo, | ||||||
|  |               pull_number: pr_number | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename); | ||||||
|  |  | ||||||
|  |             // Calculate changes excluding root tests directory for too-big calculation | ||||||
|  |             const testChanges = prFiles | ||||||
|  |               .filter(file => file.filename.startsWith('tests/')) | ||||||
|  |               .reduce((sum, file) => sum + (file.additions || 0) + (file.deletions || 0), 0); | ||||||
|  |  | ||||||
|  |             const nonTestChanges = totalChanges - testChanges; | ||||||
|  |             console.log(`Test changes: ${testChanges}, Non-test changes: ${nonTestChanges}`); | ||||||
|  |  | ||||||
|  |             // Strategy: New Component detection | ||||||
|  |             for (const file of addedFiles) { | ||||||
|  |               // Check for new component files: esphome/components/{component}/__init__.py | ||||||
|  |               const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/); | ||||||
|  |               if (componentMatch) { | ||||||
|  |                 try { | ||||||
|  |                   // Read the content directly from the filesystem since we have it checked out | ||||||
|  |                   const content = fs.readFileSync(file, 'utf8'); | ||||||
|  |  | ||||||
|  |                   // Strategy: New Target Platform detection | ||||||
|  |                   if (content.includes('IS_TARGET_PLATFORM = True')) { | ||||||
|  |                     labels.add('new-target-platform'); | ||||||
|  |                   } | ||||||
|  |                   labels.add('new-component'); | ||||||
|  |                 } catch (error) { | ||||||
|  |                   console.log(`Failed to read content of ${file}:`, error.message); | ||||||
|  |                   // Fallback: assume it's a new component if we can't read the content | ||||||
|  |                   labels.add('new-component'); | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: New Platform detection | ||||||
|  |             for (const file of addedFiles) { | ||||||
|  |               // Check for new platform files: esphome/components/{component}/{platform}.py | ||||||
|  |               const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/); | ||||||
|  |               if (platformFileMatch) { | ||||||
|  |                 const [, component, platform] = platformFileMatch; | ||||||
|  |                 if (platformComponents.includes(platform)) { | ||||||
|  |                   labels.add('new-platform'); | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               // Check for new platform files: esphome/components/{component}/{platform}/__init__.py | ||||||
|  |               const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/); | ||||||
|  |               if (platformDirMatch) { | ||||||
|  |                 const [, component, platform] = platformDirMatch; | ||||||
|  |                 if (platformComponents.includes(platform)) { | ||||||
|  |                   labels.add('new-platform'); | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             const coreFiles = changedFiles.filter(file => | ||||||
|  |               file.startsWith('esphome/core/') || | ||||||
|  |               (file.startsWith('esphome/') && file.split('/').length === 2) | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             if (coreFiles.length > 0) { | ||||||
|  |               labels.add('core'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: Small PR detection | ||||||
|  |             if (totalChanges <= smallPrThreshold) { | ||||||
|  |               labels.add('small-pr'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: Dashboard changes | ||||||
|  |             const dashboardFiles = changedFiles.filter(file => | ||||||
|  |               file.startsWith('esphome/dashboard/') || | ||||||
|  |               file.startsWith('esphome/components/dashboard_import/') | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             if (dashboardFiles.length > 0) { | ||||||
|  |               labels.add('dashboard'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: GitHub Actions changes | ||||||
|  |             const githubActionsFiles = changedFiles.filter(file => | ||||||
|  |               file.startsWith('.github/workflows/') | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             if (githubActionsFiles.length > 0) { | ||||||
|  |               labels.add('github-actions'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: Code Owner detection | ||||||
|  |             try { | ||||||
|  |               // Fetch CODEOWNERS file from the repository (in case it was changed in this PR) | ||||||
|  |               const { data: codeownersFile } = await github.rest.repos.getContent({ | ||||||
|  |                 owner, | ||||||
|  |                 repo, | ||||||
|  |                 path: 'CODEOWNERS', | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf8'); | ||||||
|  |               const prAuthor = context.payload.pull_request.user.login; | ||||||
|  |  | ||||||
|  |               // Parse CODEOWNERS file | ||||||
|  |               const codeownersLines = codeownersContent.split('\n') | ||||||
|  |                 .map(line => line.trim()) | ||||||
|  |                 .filter(line => line && !line.startsWith('#')); | ||||||
|  |  | ||||||
|  |               let isCodeOwner = false; | ||||||
|  |  | ||||||
|  |               // Precompile CODEOWNERS patterns into regex objects | ||||||
|  |               const codeownersRegexes = codeownersLines.map(line => { | ||||||
|  |                 const parts = line.split(/\s+/); | ||||||
|  |                 const pattern = parts[0]; | ||||||
|  |                 const owners = parts.slice(1); | ||||||
|  |  | ||||||
|  |                 let regex; | ||||||
|  |                 if (pattern.endsWith('*')) { | ||||||
|  |                   // Directory pattern like "esphome/components/api/*" | ||||||
|  |                   const dir = pattern.slice(0, -1); | ||||||
|  |                   regex = new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`); | ||||||
|  |                 } else if (pattern.includes('*')) { | ||||||
|  |                   // Glob pattern | ||||||
|  |                   const regexPattern = pattern | ||||||
|  |                     .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | ||||||
|  |                     .replace(/\\*/g, '.*'); | ||||||
|  |                   regex = new RegExp(`^${regexPattern}$`); | ||||||
|  |                 } else { | ||||||
|  |                   // Exact match | ||||||
|  |                   regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 return { regex, owners }; | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               for (const file of changedFiles) { | ||||||
|  |                 for (const { regex, owners } of codeownersRegexes) { | ||||||
|  |                   if (regex.test(file)) { | ||||||
|  |                     // Check if PR author is in the owners list | ||||||
|  |                     if (owners.some(owner => owner === `@${prAuthor}`)) { | ||||||
|  |                       isCodeOwner = true; | ||||||
|  |                       break; | ||||||
|  |                     } | ||||||
|  |                   } | ||||||
|  |                 } | ||||||
|  |                 if (isCodeOwner) break; | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               if (isCodeOwner) { | ||||||
|  |                 labels.add('by-code-owner'); | ||||||
|  |               } | ||||||
|  |             } catch (error) { | ||||||
|  |               console.log('Failed to read or parse CODEOWNERS file:', error.message); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: Test detection | ||||||
|  |             const testFiles = changedFiles.filter(file => | ||||||
|  |               file.startsWith('tests/') | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             if (testFiles.length > 0) { | ||||||
|  |               labels.add('has-tests'); | ||||||
|  |             } else { | ||||||
|  |               // Only check for needs-tests if this is a new component or new platform | ||||||
|  |               if (labels.has('new-component') || labels.has('new-platform')) { | ||||||
|  |                 labels.add('needs-tests'); | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Strategy: Documentation check for new components/platforms | ||||||
|  |             if (labels.has('new-component') || labels.has('new-platform')) { | ||||||
|  |               const prBody = context.payload.pull_request.body || ''; | ||||||
|  |  | ||||||
|  |               // Look for documentation PR links | ||||||
|  |               // Patterns to match: | ||||||
|  |               // - https://github.com/esphome/esphome-docs/pull/1234 | ||||||
|  |               // - esphome/esphome-docs#1234 | ||||||
|  |               const docsPrPatterns = [ | ||||||
|  |                 /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, | ||||||
|  |                 /esphome\/esphome-docs#\d+/ | ||||||
|  |               ]; | ||||||
|  |  | ||||||
|  |               const hasDocsLink = docsPrPatterns.some(pattern => pattern.test(prBody)); | ||||||
|  |  | ||||||
|  |               if (!hasDocsLink) { | ||||||
|  |                 labels.add('needs-docs'); | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Convert Set to Array | ||||||
|  |             let finalLabels = Array.from(labels); | ||||||
|  |  | ||||||
|  |             console.log('Computed labels:', finalLabels.join(', ')); | ||||||
|  |  | ||||||
|  |             // Check if PR has mega-pr label | ||||||
|  |             const isMegaPR = currentLabels.includes('mega-pr'); | ||||||
|  |  | ||||||
|  |             // Check if PR is too big (either too many labels or too many line changes) | ||||||
|  |             const tooManyLabels = finalLabels.length > maxLabels; | ||||||
|  |             const tooManyChanges = nonTestChanges > tooBigThreshold; | ||||||
|  |  | ||||||
|  |             if ((tooManyLabels || tooManyChanges) && !isMegaPR) { | ||||||
|  |               const originalLength = finalLabels.length; | ||||||
|  |               console.log(`PR is too big - Labels: ${originalLength}, Changes: ${totalChanges} (non-test: ${nonTestChanges})`); | ||||||
|  |  | ||||||
|  |               // Get all reviews on this PR to check for existing bot reviews | ||||||
|  |               const { data: reviews } = await github.rest.pulls.listReviews({ | ||||||
|  |                 owner, | ||||||
|  |                 repo, | ||||||
|  |                 pull_number: pr_number | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               // Check if there's already an active bot review requesting changes | ||||||
|  |               const existingBotReview = reviews.find(review => | ||||||
|  |                 review.user.type === 'Bot' && | ||||||
|  |                 review.state === 'CHANGES_REQUESTED' && | ||||||
|  |                 review.body && review.body.includes(BOT_COMMENT_MARKER) | ||||||
|  |               ); | ||||||
|  |  | ||||||
|  |               // If too big due to line changes only, keep original labels and add too-big | ||||||
|  |               // If too big due to too many labels, replace with just too-big | ||||||
|  |               if (tooManyChanges && !tooManyLabels) { | ||||||
|  |                 finalLabels.push('too-big'); | ||||||
|  |               } else { | ||||||
|  |                 finalLabels = ['too-big']; | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               // Only create a new review if there isn't already an active bot review | ||||||
|  |               if (!existingBotReview) { | ||||||
|  |                 // Create appropriate review message | ||||||
|  |                 let reviewBody; | ||||||
|  |                 if (tooManyLabels && tooManyChanges) { | ||||||
|  |                   reviewBody = `${BOT_COMMENT_MARKER}\nThis PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLength} different components/areas. Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; | ||||||
|  |                 } else if (tooManyLabels) { | ||||||
|  |                   reviewBody = `${BOT_COMMENT_MARKER}\nThis PR affects ${originalLength} different components/areas. Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; | ||||||
|  |                 } else { | ||||||
|  |                   reviewBody = `${BOT_COMMENT_MARKER}\nThis PR is too large with ${nonTestChanges} line changes (excluding tests). Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\nFor guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#but-howwww-looonnnggg`; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Request changes on the PR | ||||||
|  |                 await github.rest.pulls.createReview({ | ||||||
|  |                   owner, | ||||||
|  |                   repo, | ||||||
|  |                   pull_number: pr_number, | ||||||
|  |                   body: reviewBody, | ||||||
|  |                   event: 'REQUEST_CHANGES' | ||||||
|  |                 }); | ||||||
|  |                 console.log('Created new "too big" review requesting changes'); | ||||||
|  |               } else { | ||||||
|  |                 console.log('Skipping review creation - existing bot review already requesting changes'); | ||||||
|  |               } | ||||||
|  |             } else { | ||||||
|  |               // Check if PR was previously too big but is now acceptable | ||||||
|  |               const wasPreviouslyTooBig = currentLabels.includes('too-big'); | ||||||
|  |  | ||||||
|  |               if (wasPreviouslyTooBig || isMegaPR) { | ||||||
|  |                 console.log('PR is no longer too big or has mega-pr label - dismissing bot reviews'); | ||||||
|  |  | ||||||
|  |                 // Get all reviews on this PR to find reviews to dismiss | ||||||
|  |                 const { data: reviews } = await github.rest.pulls.listReviews({ | ||||||
|  |                   owner, | ||||||
|  |                   repo, | ||||||
|  |                   pull_number: pr_number | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |                 // Find bot reviews that requested changes | ||||||
|  |                 const botReviews = reviews.filter(review => | ||||||
|  |                   review.user.type === 'Bot' && | ||||||
|  |                   review.state === 'CHANGES_REQUESTED' && | ||||||
|  |                   review.body && review.body.includes(BOT_COMMENT_MARKER) | ||||||
|  |                 ); | ||||||
|  |  | ||||||
|  |                 // Dismiss bot reviews | ||||||
|  |                 for (const review of botReviews) { | ||||||
|  |                   try { | ||||||
|  |                     await github.rest.pulls.dismissReview({ | ||||||
|  |                       owner, | ||||||
|  |                       repo, | ||||||
|  |                       pull_number: pr_number, | ||||||
|  |                       review_id: review.id, | ||||||
|  |                       message: isMegaPR ? | ||||||
|  |                         'Review dismissed: mega-pr label was added' : | ||||||
|  |                         'Review dismissed: PR size is now acceptable' | ||||||
|  |                     }); | ||||||
|  |                     console.log(`Dismissed review ${review.id}`); | ||||||
|  |                   } catch (error) { | ||||||
|  |                     console.log(`Failed to dismiss review ${review.id}:`, error.message); | ||||||
|  |                   } | ||||||
|  |                 } | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Add new labels | ||||||
|  |             if (finalLabels.length > 0) { | ||||||
|  |               console.log(`Adding labels: ${finalLabels.join(', ')}`); | ||||||
|  |               await github.rest.issues.addLabels({ | ||||||
|  |                 owner, | ||||||
|  |                 repo, | ||||||
|  |                 issue_number: pr_number, | ||||||
|  |                 labels: finalLabels | ||||||
|  |               }); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Remove old managed labels that are no longer needed | ||||||
|  |             const labelsToRemove = managedLabels.filter(label => | ||||||
|  |               !finalLabels.includes(label) | ||||||
|  |             ); | ||||||
|  |  | ||||||
|  |             for (const label of labelsToRemove) { | ||||||
|  |               console.log(`Removing label: ${label}`); | ||||||
|  |               try { | ||||||
|  |                 await github.rest.issues.removeLabel({ | ||||||
|  |                   owner, | ||||||
|  |                   repo, | ||||||
|  |                   issue_number: pr_number, | ||||||
|  |                   name: label | ||||||
|  |                 }); | ||||||
|  |               } catch (error) { | ||||||
|  |                 console.log(`Failed to remove label ${label}:`, error.message); | ||||||
|  |               } | ||||||
|  |             } | ||||||
							
								
								
									
										75
									
								
								.github/workflows/ci-clang-tidy-hash.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								.github/workflows/ci-clang-tidy-hash.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | name: Clang-tidy Hash CI | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   pull_request: | ||||||
|  |     paths: | ||||||
|  |       - ".clang-tidy" | ||||||
|  |       - "platformio.ini" | ||||||
|  |       - "requirements_dev.txt" | ||||||
|  |       - ".clang-tidy.hash" | ||||||
|  |       - "script/clang_tidy_hash.py" | ||||||
|  |       - ".github/workflows/ci-clang-tidy-hash.yml" | ||||||
|  |  | ||||||
|  | permissions: | ||||||
|  |   contents: read | ||||||
|  |   pull-requests: write | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   verify-hash: | ||||||
|  |     name: Verify clang-tidy hash | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - name: Checkout | ||||||
|  |         uses: actions/checkout@v4.2.2 | ||||||
|  |  | ||||||
|  |       - name: Set up Python | ||||||
|  |         uses: actions/setup-python@v5.6.0 | ||||||
|  |         with: | ||||||
|  |           python-version: "3.11" | ||||||
|  |  | ||||||
|  |       - name: Verify hash | ||||||
|  |         run: | | ||||||
|  |           python script/clang_tidy_hash.py --verify | ||||||
|  |  | ||||||
|  |       - if: failure() | ||||||
|  |         name: Show hash details | ||||||
|  |         run: | | ||||||
|  |           python script/clang_tidy_hash.py | ||||||
|  |           echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY | ||||||
|  |           echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY | ||||||
|  |           echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY | ||||||
|  |  | ||||||
|  |       - if: failure() | ||||||
|  |         name: Request changes | ||||||
|  |         uses: actions/github-script@v7.0.1 | ||||||
|  |         with: | ||||||
|  |           script: | | ||||||
|  |             await github.rest.pulls.createReview({ | ||||||
|  |               pull_number: context.issue.number, | ||||||
|  |               owner: context.repo.owner, | ||||||
|  |               repo: context.repo.repo, | ||||||
|  |               event: 'REQUEST_CHANGES', | ||||||
|  |               body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.' | ||||||
|  |             }) | ||||||
|  |  | ||||||
|  |       - if: success() | ||||||
|  |         name: Dismiss review | ||||||
|  |         uses: actions/github-script@v7.0.1 | ||||||
|  |         with: | ||||||
|  |           script: | | ||||||
|  |             let reviews = await github.rest.pulls.listReviews({ | ||||||
|  |               pull_number: context.issue.number, | ||||||
|  |               owner: context.repo.owner, | ||||||
|  |               repo: context.repo.repo | ||||||
|  |             }); | ||||||
|  |             for (let review of reviews.data) { | ||||||
|  |               if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') { | ||||||
|  |                 await github.rest.pulls.dismissReview({ | ||||||
|  |                   pull_number: context.issue.number, | ||||||
|  |                   owner: context.repo.owner, | ||||||
|  |                   repo: context.repo.repo, | ||||||
|  |                   review_id: review.id, | ||||||
|  |                   message: 'Clang-tidy hash now matches configuration.' | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|  |             } | ||||||
							
								
								
									
										2
									
								
								.github/workflows/ci-docker.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-docker.yml
									
									
									
									
										vendored
									
									
								
							| @@ -47,7 +47,7 @@ jobs: | |||||||
|       - name: Set up Python |       - name: Set up Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v5.6.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.10" |           python-version: "3.11" | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v3.11.1 |         uses: docker/setup-buildx-action@v3.11.1 | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										277
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										277
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -20,8 +20,8 @@ permissions: | |||||||
|   contents: read |   contents: read | ||||||
|  |  | ||||||
| env: | env: | ||||||
|   DEFAULT_PYTHON: "3.10" |   DEFAULT_PYTHON: "3.11" | ||||||
|   PYUPGRADE_TARGET: "--py310-plus" |   PYUPGRADE_TARGET: "--py311-plus" | ||||||
|  |  | ||||||
| concurrency: | concurrency: | ||||||
|   # yamllint disable-line rule:line-length |   # yamllint disable-line rule:line-length | ||||||
| @@ -39,7 +39,7 @@ jobs: | |||||||
|         uses: actions/checkout@v4.2.2 |         uses: actions/checkout@v4.2.2 | ||||||
|       - name: Generate cache-key |       - name: Generate cache-key | ||||||
|         id: cache-key |         id: cache-key | ||||||
|         run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt') }}" >> $GITHUB_OUTPUT |         run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT | ||||||
|       - name: Set up Python ${{ env.DEFAULT_PYTHON }} |       - name: Set up Python ${{ env.DEFAULT_PYTHON }} | ||||||
|         id: python |         id: python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v5.6.0 | ||||||
| @@ -58,56 +58,16 @@ jobs: | |||||||
|           python -m venv venv |           python -m venv venv | ||||||
|           . venv/bin/activate |           . venv/bin/activate | ||||||
|           python --version |           python --version | ||||||
|           pip install -r requirements.txt -r requirements_test.txt |           pip install -r requirements.txt -r requirements_test.txt pre-commit | ||||||
|           pip install -e . |           pip install -e . | ||||||
|  |  | ||||||
|   ruff: |  | ||||||
|     name: Check ruff |  | ||||||
|     runs-on: ubuntu-24.04 |  | ||||||
|     needs: |  | ||||||
|       - common |  | ||||||
|     steps: |  | ||||||
|       - name: Check out code from GitHub |  | ||||||
|         uses: actions/checkout@v4.2.2 |  | ||||||
|       - name: Restore Python |  | ||||||
|         uses: ./.github/actions/restore-python |  | ||||||
|         with: |  | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON }} |  | ||||||
|           cache-key: ${{ needs.common.outputs.cache-key }} |  | ||||||
|       - name: Run Ruff |  | ||||||
|         run: | |  | ||||||
|           . venv/bin/activate |  | ||||||
|           ruff format esphome tests |  | ||||||
|       - name: Suggested changes |  | ||||||
|         run: script/ci-suggest-changes |  | ||||||
|         if: always() |  | ||||||
|  |  | ||||||
|   flake8: |  | ||||||
|     name: Check flake8 |  | ||||||
|     runs-on: ubuntu-24.04 |  | ||||||
|     needs: |  | ||||||
|       - common |  | ||||||
|     steps: |  | ||||||
|       - name: Check out code from GitHub |  | ||||||
|         uses: actions/checkout@v4.2.2 |  | ||||||
|       - name: Restore Python |  | ||||||
|         uses: ./.github/actions/restore-python |  | ||||||
|         with: |  | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON }} |  | ||||||
|           cache-key: ${{ needs.common.outputs.cache-key }} |  | ||||||
|       - name: Run flake8 |  | ||||||
|         run: | |  | ||||||
|           . venv/bin/activate |  | ||||||
|           flake8 esphome |  | ||||||
|       - name: Suggested changes |  | ||||||
|         run: script/ci-suggest-changes |  | ||||||
|         if: always() |  | ||||||
|  |  | ||||||
|   pylint: |   pylint: | ||||||
|     name: Check pylint |     name: Check pylint | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
|  |       - determine-jobs | ||||||
|  |     if: needs.determine-jobs.outputs.python-linters == 'true' | ||||||
|     steps: |     steps: | ||||||
|       - name: Check out code from GitHub |       - name: Check out code from GitHub | ||||||
|         uses: actions/checkout@v4.2.2 |         uses: actions/checkout@v4.2.2 | ||||||
| @@ -124,27 +84,6 @@ jobs: | |||||||
|         run: script/ci-suggest-changes |         run: script/ci-suggest-changes | ||||||
|         if: always() |         if: always() | ||||||
|  |  | ||||||
|   pyupgrade: |  | ||||||
|     name: Check pyupgrade |  | ||||||
|     runs-on: ubuntu-24.04 |  | ||||||
|     needs: |  | ||||||
|       - common |  | ||||||
|     steps: |  | ||||||
|       - name: Check out code from GitHub |  | ||||||
|         uses: actions/checkout@v4.2.2 |  | ||||||
|       - name: Restore Python |  | ||||||
|         uses: ./.github/actions/restore-python |  | ||||||
|         with: |  | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON }} |  | ||||||
|           cache-key: ${{ needs.common.outputs.cache-key }} |  | ||||||
|       - name: Run pyupgrade |  | ||||||
|         run: | |  | ||||||
|           . venv/bin/activate |  | ||||||
|           pyupgrade ${{ env.PYUPGRADE_TARGET }} `find esphome -name "*.py" -type f` |  | ||||||
|       - name: Suggested changes |  | ||||||
|         run: script/ci-suggest-changes |  | ||||||
|         if: always() |  | ||||||
|  |  | ||||||
|   ci-custom: |   ci-custom: | ||||||
|     name: Run script/ci-custom |     name: Run script/ci-custom | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
| @@ -173,7 +112,6 @@ jobs: | |||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       matrix: |       matrix: | ||||||
|         python-version: |         python-version: | ||||||
|           - "3.10" |  | ||||||
|           - "3.11" |           - "3.11" | ||||||
|           - "3.12" |           - "3.12" | ||||||
|           - "3.13" |           - "3.13" | ||||||
| @@ -189,14 +127,10 @@ jobs: | |||||||
|             os: windows-latest |             os: windows-latest | ||||||
|           - python-version: "3.12" |           - python-version: "3.12" | ||||||
|             os: windows-latest |             os: windows-latest | ||||||
|           - python-version: "3.10" |  | ||||||
|             os: windows-latest |  | ||||||
|           - python-version: "3.13" |           - python-version: "3.13" | ||||||
|             os: macOS-latest |             os: macOS-latest | ||||||
|           - python-version: "3.12" |           - python-version: "3.12" | ||||||
|             os: macOS-latest |             os: macOS-latest | ||||||
|           - python-version: "3.10" |  | ||||||
|             os: macOS-latest |  | ||||||
|     runs-on: ${{ matrix.os }} |     runs-on: ${{ matrix.os }} | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
| @@ -204,6 +138,7 @@ jobs: | |||||||
|       - name: Check out code from GitHub |       - name: Check out code from GitHub | ||||||
|         uses: actions/checkout@v4.2.2 |         uses: actions/checkout@v4.2.2 | ||||||
|       - name: Restore Python |       - name: Restore Python | ||||||
|  |         id: restore-python | ||||||
|         uses: ./.github/actions/restore-python |         uses: ./.github/actions/restore-python | ||||||
|         with: |         with: | ||||||
|           python-version: ${{ matrix.python-version }} |           python-version: ${{ matrix.python-version }} | ||||||
| @@ -213,7 +148,7 @@ jobs: | |||||||
|       - name: Run pytest |       - name: Run pytest | ||||||
|         if: matrix.os == 'windows-latest' |         if: matrix.os == 'windows-latest' | ||||||
|         run: | |         run: | | ||||||
|           ./venv/Scripts/activate |           . ./venv/Scripts/activate.ps1 | ||||||
|           pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ |           pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ | ||||||
|       - name: Run pytest |       - name: Run pytest | ||||||
|         if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' |         if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' | ||||||
| @@ -224,12 +159,59 @@ jobs: | |||||||
|         uses: codecov/codecov-action@v5.4.3 |         uses: codecov/codecov-action@v5.4.3 | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |           token: ${{ secrets.CODECOV_TOKEN }} | ||||||
|  |       - name: Save Python virtual environment cache | ||||||
|  |         if: github.ref == 'refs/heads/dev' | ||||||
|  |         uses: actions/cache/save@v4.2.3 | ||||||
|  |         with: | ||||||
|  |           path: venv | ||||||
|  |           key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} | ||||||
|  |  | ||||||
|  |   determine-jobs: | ||||||
|  |     name: Determine which jobs to run | ||||||
|  |     runs-on: ubuntu-24.04 | ||||||
|  |     needs: | ||||||
|  |       - common | ||||||
|  |     outputs: | ||||||
|  |       integration-tests: ${{ steps.determine.outputs.integration-tests }} | ||||||
|  |       clang-tidy: ${{ steps.determine.outputs.clang-tidy }} | ||||||
|  |       python-linters: ${{ steps.determine.outputs.python-linters }} | ||||||
|  |       changed-components: ${{ steps.determine.outputs.changed-components }} | ||||||
|  |       component-test-count: ${{ steps.determine.outputs.component-test-count }} | ||||||
|  |     steps: | ||||||
|  |       - name: Check out code from GitHub | ||||||
|  |         uses: actions/checkout@v4.2.2 | ||||||
|  |         with: | ||||||
|  |           # Fetch enough history to find the merge base | ||||||
|  |           fetch-depth: 2 | ||||||
|  |       - name: Restore Python | ||||||
|  |         uses: ./.github/actions/restore-python | ||||||
|  |         with: | ||||||
|  |           python-version: ${{ env.DEFAULT_PYTHON }} | ||||||
|  |           cache-key: ${{ needs.common.outputs.cache-key }} | ||||||
|  |       - name: Determine which tests to run | ||||||
|  |         id: determine | ||||||
|  |         env: | ||||||
|  |           GH_TOKEN: ${{ github.token }} | ||||||
|  |         run: | | ||||||
|  |           . venv/bin/activate | ||||||
|  |           output=$(python script/determine-jobs.py) | ||||||
|  |           echo "Test determination output:" | ||||||
|  |           echo "$output" | jq | ||||||
|  |  | ||||||
|  |           # Extract individual fields | ||||||
|  |           echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT | ||||||
|  |           echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT | ||||||
|  |           echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT | ||||||
|  |           echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT | ||||||
|  |           echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT | ||||||
|  |  | ||||||
|   integration-tests: |   integration-tests: | ||||||
|     name: Run integration tests |     name: Run integration tests | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
|  |       - determine-jobs | ||||||
|  |     if: needs.determine-jobs.outputs.integration-tests == 'true' | ||||||
|     steps: |     steps: | ||||||
|       - name: Check out code from GitHub |       - name: Check out code from GitHub | ||||||
|         uses: actions/checkout@v4.2.2 |         uses: actions/checkout@v4.2.2 | ||||||
| @@ -259,44 +241,15 @@ jobs: | |||||||
|           . venv/bin/activate |           . venv/bin/activate | ||||||
|           pytest -vv --no-cov --tb=native -n auto tests/integration/ |           pytest -vv --no-cov --tb=native -n auto tests/integration/ | ||||||
|  |  | ||||||
|   clang-format: |  | ||||||
|     name: Check clang-format |  | ||||||
|     runs-on: ubuntu-24.04 |  | ||||||
|     needs: |  | ||||||
|       - common |  | ||||||
|     steps: |  | ||||||
|       - name: Check out code from GitHub |  | ||||||
|         uses: actions/checkout@v4.2.2 |  | ||||||
|       - name: Restore Python |  | ||||||
|         uses: ./.github/actions/restore-python |  | ||||||
|         with: |  | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON }} |  | ||||||
|           cache-key: ${{ needs.common.outputs.cache-key }} |  | ||||||
|       - name: Install clang-format |  | ||||||
|         run: | |  | ||||||
|           . venv/bin/activate |  | ||||||
|           pip install clang-format -c requirements_dev.txt |  | ||||||
|       - name: Run clang-format |  | ||||||
|         run: | |  | ||||||
|           . venv/bin/activate |  | ||||||
|           script/clang-format -i |  | ||||||
|           git diff-index --quiet HEAD -- |  | ||||||
|       - name: Suggested changes |  | ||||||
|         run: script/ci-suggest-changes |  | ||||||
|         if: always() |  | ||||||
|  |  | ||||||
|   clang-tidy: |   clang-tidy: | ||||||
|     name: ${{ matrix.name }} |     name: ${{ matrix.name }} | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
|       - ruff |       - determine-jobs | ||||||
|       - ci-custom |     if: needs.determine-jobs.outputs.clang-tidy == 'true' | ||||||
|       - clang-format |     env: | ||||||
|       - flake8 |       GH_TOKEN: ${{ github.token }} | ||||||
|       - pylint |  | ||||||
|       - pytest |  | ||||||
|       - pyupgrade |  | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       max-parallel: 2 |       max-parallel: 2 | ||||||
| @@ -335,6 +288,10 @@ jobs: | |||||||
|     steps: |     steps: | ||||||
|       - name: Check out code from GitHub |       - name: Check out code from GitHub | ||||||
|         uses: actions/checkout@v4.2.2 |         uses: actions/checkout@v4.2.2 | ||||||
|  |         with: | ||||||
|  |           # Need history for HEAD~1 to work for checking changed files | ||||||
|  |           fetch-depth: 2 | ||||||
|  |  | ||||||
|       - name: Restore Python |       - name: Restore Python | ||||||
|         uses: ./.github/actions/restore-python |         uses: ./.github/actions/restore-python | ||||||
|         with: |         with: | ||||||
| @@ -346,14 +303,14 @@ jobs: | |||||||
|         uses: actions/cache@v4.2.3 |         uses: actions/cache@v4.2.3 | ||||||
|         with: |         with: | ||||||
|           path: ~/.platformio |           path: ~/.platformio | ||||||
|           key: platformio-${{ matrix.pio_cache_key }} |           key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} | ||||||
|  |  | ||||||
|       - name: Cache platformio |       - name: Cache platformio | ||||||
|         if: github.ref != 'refs/heads/dev' |         if: github.ref != 'refs/heads/dev' | ||||||
|         uses: actions/cache/restore@v4.2.3 |         uses: actions/cache/restore@v4.2.3 | ||||||
|         with: |         with: | ||||||
|           path: ~/.platformio |           path: ~/.platformio | ||||||
|           key: platformio-${{ matrix.pio_cache_key }} |           key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} | ||||||
|  |  | ||||||
|       - name: Register problem matchers |       - name: Register problem matchers | ||||||
|         run: | |         run: | | ||||||
| @@ -367,10 +324,28 @@ jobs: | |||||||
|           mkdir -p .temp |           mkdir -p .temp | ||||||
|           pio run --list-targets -e esp32-idf-tidy |           pio run --list-targets -e esp32-idf-tidy | ||||||
|  |  | ||||||
|  |       - name: Check if full clang-tidy scan needed | ||||||
|  |         id: check_full_scan | ||||||
|  |         run: | | ||||||
|  |           . venv/bin/activate | ||||||
|  |           if python script/clang_tidy_hash.py --check; then | ||||||
|  |             echo "full_scan=true" >> $GITHUB_OUTPUT | ||||||
|  |             echo "reason=hash_changed" >> $GITHUB_OUTPUT | ||||||
|  |           else | ||||||
|  |             echo "full_scan=false" >> $GITHUB_OUTPUT | ||||||
|  |             echo "reason=normal" >> $GITHUB_OUTPUT | ||||||
|  |           fi | ||||||
|  |  | ||||||
|       - name: Run clang-tidy |       - name: Run clang-tidy | ||||||
|         run: | |         run: | | ||||||
|           . venv/bin/activate |           . venv/bin/activate | ||||||
|           script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} |           if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then | ||||||
|  |             echo "Running FULL clang-tidy scan (hash changed)" | ||||||
|  |             script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} | ||||||
|  |           else | ||||||
|  |             echo "Running clang-tidy on changed files only" | ||||||
|  |             script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} | ||||||
|  |           fi | ||||||
|         env: |         env: | ||||||
|           # Also cache libdeps, store them in a ~/.platformio subfolder |           # Also cache libdeps, store them in a ~/.platformio subfolder | ||||||
|           PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps |           PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps | ||||||
| @@ -380,59 +355,18 @@ jobs: | |||||||
|         # yamllint disable-line rule:line-length |         # yamllint disable-line rule:line-length | ||||||
|         if: always() |         if: always() | ||||||
|  |  | ||||||
|   list-components: |  | ||||||
|     runs-on: ubuntu-24.04 |  | ||||||
|     needs: |  | ||||||
|       - common |  | ||||||
|     if: github.event_name == 'pull_request' |  | ||||||
|     outputs: |  | ||||||
|       components: ${{ steps.list-components.outputs.components }} |  | ||||||
|       count: ${{ steps.list-components.outputs.count }} |  | ||||||
|     steps: |  | ||||||
|       - name: Check out code from GitHub |  | ||||||
|         uses: actions/checkout@v4.2.2 |  | ||||||
|         with: |  | ||||||
|           # Fetch enough history so `git merge-base refs/remotes/origin/dev HEAD` works. |  | ||||||
|           fetch-depth: 500 |  | ||||||
|       - name: Get target branch |  | ||||||
|         id: target-branch |  | ||||||
|         run: | |  | ||||||
|           echo "branch=${{ github.event.pull_request.base.ref }}" >> $GITHUB_OUTPUT |  | ||||||
|       - name: Fetch ${{ steps.target-branch.outputs.branch }} branch |  | ||||||
|         run: | |  | ||||||
|           git -c protocol.version=2 fetch --no-tags --prune --no-recurse-submodules --depth=1 origin +refs/heads/${{ steps.target-branch.outputs.branch }}:refs/remotes/origin/${{ steps.target-branch.outputs.branch }} |  | ||||||
|           git merge-base refs/remotes/origin/${{ steps.target-branch.outputs.branch }} HEAD |  | ||||||
|       - name: Restore Python |  | ||||||
|         uses: ./.github/actions/restore-python |  | ||||||
|         with: |  | ||||||
|           python-version: ${{ env.DEFAULT_PYTHON }} |  | ||||||
|           cache-key: ${{ needs.common.outputs.cache-key }} |  | ||||||
|       - name: Find changed components |  | ||||||
|         id: list-components |  | ||||||
|         run: | |  | ||||||
|           . venv/bin/activate |  | ||||||
|           components=$(script/list-components.py --changed --branch ${{ steps.target-branch.outputs.branch }}) |  | ||||||
|           output_components=$(echo "$components" | jq -R -s -c 'split("\n")[:-1] | map(select(length > 0))') |  | ||||||
|           count=$(echo "$output_components" | jq length) |  | ||||||
|  |  | ||||||
|           echo "components=$output_components" >> $GITHUB_OUTPUT |  | ||||||
|           echo "count=$count" >> $GITHUB_OUTPUT |  | ||||||
|  |  | ||||||
|           echo "$count Components:" |  | ||||||
|           echo "$output_components" | jq |  | ||||||
|  |  | ||||||
|   test-build-components: |   test-build-components: | ||||||
|     name: Component test ${{ matrix.file }} |     name: Component test ${{ matrix.file }} | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
|       - list-components |       - determine-jobs | ||||||
|     if: github.event_name == 'pull_request' && fromJSON(needs.list-components.outputs.count) > 0 && fromJSON(needs.list-components.outputs.count) < 100 |     if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 && fromJSON(needs.determine-jobs.outputs.component-test-count) < 100 | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       max-parallel: 2 |       max-parallel: 2 | ||||||
|       matrix: |       matrix: | ||||||
|         file: ${{ fromJson(needs.list-components.outputs.components) }} |         file: ${{ fromJson(needs.determine-jobs.outputs.changed-components) }} | ||||||
|     steps: |     steps: | ||||||
|       - name: Install dependencies |       - name: Install dependencies | ||||||
|         run: | |         run: | | ||||||
| @@ -460,8 +394,8 @@ jobs: | |||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
|       - list-components |       - determine-jobs | ||||||
|     if: github.event_name == 'pull_request' && fromJSON(needs.list-components.outputs.count) >= 100 |     if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 | ||||||
|     outputs: |     outputs: | ||||||
|       matrix: ${{ steps.split.outputs.components }} |       matrix: ${{ steps.split.outputs.components }} | ||||||
|     steps: |     steps: | ||||||
| @@ -470,7 +404,7 @@ jobs: | |||||||
|       - name: Split components into 20 groups |       - name: Split components into 20 groups | ||||||
|         id: split |         id: split | ||||||
|         run: | |         run: | | ||||||
|           components=$(echo '${{ needs.list-components.outputs.components }}' | jq -c '.[]' | shuf | jq -s -c '[_nwise(20) | join(" ")]') |           components=$(echo '${{ needs.determine-jobs.outputs.changed-components }}' | jq -c '.[]' | shuf | jq -s -c '[_nwise(20) | join(" ")]') | ||||||
|           echo "components=$components" >> $GITHUB_OUTPUT |           echo "components=$components" >> $GITHUB_OUTPUT | ||||||
|  |  | ||||||
|   test-build-components-split: |   test-build-components-split: | ||||||
| @@ -478,9 +412,9 @@ jobs: | |||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
|       - list-components |       - determine-jobs | ||||||
|       - test-build-components-splitter |       - test-build-components-splitter | ||||||
|     if: github.event_name == 'pull_request' && fromJSON(needs.list-components.outputs.count) >= 100 |     if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 | ||||||
|     strategy: |     strategy: | ||||||
|       fail-fast: false |       fail-fast: false | ||||||
|       max-parallel: 4 |       max-parallel: 4 | ||||||
| @@ -517,24 +451,41 @@ jobs: | |||||||
|             ./script/test_build_components -e compile -c $component |             ./script/test_build_components -e compile -c $component | ||||||
|           done |           done | ||||||
|  |  | ||||||
|  |   pre-commit-ci-lite: | ||||||
|  |     name: pre-commit.ci lite | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     needs: | ||||||
|  |       - common | ||||||
|  |     if: github.event_name == 'pull_request' && github.base_ref != 'beta' && github.base_ref != 'release' | ||||||
|  |     steps: | ||||||
|  |       - name: Check out code from GitHub | ||||||
|  |         uses: actions/checkout@v4.2.2 | ||||||
|  |       - name: Restore Python | ||||||
|  |         uses: ./.github/actions/restore-python | ||||||
|  |         with: | ||||||
|  |           python-version: ${{ env.DEFAULT_PYTHON }} | ||||||
|  |           cache-key: ${{ needs.common.outputs.cache-key }} | ||||||
|  |       - uses: pre-commit/action@v3.0.1 | ||||||
|  |         env: | ||||||
|  |           SKIP: pylint,clang-tidy-hash | ||||||
|  |       - uses: pre-commit-ci/lite-action@v1.1.0 | ||||||
|  |         if: always() | ||||||
|  |  | ||||||
|   ci-status: |   ci-status: | ||||||
|     name: CI Status |     name: CI Status | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     needs: |     needs: | ||||||
|       - common |       - common | ||||||
|       - ruff |  | ||||||
|       - ci-custom |       - ci-custom | ||||||
|       - clang-format |  | ||||||
|       - flake8 |  | ||||||
|       - pylint |       - pylint | ||||||
|       - pytest |       - pytest | ||||||
|       - integration-tests |       - integration-tests | ||||||
|       - pyupgrade |  | ||||||
|       - clang-tidy |       - clang-tidy | ||||||
|       - list-components |       - determine-jobs | ||||||
|       - test-build-components |       - test-build-components | ||||||
|       - test-build-components-splitter |       - test-build-components-splitter | ||||||
|       - test-build-components-split |       - test-build-components-split | ||||||
|  |       - pre-commit-ci-lite | ||||||
|     if: always() |     if: always() | ||||||
|     steps: |     steps: | ||||||
|       - name: Success |       - name: Success | ||||||
|   | |||||||
							
								
								
									
										322
									
								
								.github/workflows/codeowner-review-request.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								.github/workflows/codeowner-review-request.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,322 @@ | |||||||
|  | # 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`); | ||||||
|  |  | ||||||
|  |             // Hidden marker to identify bot comments from this workflow | ||||||
|  |             const BOT_COMMENT_MARKER = '<!-- codeowner-review-request-bot -->'; | ||||||
|  |  | ||||||
|  |             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 `${BOT_COMMENT_MARKER}\n👋 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 `${BOT_COMMENT_MARKER}\n👋 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); | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               // Check for previous comments from this workflow to avoid duplicate pings | ||||||
|  |               const { data: comments } = await github.rest.issues.listComments({ | ||||||
|  |                 owner, | ||||||
|  |                 repo, | ||||||
|  |                 issue_number: pr_number | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               const previouslyPingedUsers = new Set(); | ||||||
|  |               const previouslyPingedTeams = new Set(); | ||||||
|  |  | ||||||
|  |               // Look for comments from github-actions bot that contain our bot marker | ||||||
|  |               const workflowComments = comments.filter(comment => | ||||||
|  |                 comment.user.type === 'Bot' && | ||||||
|  |                 comment.user.login === 'github-actions[bot]' && | ||||||
|  |                 comment.body.includes(BOT_COMMENT_MARKER) | ||||||
|  |               ); | ||||||
|  |  | ||||||
|  |               // Extract previously mentioned users and teams from workflow comments | ||||||
|  |               for (const comment of workflowComments) { | ||||||
|  |                 // Match @username patterns (not team mentions) | ||||||
|  |                 const userMentions = comment.body.match(/@([a-zA-Z0-9_.-]+)(?![/])/g) || []; | ||||||
|  |                 userMentions.forEach(mention => { | ||||||
|  |                   const username = mention.slice(1); // remove @ | ||||||
|  |                   previouslyPingedUsers.add(username); | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |                 // Match @org/team patterns | ||||||
|  |                 const teamMentions = comment.body.match(/@[a-zA-Z0-9_.-]+\/([a-zA-Z0-9_.-]+)/g) || []; | ||||||
|  |                 teamMentions.forEach(mention => { | ||||||
|  |                   const teamName = mention.split('/')[1]; | ||||||
|  |                   previouslyPingedTeams.add(teamName); | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               console.log(`Found ${previouslyPingedUsers.size} previously pinged users and ${previouslyPingedTeams.size} previously pinged teams`); | ||||||
|  |  | ||||||
|  |               // Remove users who have already been pinged in previous workflow comments | ||||||
|  |               previouslyPingedUsers.forEach(user => { | ||||||
|  |                 matchedOwners.delete(user); | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               previouslyPingedTeams.forEach(team => { | ||||||
|  |                 matchedTeams.delete(team); | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               // 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, reviewed, or previously pinged)'); | ||||||
|  |                 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'); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Only add a comment if there are new codeowners to mention (not previously pinged) | ||||||
|  |                 if (reviewersList.length > 0 || teamsList.length > 0) { | ||||||
|  |                   const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, true); | ||||||
|  |  | ||||||
|  |                   await github.rest.issues.createComment({ | ||||||
|  |                     owner, | ||||||
|  |                     repo, | ||||||
|  |                     issue_number: pr_number, | ||||||
|  |                     body: commentBody | ||||||
|  |                   }); | ||||||
|  |                   console.log(`Added comment mentioning ${reviewersList.length} users and ${teamsList.length} teams`); | ||||||
|  |                 } else { | ||||||
|  |                   console.log('No new codeowners to mention in comment (all previously pinged)'); | ||||||
|  |                 } | ||||||
|  |               } catch (error) { | ||||||
|  |                 if (error.status === 422) { | ||||||
|  |                   console.log('Some reviewers may already be requested or unavailable:', error.message); | ||||||
|  |  | ||||||
|  |                   // Only try to add a comment if there are new codeowners to mention | ||||||
|  |                   if (reviewersList.length > 0 || teamsList.length > 0) { | ||||||
|  |                     const commentBody = createCommentBody(reviewersList, teamsList, fileMatches.size, false); | ||||||
|  |  | ||||||
|  |                     try { | ||||||
|  |                       await github.rest.issues.createComment({ | ||||||
|  |                         owner, | ||||||
|  |                         repo, | ||||||
|  |                         issue_number: pr_number, | ||||||
|  |                         body: commentBody | ||||||
|  |                       }); | ||||||
|  |                       console.log(`Added fallback comment mentioning ${reviewersList.length} users and ${teamsList.length} teams`); | ||||||
|  |                     } catch (commentError) { | ||||||
|  |                       console.log('Failed to add comment:', commentError.message); | ||||||
|  |                     } | ||||||
|  |                   } else { | ||||||
|  |                     console.log('No new codeowners to mention in fallback comment'); | ||||||
|  |                   } | ||||||
|  |                 } 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
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								.github/workflows/external-component-bot.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | |||||||
|  | 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); | ||||||
|  |             } | ||||||
							
								
								
									
										158
									
								
								.github/workflows/issue-codeowner-notify.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								.github/workflows/issue-codeowner-notify.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,158 @@ | |||||||
|  | # 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}` | ||||||
|  |               ); | ||||||
|  |  | ||||||
|  |               // Check for previous comments from this workflow to avoid duplicate pings | ||||||
|  |               const { data: comments } = await github.rest.issues.listComments({ | ||||||
|  |                 owner, | ||||||
|  |                 repo, | ||||||
|  |                 issue_number: issue_number | ||||||
|  |               }); | ||||||
|  |  | ||||||
|  |               const previouslyPingedUsers = new Set(); | ||||||
|  |               const previouslyPingedTeams = new Set(); | ||||||
|  |  | ||||||
|  |               // Look for comments from github-actions bot that contain codeowner pings for this component | ||||||
|  |               const workflowComments = comments.filter(comment => | ||||||
|  |                 comment.user.type === 'Bot' && | ||||||
|  |                 comment.user.login === 'github-actions[bot]' && | ||||||
|  |                 comment.body.includes(`component: ${componentName}`) && | ||||||
|  |                 comment.body.includes("you've been identified as a codeowner") | ||||||
|  |               ); | ||||||
|  |  | ||||||
|  |               // Extract previously mentioned users and teams from workflow comments | ||||||
|  |               for (const comment of workflowComments) { | ||||||
|  |                 // Match @username patterns (not team mentions) | ||||||
|  |                 const userMentions = comment.body.match(/@([a-zA-Z0-9_.-]+)(?![/])/g) || []; | ||||||
|  |                 userMentions.forEach(mention => { | ||||||
|  |                   previouslyPingedUsers.add(mention); // Keep @ prefix for easy comparison | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |                 // Match @org/team patterns | ||||||
|  |                 const teamMentions = comment.body.match(/@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+/g) || []; | ||||||
|  |                 teamMentions.forEach(mention => { | ||||||
|  |                   previouslyPingedTeams.add(mention); | ||||||
|  |                 }); | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               console.log(`Found ${previouslyPingedUsers.size} previously pinged users and ${previouslyPingedTeams.size} previously pinged teams for component ${componentName}`); | ||||||
|  |  | ||||||
|  |               // Remove previously pinged users and teams | ||||||
|  |               const newUserOwners = filteredUserOwners.filter(mention => !previouslyPingedUsers.has(mention)); | ||||||
|  |               const newTeamOwners = teamOwners.filter(mention => !previouslyPingedTeams.has(mention)); | ||||||
|  |  | ||||||
|  |               const allMentions = [...newUserOwners, ...newTeamOwners]; | ||||||
|  |  | ||||||
|  |               if (allMentions.length === 0) { | ||||||
|  |                 console.log('No new codeowners to notify (all previously pinged or 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 new codeowners: ${mentionString}`); | ||||||
|  |  | ||||||
|  |             } catch (error) { | ||||||
|  |               console.log('Failed to process codeowner notifications:', error.message); | ||||||
|  |               console.error(error); | ||||||
|  |             } | ||||||
							
								
								
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -96,7 +96,7 @@ jobs: | |||||||
|       - name: Set up Python |       - name: Set up Python | ||||||
|         uses: actions/setup-python@v5.6.0 |         uses: actions/setup-python@v5.6.0 | ||||||
|         with: |         with: | ||||||
|           python-version: "3.10" |           python-version: "3.11" | ||||||
|  |  | ||||||
|       - name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v3.11.1 |         uses: docker/setup-buildx-action@v3.11.1 | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								.github/workflows/yaml-lint.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								.github/workflows/yaml-lint.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,25 +0,0 @@ | |||||||
| --- |  | ||||||
| name: YAML lint |  | ||||||
|  |  | ||||||
| on: |  | ||||||
|   push: |  | ||||||
|     branches: [dev, beta, release] |  | ||||||
|     paths: |  | ||||||
|       - "**.yaml" |  | ||||||
|       - "**.yml" |  | ||||||
|   pull_request: |  | ||||||
|     paths: |  | ||||||
|       - "**.yaml" |  | ||||||
|       - "**.yml" |  | ||||||
|  |  | ||||||
| jobs: |  | ||||||
|   yamllint: |  | ||||||
|     name: yamllint |  | ||||||
|     runs-on: ubuntu-latest |  | ||||||
|     steps: |  | ||||||
|       - name: Check out code from GitHub |  | ||||||
|         uses: actions/checkout@v4.2.2 |  | ||||||
|       - name: Run yamllint |  | ||||||
|         uses: frenck/action-yamllint@v1.5.0 |  | ||||||
|         with: |  | ||||||
|           strict: true |  | ||||||
| @@ -1,10 +1,17 @@ | |||||||
| --- | --- | ||||||
| # See https://pre-commit.com for more information | # See https://pre-commit.com for more information | ||||||
| # See https://pre-commit.com/hooks.html for more hooks | # See https://pre-commit.com/hooks.html for more hooks | ||||||
|  |  | ||||||
|  | ci: | ||||||
|  |   autoupdate_commit_msg: 'pre-commit: autoupdate' | ||||||
|  |   autoupdate_schedule: off  # Disabled until ruff versions are synced between deps and pre-commit | ||||||
|  |   # Skip hooks that have issues in pre-commit CI environment | ||||||
|  |   skip: [pylint, clang-tidy-hash] | ||||||
|  |  | ||||||
| repos: | repos: | ||||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit |   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||||
|     # Ruff version. |     # Ruff version. | ||||||
|     rev: v0.12.2 |     rev: v0.12.4 | ||||||
|     hooks: |     hooks: | ||||||
|       # Run the linter. |       # Run the linter. | ||||||
|       - id: ruff |       - id: ruff | ||||||
| @@ -20,22 +27,25 @@ repos: | |||||||
|           - pydocstyle==5.1.1 |           - pydocstyle==5.1.1 | ||||||
|         files: ^(esphome|tests)/.+\.py$ |         files: ^(esphome|tests)/.+\.py$ | ||||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks |   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||||
|     rev: v3.4.0 |     rev: v5.0.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: no-commit-to-branch |       - id: no-commit-to-branch | ||||||
|         args: |         args: | ||||||
|           - --branch=dev |           - --branch=dev | ||||||
|           - --branch=release |           - --branch=release | ||||||
|           - --branch=beta |           - --branch=beta | ||||||
|  |       - id: end-of-file-fixer | ||||||
|  |       - id: trailing-whitespace | ||||||
|   - repo: https://github.com/asottile/pyupgrade |   - repo: https://github.com/asottile/pyupgrade | ||||||
|     rev: v3.20.0 |     rev: v3.20.0 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: pyupgrade |       - id: pyupgrade | ||||||
|         args: [--py310-plus] |         args: [--py311-plus] | ||||||
|   - repo: https://github.com/adrienverge/yamllint.git |   - repo: https://github.com/adrienverge/yamllint.git | ||||||
|     rev: v1.37.1 |     rev: v1.37.1 | ||||||
|     hooks: |     hooks: | ||||||
|       - id: yamllint |       - id: yamllint | ||||||
|  |         exclude: ^(\.clang-format|\.clang-tidy)$ | ||||||
|   - repo: https://github.com/pre-commit/mirrors-clang-format |   - repo: https://github.com/pre-commit/mirrors-clang-format | ||||||
|     rev: v13.0.1 |     rev: v13.0.1 | ||||||
|     hooks: |     hooks: | ||||||
| @@ -48,3 +58,10 @@ repos: | |||||||
|         entry: python3 script/run-in-env.py pylint |         entry: python3 script/run-in-env.py pylint | ||||||
|         language: system |         language: system | ||||||
|         types: [python] |         types: [python] | ||||||
|  |       - id: clang-tidy-hash | ||||||
|  |         name: Update clang-tidy hash | ||||||
|  |         entry: python script/clang_tidy_hash.py --update-if-changed | ||||||
|  |         language: python | ||||||
|  |         files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$ | ||||||
|  |         pass_filenames: false | ||||||
|  |         additional_dependencies: [] | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ | |||||||
| pyproject.toml @esphome/core | pyproject.toml @esphome/core | ||||||
| esphome/*.py @esphome/core | esphome/*.py @esphome/core | ||||||
| esphome/core/* @esphome/core | esphome/core/* @esphome/core | ||||||
|  | .github/** @esphome/core | ||||||
|  |  | ||||||
| # Integrations | # Integrations | ||||||
| esphome/components/a01nyub/* @MrSuicideParrot | esphome/components/a01nyub/* @MrSuicideParrot | ||||||
| @@ -28,7 +29,7 @@ esphome/components/aic3204/* @kbx81 | |||||||
| esphome/components/airthings_ble/* @jeromelaban | esphome/components/airthings_ble/* @jeromelaban | ||||||
| esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau | esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau | ||||||
| esphome/components/airthings_wave_mini/* @ncareau | esphome/components/airthings_wave_mini/* @ncareau | ||||||
| esphome/components/airthings_wave_plus/* @jeromelaban | esphome/components/airthings_wave_plus/* @jeromelaban @precurse | ||||||
| esphome/components/alarm_control_panel/* @grahambrown11 @hwstar | esphome/components/alarm_control_panel/* @grahambrown11 @hwstar | ||||||
| esphome/components/alpha3/* @jan-hofmeier | esphome/components/alpha3/* @jan-hofmeier | ||||||
| esphome/components/am2315c/* @swoboda1337 | esphome/components/am2315c/* @swoboda1337 | ||||||
| @@ -170,6 +171,7 @@ esphome/components/ft5x06/* @clydebarrow | |||||||
| esphome/components/ft63x6/* @gpambrozio | esphome/components/ft63x6/* @gpambrozio | ||||||
| esphome/components/gcja5/* @gcormier | esphome/components/gcja5/* @gcormier | ||||||
| esphome/components/gdk101/* @Szewcson | esphome/components/gdk101/* @Szewcson | ||||||
|  | esphome/components/gl_r01_i2c/* @pkejval | ||||||
| esphome/components/globals/* @esphome/core | esphome/components/globals/* @esphome/core | ||||||
| esphome/components/gp2y1010au0f/* @zry98 | esphome/components/gp2y1010au0f/* @zry98 | ||||||
| esphome/components/gp8403/* @jesserockz | esphome/components/gp8403/* @jesserockz | ||||||
| @@ -254,6 +256,7 @@ esphome/components/ln882x/* @lamauny | |||||||
| esphome/components/lock/* @esphome/core | esphome/components/lock/* @esphome/core | ||||||
| esphome/components/logger/* @esphome/core | esphome/components/logger/* @esphome/core | ||||||
| esphome/components/logger/select/* @clydebarrow | esphome/components/logger/select/* @clydebarrow | ||||||
|  | esphome/components/lps22/* @nagisa | ||||||
| esphome/components/ltr390/* @latonita @sjtrny | esphome/components/ltr390/* @latonita @sjtrny | ||||||
| esphome/components/ltr501/* @latonita | esphome/components/ltr501/* @latonita | ||||||
| esphome/components/ltr_als_ps/* @latonita | esphome/components/ltr_als_ps/* @latonita | ||||||
| @@ -322,6 +325,7 @@ esphome/components/nextion/text_sensor/* @senexcrenshaw | |||||||
| esphome/components/nfc/* @jesserockz @kbx81 | esphome/components/nfc/* @jesserockz @kbx81 | ||||||
| esphome/components/noblex/* @AGalfra | esphome/components/noblex/* @AGalfra | ||||||
| esphome/components/npi19/* @bakerkj | esphome/components/npi19/* @bakerkj | ||||||
|  | esphome/components/nrf52/* @tomaszduda23 | ||||||
| esphome/components/number/* @esphome/core | esphome/components/number/* @esphome/core | ||||||
| esphome/components/one_wire/* @ssieb | esphome/components/one_wire/* @ssieb | ||||||
| esphome/components/online_image/* @clydebarrow @guillempages | esphome/components/online_image/* @clydebarrow @guillempages | ||||||
| @@ -376,6 +380,7 @@ esphome/components/rp2040_pwm/* @jesserockz | |||||||
| esphome/components/rpi_dpi_rgb/* @clydebarrow | esphome/components/rpi_dpi_rgb/* @clydebarrow | ||||||
| esphome/components/rtl87xx/* @kuba2k2 | esphome/components/rtl87xx/* @kuba2k2 | ||||||
| esphome/components/rtttl/* @glmnet | esphome/components/rtttl/* @glmnet | ||||||
|  | esphome/components/runtime_stats/* @bdraco | ||||||
| esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti | esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti | ||||||
| esphome/components/scd4x/* @martgras @sjtrny | esphome/components/scd4x/* @martgras @sjtrny | ||||||
| esphome/components/script/* @esphome/core | esphome/components/script/* @esphome/core | ||||||
| @@ -533,5 +538,6 @@ esphome/components/xiaomi_xmwsdj04mmc/* @medusalix | |||||||
| esphome/components/xl9535/* @mreditor97 | esphome/components/xl9535/* @mreditor97 | ||||||
| esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 | esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 | ||||||
| esphome/components/xxtea/* @clydebarrow | esphome/components/xxtea/* @clydebarrow | ||||||
|  | esphome/components/zephyr/* @tomaszduda23 | ||||||
| esphome/components/zhlt01/* @cfeenstra1024 | esphome/components/zhlt01/* @cfeenstra1024 | ||||||
| esphome/components/zio_ultrasonic/* @kahrendt | esphome/components/zio_ultrasonic/* @kahrendt | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ project and be sure to join us on [Discord](https://discord.gg/KhAMKrd). | |||||||
|  |  | ||||||
| **See also:** | **See also:** | ||||||
|  |  | ||||||
| [Documentation](https://esphome.io) -- [Issues](https://github.com/esphome/issues/issues) -- [Feature requests](https://github.com/esphome/feature-requests/issues) | [Documentation](https://esphome.io) -- [Issues](https://github.com/esphome/esphome/issues) -- [Feature requests](https://github.com/orgs/esphome/discussions) | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								Doxyfile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Doxyfile
									
									
									
									
									
								
							| @@ -48,7 +48,7 @@ PROJECT_NAME           = ESPHome | |||||||
| # could be handy for archiving the generated documentation or if some version | # could be handy for archiving the generated documentation or if some version | ||||||
| # control system is used. | # control system is used. | ||||||
|  |  | ||||||
| PROJECT_NUMBER         = 2025.7.0-dev | PROJECT_NUMBER         = 2025.8.0-dev | ||||||
|  |  | ||||||
| # Using the PROJECT_BRIEF tag one can provide an optional one line description | # Using the PROJECT_BRIEF tag one can provide an optional one line description | ||||||
| # for a project that appears at the top of each page and should give viewer a | # for a project that appears at the top of each page and should give viewer a | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ | |||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
| [Documentation](https://esphome.io) -- [Issues](https://github.com/esphome/issues/issues) -- [Feature requests](https://github.com/esphome/feature-requests/issues) | [Documentation](https://esphome.io) -- [Issues](https://github.com/esphome/esphome/issues) -- [Feature requests](https://github.com/orgs/esphome/discussions) | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -34,6 +34,7 @@ from esphome.const import ( | |||||||
|     CONF_PORT, |     CONF_PORT, | ||||||
|     CONF_SUBSTITUTIONS, |     CONF_SUBSTITUTIONS, | ||||||
|     CONF_TOPIC, |     CONF_TOPIC, | ||||||
|  |     ENV_NOGITIGNORE, | ||||||
|     PLATFORM_ESP32, |     PLATFORM_ESP32, | ||||||
|     PLATFORM_ESP8266, |     PLATFORM_ESP8266, | ||||||
|     PLATFORM_RP2040, |     PLATFORM_RP2040, | ||||||
| @@ -209,6 +210,9 @@ def wrap_to_code(name, comp): | |||||||
|  |  | ||||||
|  |  | ||||||
| def write_cpp(config): | def write_cpp(config): | ||||||
|  |     if not get_bool_env(ENV_NOGITIGNORE): | ||||||
|  |         writer.write_gitignore() | ||||||
|  |  | ||||||
|     generate_cpp_contents(config) |     generate_cpp_contents(config) | ||||||
|     return write_cpp_file() |     return write_cpp_file() | ||||||
|  |  | ||||||
| @@ -225,10 +229,13 @@ def generate_cpp_contents(config): | |||||||
|  |  | ||||||
|  |  | ||||||
| def write_cpp_file(): | def write_cpp_file(): | ||||||
|     writer.write_platformio_project() |  | ||||||
|  |  | ||||||
|     code_s = indent(CORE.cpp_main_section) |     code_s = indent(CORE.cpp_main_section) | ||||||
|     writer.write_cpp(code_s) |     writer.write_cpp(code_s) | ||||||
|  |  | ||||||
|  |     from esphome.build_gen import platformio | ||||||
|  |  | ||||||
|  |     platformio.write_project() | ||||||
|  |  | ||||||
|     return 0 |     return 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								esphome/build_gen/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								esphome/build_gen/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										102
									
								
								esphome/build_gen/platformio.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								esphome/build_gen/platformio.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,102 @@ | |||||||
|  | import os | ||||||
|  |  | ||||||
|  | from esphome.const import __version__ | ||||||
|  | from esphome.core import CORE | ||||||
|  | from esphome.helpers import mkdir_p, read_file, write_file_if_changed | ||||||
|  | from esphome.writer import find_begin_end, update_storage_json | ||||||
|  |  | ||||||
|  | INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" | ||||||
|  | INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============" | ||||||
|  |  | ||||||
|  | INI_BASE_FORMAT = ( | ||||||
|  |     """; Auto generated code by esphome | ||||||
|  |  | ||||||
|  | [common] | ||||||
|  | lib_deps = | ||||||
|  | build_flags = | ||||||
|  | upload_flags = | ||||||
|  |  | ||||||
|  | """, | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  | """, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_ini(data: dict[str, str | list[str]]) -> str: | ||||||
|  |     content = "" | ||||||
|  |     for key, value in sorted(data.items()): | ||||||
|  |         if isinstance(value, list): | ||||||
|  |             content += f"{key} =\n" | ||||||
|  |             for x in value: | ||||||
|  |                 content += f"    {x}\n" | ||||||
|  |         else: | ||||||
|  |             content += f"{key} = {value}\n" | ||||||
|  |     return content | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_ini_content(): | ||||||
|  |     CORE.add_platformio_option( | ||||||
|  |         "lib_deps", | ||||||
|  |         [x.as_lib_dep for x in CORE.platformio_libraries.values()] | ||||||
|  |         + ["${common.lib_deps}"], | ||||||
|  |     ) | ||||||
|  |     # Sort to avoid changing build flags order | ||||||
|  |     CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) | ||||||
|  |  | ||||||
|  |     # Sort to avoid changing build unflags order | ||||||
|  |     CORE.add_platformio_option("build_unflags", sorted(CORE.build_unflags)) | ||||||
|  |  | ||||||
|  |     # Add extra script for C++ flags | ||||||
|  |     CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) | ||||||
|  |  | ||||||
|  |     content = "[platformio]\n" | ||||||
|  |     content += f"description = ESPHome {__version__}\n" | ||||||
|  |  | ||||||
|  |     content += f"[env:{CORE.name}]\n" | ||||||
|  |     content += format_ini(CORE.platformio_options) | ||||||
|  |  | ||||||
|  |     return content | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def write_ini(content): | ||||||
|  |     update_storage_json() | ||||||
|  |     path = CORE.relative_build_path("platformio.ini") | ||||||
|  |  | ||||||
|  |     if os.path.isfile(path): | ||||||
|  |         text = read_file(path) | ||||||
|  |         content_format = find_begin_end( | ||||||
|  |             text, INI_AUTO_GENERATE_BEGIN, INI_AUTO_GENERATE_END | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         content_format = INI_BASE_FORMAT | ||||||
|  |     full_file = f"{content_format[0] + INI_AUTO_GENERATE_BEGIN}\n{content}" | ||||||
|  |     full_file += INI_AUTO_GENERATE_END + content_format[1] | ||||||
|  |     write_file_if_changed(path, full_file) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def write_project(): | ||||||
|  |     mkdir_p(CORE.build_path) | ||||||
|  |  | ||||||
|  |     content = get_ini_content() | ||||||
|  |     write_ini(content) | ||||||
|  |  | ||||||
|  |     # Write extra script for C++ specific flags | ||||||
|  |     write_cxx_flags_script() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | CXX_FLAGS_FILE_NAME = "cxx_flags.py" | ||||||
|  | CXX_FLAGS_FILE_CONTENTS = """# Auto-generated ESPHome script for C++ specific compiler flags | ||||||
|  | Import("env") | ||||||
|  |  | ||||||
|  | # Add C++ specific flags | ||||||
|  | """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def write_cxx_flags_script() -> None: | ||||||
|  |     path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME) | ||||||
|  |     contents = CXX_FLAGS_FILE_CONTENTS | ||||||
|  |     if not CORE.is_host: | ||||||
|  |         contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])' | ||||||
|  |         contents += "\n" | ||||||
|  |     write_file_if_changed(path, contents) | ||||||
| @@ -5,6 +5,7 @@ from esphome.components.esp32.const import ( | |||||||
|     VARIANT_ESP32, |     VARIANT_ESP32, | ||||||
|     VARIANT_ESP32C2, |     VARIANT_ESP32C2, | ||||||
|     VARIANT_ESP32C3, |     VARIANT_ESP32C3, | ||||||
|  |     VARIANT_ESP32C5, | ||||||
|     VARIANT_ESP32C6, |     VARIANT_ESP32C6, | ||||||
|     VARIANT_ESP32H2, |     VARIANT_ESP32H2, | ||||||
|     VARIANT_ESP32S2, |     VARIANT_ESP32S2, | ||||||
| @@ -51,82 +52,93 @@ SAMPLING_MODES = { | |||||||
|     "max": sampling_mode.MAX, |     "max": sampling_mode.MAX, | ||||||
| } | } | ||||||
|  |  | ||||||
| adc1_channel_t = cg.global_ns.enum("adc1_channel_t") | adc_unit_t = cg.global_ns.enum("adc_unit_t", is_class=True) | ||||||
| adc2_channel_t = cg.global_ns.enum("adc2_channel_t") |  | ||||||
|  | adc_channel_t = cg.global_ns.enum("adc_channel_t", is_class=True) | ||||||
|  |  | ||||||
| # pin to adc1 channel mapping | # pin to adc1 channel mapping | ||||||
| # https://github.com/espressif/esp-idf/blob/v4.4.8/components/driver/include/driver/adc.h | # https://github.com/espressif/esp-idf/blob/v4.4.8/components/driver/include/driver/adc.h | ||||||
| ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { | ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32: { |     VARIANT_ESP32: { | ||||||
|         36: adc1_channel_t.ADC1_CHANNEL_0, |         36: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         37: adc1_channel_t.ADC1_CHANNEL_1, |         37: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         38: adc1_channel_t.ADC1_CHANNEL_2, |         38: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         39: adc1_channel_t.ADC1_CHANNEL_3, |         39: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         32: adc1_channel_t.ADC1_CHANNEL_4, |         32: adc_channel_t.ADC_CHANNEL_4, | ||||||
|         33: adc1_channel_t.ADC1_CHANNEL_5, |         33: adc_channel_t.ADC_CHANNEL_5, | ||||||
|         34: adc1_channel_t.ADC1_CHANNEL_6, |         34: adc_channel_t.ADC_CHANNEL_6, | ||||||
|         35: adc1_channel_t.ADC1_CHANNEL_7, |         35: adc_channel_t.ADC_CHANNEL_7, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c2/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c2/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32C2: { |     VARIANT_ESP32C2: { | ||||||
|         0: adc1_channel_t.ADC1_CHANNEL_0, |         0: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         1: adc1_channel_t.ADC1_CHANNEL_1, |         1: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         2: adc1_channel_t.ADC1_CHANNEL_2, |         2: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         3: adc1_channel_t.ADC1_CHANNEL_3, |         3: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         4: adc1_channel_t.ADC1_CHANNEL_4, |         4: adc_channel_t.ADC_CHANNEL_4, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c3/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c3/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32C3: { |     VARIANT_ESP32C3: { | ||||||
|         0: adc1_channel_t.ADC1_CHANNEL_0, |         0: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         1: adc1_channel_t.ADC1_CHANNEL_1, |         1: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         2: adc1_channel_t.ADC1_CHANNEL_2, |         2: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         3: adc1_channel_t.ADC1_CHANNEL_3, |         3: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         4: adc1_channel_t.ADC1_CHANNEL_4, |         4: adc_channel_t.ADC_CHANNEL_4, | ||||||
|  |     }, | ||||||
|  |     # ESP32-C5 ADC1 pin mapping - based on official ESP-IDF documentation | ||||||
|  |     # https://docs.espressif.com/projects/esp-idf/en/latest/esp32c5/api-reference/peripherals/gpio.html | ||||||
|  |     VARIANT_ESP32C5: { | ||||||
|  |         1: adc_channel_t.ADC_CHANNEL_0, | ||||||
|  |         2: adc_channel_t.ADC_CHANNEL_1, | ||||||
|  |         3: adc_channel_t.ADC_CHANNEL_2, | ||||||
|  |         4: adc_channel_t.ADC_CHANNEL_3, | ||||||
|  |         5: adc_channel_t.ADC_CHANNEL_4, | ||||||
|  |         6: adc_channel_t.ADC_CHANNEL_5, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32C6: { |     VARIANT_ESP32C6: { | ||||||
|         0: adc1_channel_t.ADC1_CHANNEL_0, |         0: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         1: adc1_channel_t.ADC1_CHANNEL_1, |         1: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         2: adc1_channel_t.ADC1_CHANNEL_2, |         2: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         3: adc1_channel_t.ADC1_CHANNEL_3, |         3: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         4: adc1_channel_t.ADC1_CHANNEL_4, |         4: adc_channel_t.ADC_CHANNEL_4, | ||||||
|         5: adc1_channel_t.ADC1_CHANNEL_5, |         5: adc_channel_t.ADC_CHANNEL_5, | ||||||
|         6: adc1_channel_t.ADC1_CHANNEL_6, |         6: adc_channel_t.ADC_CHANNEL_6, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32H2: { |     VARIANT_ESP32H2: { | ||||||
|         1: adc1_channel_t.ADC1_CHANNEL_0, |         1: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         2: adc1_channel_t.ADC1_CHANNEL_1, |         2: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         3: adc1_channel_t.ADC1_CHANNEL_2, |         3: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         4: adc1_channel_t.ADC1_CHANNEL_3, |         4: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         5: adc1_channel_t.ADC1_CHANNEL_4, |         5: adc_channel_t.ADC_CHANNEL_4, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32S2: { |     VARIANT_ESP32S2: { | ||||||
|         1: adc1_channel_t.ADC1_CHANNEL_0, |         1: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         2: adc1_channel_t.ADC1_CHANNEL_1, |         2: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         3: adc1_channel_t.ADC1_CHANNEL_2, |         3: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         4: adc1_channel_t.ADC1_CHANNEL_3, |         4: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         5: adc1_channel_t.ADC1_CHANNEL_4, |         5: adc_channel_t.ADC_CHANNEL_4, | ||||||
|         6: adc1_channel_t.ADC1_CHANNEL_5, |         6: adc_channel_t.ADC_CHANNEL_5, | ||||||
|         7: adc1_channel_t.ADC1_CHANNEL_6, |         7: adc_channel_t.ADC_CHANNEL_6, | ||||||
|         8: adc1_channel_t.ADC1_CHANNEL_7, |         8: adc_channel_t.ADC_CHANNEL_7, | ||||||
|         9: adc1_channel_t.ADC1_CHANNEL_8, |         9: adc_channel_t.ADC_CHANNEL_8, | ||||||
|         10: adc1_channel_t.ADC1_CHANNEL_9, |         10: adc_channel_t.ADC_CHANNEL_9, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s3/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s3/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32S3: { |     VARIANT_ESP32S3: { | ||||||
|         1: adc1_channel_t.ADC1_CHANNEL_0, |         1: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         2: adc1_channel_t.ADC1_CHANNEL_1, |         2: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         3: adc1_channel_t.ADC1_CHANNEL_2, |         3: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         4: adc1_channel_t.ADC1_CHANNEL_3, |         4: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         5: adc1_channel_t.ADC1_CHANNEL_4, |         5: adc_channel_t.ADC_CHANNEL_4, | ||||||
|         6: adc1_channel_t.ADC1_CHANNEL_5, |         6: adc_channel_t.ADC_CHANNEL_5, | ||||||
|         7: adc1_channel_t.ADC1_CHANNEL_6, |         7: adc_channel_t.ADC_CHANNEL_6, | ||||||
|         8: adc1_channel_t.ADC1_CHANNEL_7, |         8: adc_channel_t.ADC_CHANNEL_7, | ||||||
|         9: adc1_channel_t.ADC1_CHANNEL_8, |         9: adc_channel_t.ADC_CHANNEL_8, | ||||||
|         10: adc1_channel_t.ADC1_CHANNEL_9, |         10: adc_channel_t.ADC_CHANNEL_9, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -135,54 +147,56 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { | |||||||
| ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { | ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32: { |     VARIANT_ESP32: { | ||||||
|         4: adc2_channel_t.ADC2_CHANNEL_0, |         4: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         0: adc2_channel_t.ADC2_CHANNEL_1, |         0: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         2: adc2_channel_t.ADC2_CHANNEL_2, |         2: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         15: adc2_channel_t.ADC2_CHANNEL_3, |         15: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         13: adc2_channel_t.ADC2_CHANNEL_4, |         13: adc_channel_t.ADC_CHANNEL_4, | ||||||
|         12: adc2_channel_t.ADC2_CHANNEL_5, |         12: adc_channel_t.ADC_CHANNEL_5, | ||||||
|         14: adc2_channel_t.ADC2_CHANNEL_6, |         14: adc_channel_t.ADC_CHANNEL_6, | ||||||
|         27: adc2_channel_t.ADC2_CHANNEL_7, |         27: adc_channel_t.ADC_CHANNEL_7, | ||||||
|         25: adc2_channel_t.ADC2_CHANNEL_8, |         25: adc_channel_t.ADC_CHANNEL_8, | ||||||
|         26: adc2_channel_t.ADC2_CHANNEL_9, |         26: adc_channel_t.ADC_CHANNEL_9, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c2/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c2/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32C2: { |     VARIANT_ESP32C2: { | ||||||
|         5: adc2_channel_t.ADC2_CHANNEL_0, |         5: adc_channel_t.ADC_CHANNEL_0, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c3/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c3/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32C3: { |     VARIANT_ESP32C3: { | ||||||
|         5: adc2_channel_t.ADC2_CHANNEL_0, |         5: adc_channel_t.ADC_CHANNEL_0, | ||||||
|     }, |     }, | ||||||
|  |     # ESP32-C5 has no ADC2 channels | ||||||
|  |     VARIANT_ESP32C5: {},  # no ADC2 | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32C6: {},  # no ADC2 |     VARIANT_ESP32C6: {},  # no ADC2 | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32H2: {},  # no ADC2 |     VARIANT_ESP32H2: {},  # no ADC2 | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32S2: { |     VARIANT_ESP32S2: { | ||||||
|         11: adc2_channel_t.ADC2_CHANNEL_0, |         11: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         12: adc2_channel_t.ADC2_CHANNEL_1, |         12: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         13: adc2_channel_t.ADC2_CHANNEL_2, |         13: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         14: adc2_channel_t.ADC2_CHANNEL_3, |         14: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         15: adc2_channel_t.ADC2_CHANNEL_4, |         15: adc_channel_t.ADC_CHANNEL_4, | ||||||
|         16: adc2_channel_t.ADC2_CHANNEL_5, |         16: adc_channel_t.ADC_CHANNEL_5, | ||||||
|         17: adc2_channel_t.ADC2_CHANNEL_6, |         17: adc_channel_t.ADC_CHANNEL_6, | ||||||
|         18: adc2_channel_t.ADC2_CHANNEL_7, |         18: adc_channel_t.ADC_CHANNEL_7, | ||||||
|         19: adc2_channel_t.ADC2_CHANNEL_8, |         19: adc_channel_t.ADC_CHANNEL_8, | ||||||
|         20: adc2_channel_t.ADC2_CHANNEL_9, |         20: adc_channel_t.ADC_CHANNEL_9, | ||||||
|     }, |     }, | ||||||
|     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s3/include/soc/adc_channel.h |     # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s3/include/soc/adc_channel.h | ||||||
|     VARIANT_ESP32S3: { |     VARIANT_ESP32S3: { | ||||||
|         11: adc2_channel_t.ADC2_CHANNEL_0, |         11: adc_channel_t.ADC_CHANNEL_0, | ||||||
|         12: adc2_channel_t.ADC2_CHANNEL_1, |         12: adc_channel_t.ADC_CHANNEL_1, | ||||||
|         13: adc2_channel_t.ADC2_CHANNEL_2, |         13: adc_channel_t.ADC_CHANNEL_2, | ||||||
|         14: adc2_channel_t.ADC2_CHANNEL_3, |         14: adc_channel_t.ADC_CHANNEL_3, | ||||||
|         15: adc2_channel_t.ADC2_CHANNEL_4, |         15: adc_channel_t.ADC_CHANNEL_4, | ||||||
|         16: adc2_channel_t.ADC2_CHANNEL_5, |         16: adc_channel_t.ADC_CHANNEL_5, | ||||||
|         17: adc2_channel_t.ADC2_CHANNEL_6, |         17: adc_channel_t.ADC_CHANNEL_6, | ||||||
|         18: adc2_channel_t.ADC2_CHANNEL_7, |         18: adc_channel_t.ADC_CHANNEL_7, | ||||||
|         19: adc2_channel_t.ADC2_CHANNEL_8, |         19: adc_channel_t.ADC_CHANNEL_8, | ||||||
|         20: adc2_channel_t.ADC2_CHANNEL_9, |         20: adc_channel_t.ADC_CHANNEL_9, | ||||||
|     }, |     }, | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,12 +3,15 @@ | |||||||
| #include "esphome/components/sensor/sensor.h" | #include "esphome/components/sensor/sensor.h" | ||||||
| #include "esphome/components/voltage_sampler/voltage_sampler.h" | #include "esphome/components/voltage_sampler/voltage_sampler.h" | ||||||
| #include "esphome/core/component.h" | #include "esphome/core/component.h" | ||||||
|  | #include "esphome/core/defines.h" | ||||||
| #include "esphome/core/hal.h" | #include "esphome/core/hal.h" | ||||||
|  |  | ||||||
| #ifdef USE_ESP32 | #ifdef USE_ESP32 | ||||||
| #include <esp_adc_cal.h> | #include "esp_adc/adc_cali.h" | ||||||
| #include "driver/adc.h" | #include "esp_adc/adc_cali_scheme.h" | ||||||
| #endif  // USE_ESP32 | #include "esp_adc/adc_oneshot.h" | ||||||
|  | #include "hal/adc_types.h"  // This defines ADC_CHANNEL_MAX | ||||||
|  | #endif                      // USE_ESP32 | ||||||
|  |  | ||||||
| namespace esphome { | namespace esphome { | ||||||
| namespace adc { | namespace adc { | ||||||
| @@ -49,36 +52,72 @@ class Aggregator { | |||||||
|  |  | ||||||
| class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { | class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { | ||||||
|  public: |  public: | ||||||
| #ifdef USE_ESP32 |   /// Update the sensor's state by reading the current ADC value. | ||||||
|   /// Set the attenuation for this pin. Only available on the ESP32. |   /// This method is called periodically based on the update interval. | ||||||
|   void set_attenuation(adc_atten_t attenuation) { this->attenuation_ = attenuation; } |  | ||||||
|   void set_channel1(adc1_channel_t channel) { |  | ||||||
|     this->channel1_ = channel; |  | ||||||
|     this->channel2_ = ADC2_CHANNEL_MAX; |  | ||||||
|   } |  | ||||||
|   void set_channel2(adc2_channel_t channel) { |  | ||||||
|     this->channel2_ = channel; |  | ||||||
|     this->channel1_ = ADC1_CHANNEL_MAX; |  | ||||||
|   } |  | ||||||
|   void set_autorange(bool autorange) { this->autorange_ = autorange; } |  | ||||||
| #endif  // USE_ESP32 |  | ||||||
|  |  | ||||||
|   /// Update ADC values |  | ||||||
|   void update() override; |   void update() override; | ||||||
|   /// Setup ADC |  | ||||||
|  |   /// Set up the ADC sensor by initializing hardware and calibration parameters. | ||||||
|  |   /// This method is called once during device initialization. | ||||||
|   void setup() override; |   void setup() override; | ||||||
|  |  | ||||||
|  |   /// Output the configuration details of the ADC sensor for debugging purposes. | ||||||
|  |   /// This method is called during the ESPHome setup process to log the configuration. | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|   /// `HARDWARE_LATE` setup priority |  | ||||||
|  |   /// Return the setup priority for this component. | ||||||
|  |   /// Components with higher priority are initialized earlier during setup. | ||||||
|  |   /// @return A float representing the setup priority. | ||||||
|   float get_setup_priority() const override; |   float get_setup_priority() const override; | ||||||
|  |  | ||||||
|  |   /// Set the GPIO pin to be used by the ADC sensor. | ||||||
|  |   /// @param pin Pointer to an InternalGPIOPin representing the ADC input pin. | ||||||
|   void set_pin(InternalGPIOPin *pin) { this->pin_ = pin; } |   void set_pin(InternalGPIOPin *pin) { this->pin_ = pin; } | ||||||
|  |  | ||||||
|  |   /// Enable or disable the output of raw ADC values (unprocessed data). | ||||||
|  |   /// @param output_raw Boolean indicating whether to output raw ADC values (true) or processed values (false). | ||||||
|   void set_output_raw(bool output_raw) { this->output_raw_ = output_raw; } |   void set_output_raw(bool output_raw) { this->output_raw_ = output_raw; } | ||||||
|  |  | ||||||
|  |   /// Set the number of samples to be taken for ADC readings to improve accuracy. | ||||||
|  |   /// A higher sample count reduces noise but increases the reading time. | ||||||
|  |   /// @param sample_count The number of samples (e.g., 1, 4, 8). | ||||||
|   void set_sample_count(uint8_t sample_count); |   void set_sample_count(uint8_t sample_count); | ||||||
|  |  | ||||||
|  |   /// Set the sampling mode for how multiple ADC samples are combined into a single measurement. | ||||||
|  |   /// | ||||||
|  |   /// When multiple samples are taken (controlled by set_sample_count), they can be combined | ||||||
|  |   /// in one of three ways: | ||||||
|  |   ///   - SamplingMode::AVG: Compute the average (default) | ||||||
|  |   ///   - SamplingMode::MIN: Use the lowest sample value | ||||||
|  |   ///   - SamplingMode::MAX: Use the highest sample value | ||||||
|  |   /// @param sampling_mode The desired sampling mode to use for aggregating ADC samples. | ||||||
|   void set_sampling_mode(SamplingMode sampling_mode); |   void set_sampling_mode(SamplingMode sampling_mode); | ||||||
|  |  | ||||||
|  |   /// Perform a single ADC sampling operation and return the measured value. | ||||||
|  |   /// This function handles raw readings, calibration, and averaging as needed. | ||||||
|  |   /// @return The sampled value as a float. | ||||||
|   float sample() override; |   float sample() override; | ||||||
|  |  | ||||||
| #ifdef USE_ESP8266 | #ifdef USE_ESP32 | ||||||
|   std::string unique_id() override; |   /// Set the ADC attenuation level to adjust the input voltage range. | ||||||
| #endif  // USE_ESP8266 |   /// This determines how the ADC interprets input voltages, allowing for greater precision | ||||||
|  |   /// or the ability to measure higher voltages depending on the chosen attenuation level. | ||||||
|  |   /// @param attenuation The desired ADC attenuation level (e.g., ADC_ATTEN_DB_0, ADC_ATTEN_DB_11). | ||||||
|  |   void set_attenuation(adc_atten_t attenuation) { this->attenuation_ = attenuation; } | ||||||
|  |  | ||||||
|  |   /// Configure the ADC to use a specific channel on a specific ADC unit. | ||||||
|  |   /// This sets the channel for single-shot or continuous ADC measurements. | ||||||
|  |   /// @param unit The ADC unit to use (ADC_UNIT_1 or ADC_UNIT_2). | ||||||
|  |   /// @param channel The ADC channel to configure, such as ADC_CHANNEL_0, ADC_CHANNEL_3, etc. | ||||||
|  |   void set_channel(adc_unit_t unit, adc_channel_t channel) { | ||||||
|  |     this->adc_unit_ = unit; | ||||||
|  |     this->channel_ = channel; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /// Set whether autoranging should be enabled for the ADC. | ||||||
|  |   /// Autoranging automatically adjusts the attenuation level to handle a wide range of input voltages. | ||||||
|  |   /// @param autorange Boolean indicating whether to enable autoranging. | ||||||
|  |   void set_autorange(bool autorange) { this->autorange_ = autorange; } | ||||||
|  | #endif  // USE_ESP32 | ||||||
|  |  | ||||||
| #ifdef USE_RP2040 | #ifdef USE_RP2040 | ||||||
|   void set_is_temperature() { this->is_temperature_ = true; } |   void set_is_temperature() { this->is_temperature_ = true; } | ||||||
| @@ -90,17 +129,28 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage | |||||||
|   InternalGPIOPin *pin_; |   InternalGPIOPin *pin_; | ||||||
|   SamplingMode sampling_mode_{SamplingMode::AVG}; |   SamplingMode sampling_mode_{SamplingMode::AVG}; | ||||||
|  |  | ||||||
|  | #ifdef USE_ESP32 | ||||||
|  |   float sample_autorange_(); | ||||||
|  |   float sample_fixed_attenuation_(); | ||||||
|  |   bool autorange_{false}; | ||||||
|  |   adc_oneshot_unit_handle_t adc_handle_{nullptr}; | ||||||
|  |   adc_cali_handle_t calibration_handle_{nullptr}; | ||||||
|  |   adc_atten_t attenuation_{ADC_ATTEN_DB_0}; | ||||||
|  |   adc_channel_t channel_; | ||||||
|  |   adc_unit_t adc_unit_; | ||||||
|  |   struct SetupFlags { | ||||||
|  |     uint8_t init_complete : 1; | ||||||
|  |     uint8_t config_complete : 1; | ||||||
|  |     uint8_t handle_init_complete : 1; | ||||||
|  |     uint8_t calibration_complete : 1; | ||||||
|  |     uint8_t reserved : 4; | ||||||
|  |   } setup_flags_{}; | ||||||
|  |   static adc_oneshot_unit_handle_t shared_adc_handles[2]; | ||||||
|  | #endif  // USE_ESP32 | ||||||
|  |  | ||||||
| #ifdef USE_RP2040 | #ifdef USE_RP2040 | ||||||
|   bool is_temperature_{false}; |   bool is_temperature_{false}; | ||||||
| #endif  // USE_RP2040 | #endif  // USE_RP2040 | ||||||
|  |  | ||||||
| #ifdef USE_ESP32 |  | ||||||
|   adc_atten_t attenuation_{ADC_ATTEN_DB_0}; |  | ||||||
|   adc1_channel_t channel1_{ADC1_CHANNEL_MAX}; |  | ||||||
|   adc2_channel_t channel2_{ADC2_CHANNEL_MAX}; |  | ||||||
|   bool autorange_{false}; |  | ||||||
|   esp_adc_cal_characteristics_t cal_characteristics_[SOC_ADC_ATTEN_NUM] = {}; |  | ||||||
| #endif  // USE_ESP32 |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| }  // namespace adc | }  // namespace adc | ||||||
|   | |||||||
| @@ -8,145 +8,315 @@ namespace adc { | |||||||
|  |  | ||||||
| static const char *const TAG = "adc.esp32"; | static const char *const TAG = "adc.esp32"; | ||||||
|  |  | ||||||
| static const adc_bits_width_t ADC_WIDTH_MAX_SOC_BITS = static_cast<adc_bits_width_t>(ADC_WIDTH_MAX - 1); | adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; | ||||||
|  |  | ||||||
| #ifndef SOC_ADC_RTC_MAX_BITWIDTH | const LogString *attenuation_to_str(adc_atten_t attenuation) { | ||||||
| #if USE_ESP32_VARIANT_ESP32S2 |   switch (attenuation) { | ||||||
| static const int32_t SOC_ADC_RTC_MAX_BITWIDTH = 13; |     case ADC_ATTEN_DB_0: | ||||||
| #else |       return LOG_STR("0 dB"); | ||||||
| static const int32_t SOC_ADC_RTC_MAX_BITWIDTH = 12; |     case ADC_ATTEN_DB_2_5: | ||||||
| #endif  // USE_ESP32_VARIANT_ESP32S2 |       return LOG_STR("2.5 dB"); | ||||||
| #endif  // SOC_ADC_RTC_MAX_BITWIDTH |     case ADC_ATTEN_DB_6: | ||||||
|  |       return LOG_STR("6 dB"); | ||||||
| static const int ADC_MAX = (1 << SOC_ADC_RTC_MAX_BITWIDTH) - 1; |     case ADC_ATTEN_DB_12_COMPAT: | ||||||
| static const int ADC_HALF = (1 << SOC_ADC_RTC_MAX_BITWIDTH) >> 1; |       return LOG_STR("12 dB"); | ||||||
|  |     default: | ||||||
| void ADCSensor::setup() { |       return LOG_STR("Unknown Attenuation"); | ||||||
|   ESP_LOGCONFIG(TAG, "Running setup for '%s'", this->get_name().c_str()); |  | ||||||
|  |  | ||||||
|   if (this->channel1_ != ADC1_CHANNEL_MAX) { |  | ||||||
|     adc1_config_width(ADC_WIDTH_MAX_SOC_BITS); |  | ||||||
|     if (!this->autorange_) { |  | ||||||
|       adc1_config_channel_atten(this->channel1_, this->attenuation_); |  | ||||||
|     } |  | ||||||
|   } else if (this->channel2_ != ADC2_CHANNEL_MAX) { |  | ||||||
|     if (!this->autorange_) { |  | ||||||
|       adc2_config_channel_atten(this->channel2_, this->attenuation_); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   for (int32_t i = 0; i <= ADC_ATTEN_DB_12_COMPAT; i++) { |  | ||||||
|     auto adc_unit = this->channel1_ != ADC1_CHANNEL_MAX ? ADC_UNIT_1 : ADC_UNIT_2; |  | ||||||
|     auto cal_value = esp_adc_cal_characterize(adc_unit, (adc_atten_t) i, ADC_WIDTH_MAX_SOC_BITS, |  | ||||||
|                                               1100,  // default vref |  | ||||||
|                                               &this->cal_characteristics_[i]); |  | ||||||
|     switch (cal_value) { |  | ||||||
|       case ESP_ADC_CAL_VAL_EFUSE_VREF: |  | ||||||
|         ESP_LOGV(TAG, "Using eFuse Vref for calibration"); |  | ||||||
|         break; |  | ||||||
|       case ESP_ADC_CAL_VAL_EFUSE_TP: |  | ||||||
|         ESP_LOGV(TAG, "Using two-point eFuse Vref for calibration"); |  | ||||||
|         break; |  | ||||||
|       case ESP_ADC_CAL_VAL_DEFAULT_VREF: |  | ||||||
|       default: |  | ||||||
|         break; |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| void ADCSensor::dump_config() { | const LogString *adc_unit_to_str(adc_unit_t unit) { | ||||||
|   static const char *const ATTEN_AUTO_STR = "auto"; |   switch (unit) { | ||||||
|   static const char *const ATTEN_0DB_STR = "0 db"; |     case ADC_UNIT_1: | ||||||
|   static const char *const ATTEN_2_5DB_STR = "2.5 db"; |       return LOG_STR("ADC1"); | ||||||
|   static const char *const ATTEN_6DB_STR = "6 db"; |     case ADC_UNIT_2: | ||||||
|   static const char *const ATTEN_12DB_STR = "12 db"; |       return LOG_STR("ADC2"); | ||||||
|   const char *atten_str = ATTEN_AUTO_STR; |     default: | ||||||
|  |       return LOG_STR("Unknown ADC Unit"); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|   LOG_SENSOR("", "ADC Sensor", this); | void ADCSensor::setup() { | ||||||
|   LOG_PIN("  Pin: ", this->pin_); |   ESP_LOGCONFIG(TAG, "Running setup for '%s'", this->get_name().c_str()); | ||||||
|  |   // Check if another sensor already initialized this ADC unit | ||||||
|   if (!this->autorange_) { |   if (ADCSensor::shared_adc_handles[this->adc_unit_] == nullptr) { | ||||||
|     switch (this->attenuation_) { |     adc_oneshot_unit_init_cfg_t init_config = {};  // Zero initialize | ||||||
|       case ADC_ATTEN_DB_0: |     init_config.unit_id = this->adc_unit_; | ||||||
|         atten_str = ATTEN_0DB_STR; |     init_config.ulp_mode = ADC_ULP_MODE_DISABLE; | ||||||
|         break; | #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32H2 | ||||||
|       case ADC_ATTEN_DB_2_5: |     init_config.clk_src = ADC_DIGI_CLK_SRC_DEFAULT; | ||||||
|         atten_str = ATTEN_2_5DB_STR; | #endif  // USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || | ||||||
|         break; |         // USE_ESP32_VARIANT_ESP32H2 | ||||||
|       case ADC_ATTEN_DB_6: |     esp_err_t err = adc_oneshot_new_unit(&init_config, &ADCSensor::shared_adc_handles[this->adc_unit_]); | ||||||
|         atten_str = ATTEN_6DB_STR; |     if (err != ESP_OK) { | ||||||
|         break; |       ESP_LOGE(TAG, "Error initializing %s: %d", LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)), err); | ||||||
|       case ADC_ATTEN_DB_12_COMPAT: |       this->mark_failed(); | ||||||
|         atten_str = ATTEN_12DB_STR; |       return; | ||||||
|         break; |  | ||||||
|       default:  // This is to satisfy the unused ADC_ATTEN_MAX |  | ||||||
|         break; |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |   this->adc_handle_ = ADCSensor::shared_adc_handles[this->adc_unit_]; | ||||||
|  |  | ||||||
|  |   this->setup_flags_.handle_init_complete = true; | ||||||
|  |  | ||||||
|  |   adc_oneshot_chan_cfg_t config = { | ||||||
|  |       .atten = this->attenuation_, | ||||||
|  |       .bitwidth = ADC_BITWIDTH_DEFAULT, | ||||||
|  |   }; | ||||||
|  |   esp_err_t err = adc_oneshot_config_channel(this->adc_handle_, this->channel_, &config); | ||||||
|  |   if (err != ESP_OK) { | ||||||
|  |     ESP_LOGE(TAG, "Error configuring channel: %d", err); | ||||||
|  |     this->mark_failed(); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   this->setup_flags_.config_complete = true; | ||||||
|  |  | ||||||
|  |   // Initialize ADC calibration | ||||||
|  |   if (this->calibration_handle_ == nullptr) { | ||||||
|  |     adc_cali_handle_t handle = nullptr; | ||||||
|  |     esp_err_t err; | ||||||
|  |  | ||||||
|  | #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ | ||||||
|  |     USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 | ||||||
|  |     // RISC-V variants and S3 use curve fitting calibration | ||||||
|  |     adc_cali_curve_fitting_config_t cali_config = {};  // Zero initialize first | ||||||
|  | #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) | ||||||
|  |     cali_config.chan = this->channel_; | ||||||
|  | #endif  // ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) | ||||||
|  |     cali_config.unit_id = this->adc_unit_; | ||||||
|  |     cali_config.atten = this->attenuation_; | ||||||
|  |     cali_config.bitwidth = ADC_BITWIDTH_DEFAULT; | ||||||
|  |  | ||||||
|  |     err = adc_cali_create_scheme_curve_fitting(&cali_config, &handle); | ||||||
|  |     if (err == ESP_OK) { | ||||||
|  |       this->calibration_handle_ = handle; | ||||||
|  |       this->setup_flags_.calibration_complete = true; | ||||||
|  |       ESP_LOGV(TAG, "Using curve fitting calibration"); | ||||||
|  |     } else { | ||||||
|  |       ESP_LOGW(TAG, "Curve fitting calibration failed with error %d, will use uncalibrated readings", err); | ||||||
|  |       this->setup_flags_.calibration_complete = false; | ||||||
|  |     } | ||||||
|  | #else  // Other ESP32 variants use line fitting calibration | ||||||
|  |     adc_cali_line_fitting_config_t cali_config = { | ||||||
|  |       .unit_id = this->adc_unit_, | ||||||
|  |       .atten = this->attenuation_, | ||||||
|  |       .bitwidth = ADC_BITWIDTH_DEFAULT, | ||||||
|  | #if !defined(USE_ESP32_VARIANT_ESP32S2) | ||||||
|  |       .default_vref = 1100,  // Default reference voltage in mV | ||||||
|  | #endif  // !defined(USE_ESP32_VARIANT_ESP32S2) | ||||||
|  |     }; | ||||||
|  |     err = adc_cali_create_scheme_line_fitting(&cali_config, &handle); | ||||||
|  |     if (err == ESP_OK) { | ||||||
|  |       this->calibration_handle_ = handle; | ||||||
|  |       this->setup_flags_.calibration_complete = true; | ||||||
|  |       ESP_LOGV(TAG, "Using line fitting calibration"); | ||||||
|  |     } else { | ||||||
|  |       ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err); | ||||||
|  |       this->setup_flags_.calibration_complete = false; | ||||||
|  |     } | ||||||
|  | #endif  // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   this->setup_flags_.init_complete = true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void ADCSensor::dump_config() { | ||||||
|  |   LOG_SENSOR("", "ADC Sensor", this); | ||||||
|  |   LOG_PIN("  Pin: ", this->pin_); | ||||||
|   ESP_LOGCONFIG(TAG, |   ESP_LOGCONFIG(TAG, | ||||||
|                 "  Attenuation: %s\n" |                 "  Channel:       %d\n" | ||||||
|                 "  Samples: %i\n" |                 "  Unit:          %s\n" | ||||||
|  |                 "  Attenuation:   %s\n" | ||||||
|  |                 "  Samples:       %i\n" | ||||||
|                 "  Sampling mode: %s", |                 "  Sampling mode: %s", | ||||||
|                 atten_str, this->sample_count_, LOG_STR_ARG(sampling_mode_to_str(this->sampling_mode_))); |                 this->channel_, LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)), | ||||||
|  |                 this->autorange_ ? "Auto" : LOG_STR_ARG(attenuation_to_str(this->attenuation_)), this->sample_count_, | ||||||
|  |                 LOG_STR_ARG(sampling_mode_to_str(this->sampling_mode_))); | ||||||
|  |  | ||||||
|  |   ESP_LOGCONFIG( | ||||||
|  |       TAG, | ||||||
|  |       "  Setup Status:\n" | ||||||
|  |       "    Handle Init:  %s\n" | ||||||
|  |       "    Config:       %s\n" | ||||||
|  |       "    Calibration:  %s\n" | ||||||
|  |       "    Overall Init: %s", | ||||||
|  |       this->setup_flags_.handle_init_complete ? "OK" : "FAILED", this->setup_flags_.config_complete ? "OK" : "FAILED", | ||||||
|  |       this->setup_flags_.calibration_complete ? "OK" : "FAILED", this->setup_flags_.init_complete ? "OK" : "FAILED"); | ||||||
|  |  | ||||||
|   LOG_UPDATE_INTERVAL(this); |   LOG_UPDATE_INTERVAL(this); | ||||||
| } | } | ||||||
|  |  | ||||||
| float ADCSensor::sample() { | float ADCSensor::sample() { | ||||||
|   if (!this->autorange_) { |   if (this->autorange_) { | ||||||
|     auto aggr = Aggregator(this->sampling_mode_); |     return this->sample_autorange_(); | ||||||
|  |   } else { | ||||||
|  |     return this->sample_fixed_attenuation_(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|     for (uint8_t sample = 0; sample < this->sample_count_; sample++) { | float ADCSensor::sample_fixed_attenuation_() { | ||||||
|       int raw = -1; |   auto aggr = Aggregator(this->sampling_mode_); | ||||||
|       if (this->channel1_ != ADC1_CHANNEL_MAX) { |  | ||||||
|         raw = adc1_get_raw(this->channel1_); |  | ||||||
|       } else if (this->channel2_ != ADC2_CHANNEL_MAX) { |  | ||||||
|         adc2_get_raw(this->channel2_, ADC_WIDTH_MAX_SOC_BITS, &raw); |  | ||||||
|       } |  | ||||||
|       if (raw == -1) { |  | ||||||
|         return NAN; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       aggr.add_sample(raw); |   for (uint8_t sample = 0; sample < this->sample_count_; sample++) { | ||||||
|  |     int raw; | ||||||
|  |     esp_err_t err = adc_oneshot_read(this->adc_handle_, this->channel_, &raw); | ||||||
|  |  | ||||||
|  |     if (err != ESP_OK) { | ||||||
|  |       ESP_LOGW(TAG, "ADC read failed with error %d", err); | ||||||
|  |       continue; | ||||||
|     } |     } | ||||||
|     if (this->output_raw_) { |  | ||||||
|       return aggr.aggregate(); |     if (raw == -1) { | ||||||
|  |       ESP_LOGW(TAG, "Invalid ADC reading"); | ||||||
|  |       continue; | ||||||
|     } |     } | ||||||
|     uint32_t mv = |  | ||||||
|         esp_adc_cal_raw_to_voltage(aggr.aggregate(), &this->cal_characteristics_[(int32_t) this->attenuation_]); |     aggr.add_sample(raw); | ||||||
|     return mv / 1000.0f; |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   int raw12 = ADC_MAX, raw6 = ADC_MAX, raw2 = ADC_MAX, raw0 = ADC_MAX; |   uint32_t final_value = aggr.aggregate(); | ||||||
|  |  | ||||||
|   if (this->channel1_ != ADC1_CHANNEL_MAX) { |   if (this->output_raw_) { | ||||||
|     adc1_config_channel_atten(this->channel1_, ADC_ATTEN_DB_12_COMPAT); |     return final_value; | ||||||
|     raw12 = adc1_get_raw(this->channel1_); |   } | ||||||
|     if (raw12 < ADC_MAX) { |  | ||||||
|       adc1_config_channel_atten(this->channel1_, ADC_ATTEN_DB_6); |   if (this->calibration_handle_ != nullptr) { | ||||||
|       raw6 = adc1_get_raw(this->channel1_); |     int voltage_mv; | ||||||
|       if (raw6 < ADC_MAX) { |     esp_err_t err = adc_cali_raw_to_voltage(this->calibration_handle_, final_value, &voltage_mv); | ||||||
|         adc1_config_channel_atten(this->channel1_, ADC_ATTEN_DB_2_5); |     if (err == ESP_OK) { | ||||||
|         raw2 = adc1_get_raw(this->channel1_); |       return voltage_mv / 1000.0f; | ||||||
|         if (raw2 < ADC_MAX) { |     } else { | ||||||
|           adc1_config_channel_atten(this->channel1_, ADC_ATTEN_DB_0); |       ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err); | ||||||
|           raw0 = adc1_get_raw(this->channel1_); |       if (this->calibration_handle_ != nullptr) { | ||||||
|         } | #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ | ||||||
|  |     USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 | ||||||
|  |         adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); | ||||||
|  | #else   // Other ESP32 variants use line fitting calibration | ||||||
|  |         adc_cali_delete_scheme_line_fitting(this->calibration_handle_); | ||||||
|  | #endif  // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 | ||||||
|  |         this->calibration_handle_ = nullptr; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } else if (this->channel2_ != ADC2_CHANNEL_MAX) { |   } | ||||||
|     adc2_config_channel_atten(this->channel2_, ADC_ATTEN_DB_12_COMPAT); |  | ||||||
|     adc2_get_raw(this->channel2_, ADC_WIDTH_MAX_SOC_BITS, &raw12); |   return final_value * 3.3f / 4095.0f; | ||||||
|     if (raw12 < ADC_MAX) { | } | ||||||
|       adc2_config_channel_atten(this->channel2_, ADC_ATTEN_DB_6); |  | ||||||
|       adc2_get_raw(this->channel2_, ADC_WIDTH_MAX_SOC_BITS, &raw6); | float ADCSensor::sample_autorange_() { | ||||||
|       if (raw6 < ADC_MAX) { |   // Auto-range mode | ||||||
|         adc2_config_channel_atten(this->channel2_, ADC_ATTEN_DB_2_5); |   auto read_atten = [this](adc_atten_t atten) -> std::pair<int, float> { | ||||||
|         adc2_get_raw(this->channel2_, ADC_WIDTH_MAX_SOC_BITS, &raw2); |     // First reconfigure the attenuation for this reading | ||||||
|         if (raw2 < ADC_MAX) { |     adc_oneshot_chan_cfg_t config = { | ||||||
|           adc2_config_channel_atten(this->channel2_, ADC_ATTEN_DB_0); |         .atten = atten, | ||||||
|           adc2_get_raw(this->channel2_, ADC_WIDTH_MAX_SOC_BITS, &raw0); |         .bitwidth = ADC_BITWIDTH_DEFAULT, | ||||||
|         } |     }; | ||||||
|  |  | ||||||
|  |     esp_err_t err = adc_oneshot_config_channel(this->adc_handle_, this->channel_, &config); | ||||||
|  |  | ||||||
|  |     if (err != ESP_OK) { | ||||||
|  |       ESP_LOGW(TAG, "Error configuring ADC channel for autorange: %d", err); | ||||||
|  |       return {-1, 0.0f}; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Need to recalibrate for the new attenuation | ||||||
|  |     if (this->calibration_handle_ != nullptr) { | ||||||
|  |       // Delete old calibration handle | ||||||
|  | #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ | ||||||
|  |     USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 | ||||||
|  |       adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); | ||||||
|  | #else | ||||||
|  |       adc_cali_delete_scheme_line_fitting(this->calibration_handle_); | ||||||
|  | #endif | ||||||
|  |       this->calibration_handle_ = nullptr; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Create new calibration handle for this attenuation | ||||||
|  |     adc_cali_handle_t handle = nullptr; | ||||||
|  |  | ||||||
|  | #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ | ||||||
|  |     USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 | ||||||
|  |     adc_cali_curve_fitting_config_t cali_config = {}; | ||||||
|  | #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) | ||||||
|  |     cali_config.chan = this->channel_; | ||||||
|  | #endif | ||||||
|  |     cali_config.unit_id = this->adc_unit_; | ||||||
|  |     cali_config.atten = atten; | ||||||
|  |     cali_config.bitwidth = ADC_BITWIDTH_DEFAULT; | ||||||
|  |  | ||||||
|  |     err = adc_cali_create_scheme_curve_fitting(&cali_config, &handle); | ||||||
|  | #else | ||||||
|  |     adc_cali_line_fitting_config_t cali_config = { | ||||||
|  |       .unit_id = this->adc_unit_, | ||||||
|  |       .atten = atten, | ||||||
|  |       .bitwidth = ADC_BITWIDTH_DEFAULT, | ||||||
|  | #if !defined(USE_ESP32_VARIANT_ESP32S2) | ||||||
|  |       .default_vref = 1100, | ||||||
|  | #endif | ||||||
|  |     }; | ||||||
|  |     err = adc_cali_create_scheme_line_fitting(&cali_config, &handle); | ||||||
|  | #endif | ||||||
|  |  | ||||||
|  |     int raw; | ||||||
|  |     err = adc_oneshot_read(this->adc_handle_, this->channel_, &raw); | ||||||
|  |  | ||||||
|  |     if (err != ESP_OK) { | ||||||
|  |       ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); | ||||||
|  |       if (handle != nullptr) { | ||||||
|  | #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ | ||||||
|  |     USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 | ||||||
|  |         adc_cali_delete_scheme_curve_fitting(handle); | ||||||
|  | #else | ||||||
|  |         adc_cali_delete_scheme_line_fitting(handle); | ||||||
|  | #endif | ||||||
|  |       } | ||||||
|  |       return {-1, 0.0f}; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     float voltage = 0.0f; | ||||||
|  |     if (handle != nullptr) { | ||||||
|  |       int voltage_mv; | ||||||
|  |       err = adc_cali_raw_to_voltage(handle, raw, &voltage_mv); | ||||||
|  |       if (err == ESP_OK) { | ||||||
|  |         voltage = voltage_mv / 1000.0f; | ||||||
|  |       } else { | ||||||
|  |         voltage = raw * 3.3f / 4095.0f; | ||||||
|  |       } | ||||||
|  |       // Clean up calibration handle | ||||||
|  | #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ | ||||||
|  |     USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 | ||||||
|  |       adc_cali_delete_scheme_curve_fitting(handle); | ||||||
|  | #else | ||||||
|  |       adc_cali_delete_scheme_line_fitting(handle); | ||||||
|  | #endif | ||||||
|  |     } else { | ||||||
|  |       voltage = raw * 3.3f / 4095.0f; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return {raw, voltage}; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   auto [raw12, mv12] = read_atten(ADC_ATTEN_DB_12); | ||||||
|  |   if (raw12 == -1) { | ||||||
|  |     ESP_LOGE(TAG, "Failed to read ADC in autorange mode"); | ||||||
|  |     return NAN; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   int raw6 = 4095, raw2 = 4095, raw0 = 4095; | ||||||
|  |   float mv6 = 0, mv2 = 0, mv0 = 0; | ||||||
|  |  | ||||||
|  |   if (raw12 < 4095) { | ||||||
|  |     auto [raw6_val, mv6_val] = read_atten(ADC_ATTEN_DB_6); | ||||||
|  |     raw6 = raw6_val; | ||||||
|  |     mv6 = mv6_val; | ||||||
|  |  | ||||||
|  |     if (raw6 < 4095 && raw6 != -1) { | ||||||
|  |       auto [raw2_val, mv2_val] = read_atten(ADC_ATTEN_DB_2_5); | ||||||
|  |       raw2 = raw2_val; | ||||||
|  |       mv2 = mv2_val; | ||||||
|  |  | ||||||
|  |       if (raw2 < 4095 && raw2 != -1) { | ||||||
|  |         auto [raw0_val, mv0_val] = read_atten(ADC_ATTEN_DB_0); | ||||||
|  |         raw0 = raw0_val; | ||||||
|  |         mv0 = mv0_val; | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -155,19 +325,19 @@ float ADCSensor::sample() { | |||||||
|     return NAN; |     return NAN; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   uint32_t mv12 = esp_adc_cal_raw_to_voltage(raw12, &this->cal_characteristics_[(int32_t) ADC_ATTEN_DB_12_COMPAT]); |   const int adc_half = 2048; | ||||||
|   uint32_t mv6 = esp_adc_cal_raw_to_voltage(raw6, &this->cal_characteristics_[(int32_t) ADC_ATTEN_DB_6]); |   uint32_t c12 = std::min(raw12, adc_half); | ||||||
|   uint32_t mv2 = esp_adc_cal_raw_to_voltage(raw2, &this->cal_characteristics_[(int32_t) ADC_ATTEN_DB_2_5]); |   uint32_t c6 = adc_half - std::abs(raw6 - adc_half); | ||||||
|   uint32_t mv0 = esp_adc_cal_raw_to_voltage(raw0, &this->cal_characteristics_[(int32_t) ADC_ATTEN_DB_0]); |   uint32_t c2 = adc_half - std::abs(raw2 - adc_half); | ||||||
|  |   uint32_t c0 = std::min(4095 - raw0, adc_half); | ||||||
|   uint32_t c12 = std::min(raw12, ADC_HALF); |  | ||||||
|   uint32_t c6 = ADC_HALF - std::abs(raw6 - ADC_HALF); |  | ||||||
|   uint32_t c2 = ADC_HALF - std::abs(raw2 - ADC_HALF); |  | ||||||
|   uint32_t c0 = std::min(ADC_MAX - raw0, ADC_HALF); |  | ||||||
|   uint32_t csum = c12 + c6 + c2 + c0; |   uint32_t csum = c12 + c6 + c2 + c0; | ||||||
|  |  | ||||||
|   uint32_t mv_scaled = (mv12 * c12) + (mv6 * c6) + (mv2 * c2) + (mv0 * c0); |   if (csum == 0) { | ||||||
|   return mv_scaled / (float) (csum * 1000U); |     ESP_LOGE(TAG, "Invalid weight sum in autorange calculation"); | ||||||
|  |     return NAN; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum; | ||||||
| } | } | ||||||
|  |  | ||||||
| }  // namespace adc | }  // namespace adc | ||||||
|   | |||||||
| @@ -56,8 +56,6 @@ float ADCSensor::sample() { | |||||||
|   return aggr.aggregate() / 1024.0f; |   return aggr.aggregate() / 1024.0f; | ||||||
| } | } | ||||||
|  |  | ||||||
| std::string ADCSensor::unique_id() { return get_mac_address() + "-adc"; } |  | ||||||
|  |  | ||||||
| }  // namespace adc | }  // namespace adc | ||||||
| }  // namespace esphome | }  // namespace esphome | ||||||
|  |  | ||||||
|   | |||||||
| @@ -10,13 +10,11 @@ from esphome.const import ( | |||||||
|     CONF_NUMBER, |     CONF_NUMBER, | ||||||
|     CONF_PIN, |     CONF_PIN, | ||||||
|     CONF_RAW, |     CONF_RAW, | ||||||
|     CONF_WIFI, |  | ||||||
|     DEVICE_CLASS_VOLTAGE, |     DEVICE_CLASS_VOLTAGE, | ||||||
|     STATE_CLASS_MEASUREMENT, |     STATE_CLASS_MEASUREMENT, | ||||||
|     UNIT_VOLT, |     UNIT_VOLT, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE | from esphome.core import CORE | ||||||
| import esphome.final_validate as fv |  | ||||||
|  |  | ||||||
| from . import ( | from . import ( | ||||||
|     ATTENUATION_MODES, |     ATTENUATION_MODES, | ||||||
| @@ -24,6 +22,7 @@ from . import ( | |||||||
|     ESP32_VARIANT_ADC2_PIN_TO_CHANNEL, |     ESP32_VARIANT_ADC2_PIN_TO_CHANNEL, | ||||||
|     SAMPLING_MODES, |     SAMPLING_MODES, | ||||||
|     adc_ns, |     adc_ns, | ||||||
|  |     adc_unit_t, | ||||||
|     validate_adc_pin, |     validate_adc_pin, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -57,21 +56,6 @@ def validate_config(config): | |||||||
|     return config |     return config | ||||||
|  |  | ||||||
|  |  | ||||||
| def final_validate_config(config): |  | ||||||
|     if CORE.is_esp32: |  | ||||||
|         variant = get_esp32_variant() |  | ||||||
|         if ( |  | ||||||
|             CONF_WIFI in fv.full_config.get() |  | ||||||
|             and config[CONF_PIN][CONF_NUMBER] |  | ||||||
|             in ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant] |  | ||||||
|         ): |  | ||||||
|             raise cv.Invalid( |  | ||||||
|                 f"{variant} doesn't support ADC on this pin when Wi-Fi is configured" |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     return config |  | ||||||
|  |  | ||||||
|  |  | ||||||
| ADCSensor = adc_ns.class_( | ADCSensor = adc_ns.class_( | ||||||
|     "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler |     "ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler | ||||||
| ) | ) | ||||||
| @@ -99,8 +83,6 @@ CONFIG_SCHEMA = cv.All( | |||||||
|     validate_config, |     validate_config, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| FINAL_VALIDATE_SCHEMA = final_validate_config |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def to_code(config): | async def to_code(config): | ||||||
|     var = cg.new_Pvariable(config[CONF_ID]) |     var = cg.new_Pvariable(config[CONF_ID]) | ||||||
| @@ -119,13 +101,13 @@ async def to_code(config): | |||||||
|     cg.add(var.set_sample_count(config[CONF_SAMPLES])) |     cg.add(var.set_sample_count(config[CONF_SAMPLES])) | ||||||
|     cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE])) |     cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE])) | ||||||
|  |  | ||||||
|     if attenuation := config.get(CONF_ATTENUATION): |  | ||||||
|         if attenuation == "auto": |  | ||||||
|             cg.add(var.set_autorange(cg.global_ns.true)) |  | ||||||
|         else: |  | ||||||
|             cg.add(var.set_attenuation(attenuation)) |  | ||||||
|  |  | ||||||
|     if CORE.is_esp32: |     if CORE.is_esp32: | ||||||
|  |         if attenuation := config.get(CONF_ATTENUATION): | ||||||
|  |             if attenuation == "auto": | ||||||
|  |                 cg.add(var.set_autorange(cg.global_ns.true)) | ||||||
|  |             else: | ||||||
|  |                 cg.add(var.set_attenuation(attenuation)) | ||||||
|  |  | ||||||
|         variant = get_esp32_variant() |         variant = get_esp32_variant() | ||||||
|         pin_num = config[CONF_PIN][CONF_NUMBER] |         pin_num = config[CONF_PIN][CONF_NUMBER] | ||||||
|         if ( |         if ( | ||||||
| @@ -133,10 +115,10 @@ async def to_code(config): | |||||||
|             and pin_num in ESP32_VARIANT_ADC1_PIN_TO_CHANNEL[variant] |             and pin_num in ESP32_VARIANT_ADC1_PIN_TO_CHANNEL[variant] | ||||||
|         ): |         ): | ||||||
|             chan = ESP32_VARIANT_ADC1_PIN_TO_CHANNEL[variant][pin_num] |             chan = ESP32_VARIANT_ADC1_PIN_TO_CHANNEL[variant][pin_num] | ||||||
|             cg.add(var.set_channel1(chan)) |             cg.add(var.set_channel(adc_unit_t.ADC_UNIT_1, chan)) | ||||||
|         elif ( |         elif ( | ||||||
|             variant in ESP32_VARIANT_ADC2_PIN_TO_CHANNEL |             variant in ESP32_VARIANT_ADC2_PIN_TO_CHANNEL | ||||||
|             and pin_num in ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant] |             and pin_num in ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant] | ||||||
|         ): |         ): | ||||||
|             chan = ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant][pin_num] |             chan = ESP32_VARIANT_ADC2_PIN_TO_CHANNEL[variant][pin_num] | ||||||
|             cg.add(var.set_channel2(chan)) |             cg.add(var.set_channel(adc_unit_t.ADC_UNIT_2, chan)) | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| CODEOWNERS = ["@jeromelaban"] | CODEOWNERS = ["@jeromelaban", "@precurse"] | ||||||
|   | |||||||
| @@ -73,11 +73,29 @@ void AirthingsWavePlus::dump_config() { | |||||||
|   LOG_SENSOR("  ", "Illuminance", this->illuminance_sensor_); |   LOG_SENSOR("  ", "Illuminance", this->illuminance_sensor_); | ||||||
| } | } | ||||||
|  |  | ||||||
| AirthingsWavePlus::AirthingsWavePlus() { | void AirthingsWavePlus::setup() { | ||||||
|   this->service_uuid_ = espbt::ESPBTUUID::from_raw(SERVICE_UUID); |   const char *service_uuid; | ||||||
|   this->sensors_data_characteristic_uuid_ = espbt::ESPBTUUID::from_raw(CHARACTERISTIC_UUID); |   const char *characteristic_uuid; | ||||||
|  |   const char *access_control_point_characteristic_uuid; | ||||||
|  |  | ||||||
|  |   // Change UUIDs for Wave Radon Gen2 | ||||||
|  |   switch (this->wave_device_type_) { | ||||||
|  |     case WaveDeviceType::WAVE_GEN2: | ||||||
|  |       service_uuid = SERVICE_UUID_WAVE_RADON_GEN2; | ||||||
|  |       characteristic_uuid = CHARACTERISTIC_UUID_WAVE_RADON_GEN2; | ||||||
|  |       access_control_point_characteristic_uuid = ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2; | ||||||
|  |       break; | ||||||
|  |     default: | ||||||
|  |       // Wave Plus | ||||||
|  |       service_uuid = SERVICE_UUID; | ||||||
|  |       characteristic_uuid = CHARACTERISTIC_UUID; | ||||||
|  |       access_control_point_characteristic_uuid = ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   this->service_uuid_ = espbt::ESPBTUUID::from_raw(service_uuid); | ||||||
|  |   this->sensors_data_characteristic_uuid_ = espbt::ESPBTUUID::from_raw(characteristic_uuid); | ||||||
|   this->access_control_point_characteristic_uuid_ = |   this->access_control_point_characteristic_uuid_ = | ||||||
|       espbt::ESPBTUUID::from_raw(ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID); |       espbt::ESPBTUUID::from_raw(access_control_point_characteristic_uuid); | ||||||
| } | } | ||||||
|  |  | ||||||
| }  // namespace airthings_wave_plus | }  // namespace airthings_wave_plus | ||||||
|   | |||||||
| @@ -9,13 +9,20 @@ namespace airthings_wave_plus { | |||||||
|  |  | ||||||
| namespace espbt = esphome::esp32_ble_tracker; | namespace espbt = esphome::esp32_ble_tracker; | ||||||
|  |  | ||||||
|  | enum WaveDeviceType : uint8_t { WAVE_PLUS = 0, WAVE_GEN2 = 1 }; | ||||||
|  |  | ||||||
| static const char *const SERVICE_UUID = "b42e1c08-ade7-11e4-89d3-123b93f75cba"; | static const char *const SERVICE_UUID = "b42e1c08-ade7-11e4-89d3-123b93f75cba"; | ||||||
| static const char *const CHARACTERISTIC_UUID = "b42e2a68-ade7-11e4-89d3-123b93f75cba"; | static const char *const CHARACTERISTIC_UUID = "b42e2a68-ade7-11e4-89d3-123b93f75cba"; | ||||||
| static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e2d06-ade7-11e4-89d3-123b93f75cba"; | static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e2d06-ade7-11e4-89d3-123b93f75cba"; | ||||||
|  |  | ||||||
|  | static const char *const SERVICE_UUID_WAVE_RADON_GEN2 = "b42e4a8e-ade7-11e4-89d3-123b93f75cba"; | ||||||
|  | static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11e4-89d3-123b93f75cba"; | ||||||
|  | static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = | ||||||
|  |     "b42e50d8-ade7-11e4-89d3-123b93f75cba"; | ||||||
|  |  | ||||||
| class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { | class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { | ||||||
|  public: |  public: | ||||||
|   AirthingsWavePlus(); |   void setup() override; | ||||||
|  |  | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|  |  | ||||||
| @@ -23,12 +30,14 @@ class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase { | |||||||
|   void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; } |   void set_radon_long_term(sensor::Sensor *radon_long_term) { radon_long_term_sensor_ = radon_long_term; } | ||||||
|   void set_co2(sensor::Sensor *co2) { co2_sensor_ = co2; } |   void set_co2(sensor::Sensor *co2) { co2_sensor_ = co2; } | ||||||
|   void set_illuminance(sensor::Sensor *illuminance) { illuminance_sensor_ = illuminance; } |   void set_illuminance(sensor::Sensor *illuminance) { illuminance_sensor_ = illuminance; } | ||||||
|  |   void set_device_type(WaveDeviceType wave_device_type) { wave_device_type_ = wave_device_type; } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   bool is_valid_radon_value_(uint16_t radon); |   bool is_valid_radon_value_(uint16_t radon); | ||||||
|   bool is_valid_co2_value_(uint16_t co2); |   bool is_valid_co2_value_(uint16_t co2); | ||||||
|  |  | ||||||
|   void read_sensors(uint8_t *raw_value, uint16_t value_len) override; |   void read_sensors(uint8_t *raw_value, uint16_t value_len) override; | ||||||
|  |   WaveDeviceType wave_device_type_{WaveDeviceType::WAVE_PLUS}; | ||||||
|  |  | ||||||
|   sensor::Sensor *radon_sensor_{nullptr}; |   sensor::Sensor *radon_sensor_{nullptr}; | ||||||
|   sensor::Sensor *radon_long_term_sensor_{nullptr}; |   sensor::Sensor *radon_long_term_sensor_{nullptr}; | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from esphome.const import ( | |||||||
|     CONF_ILLUMINANCE, |     CONF_ILLUMINANCE, | ||||||
|     CONF_RADON, |     CONF_RADON, | ||||||
|     CONF_RADON_LONG_TERM, |     CONF_RADON_LONG_TERM, | ||||||
|  |     CONF_TVOC, | ||||||
|     DEVICE_CLASS_CARBON_DIOXIDE, |     DEVICE_CLASS_CARBON_DIOXIDE, | ||||||
|     DEVICE_CLASS_ILLUMINANCE, |     DEVICE_CLASS_ILLUMINANCE, | ||||||
|     ICON_RADIOACTIVE, |     ICON_RADIOACTIVE, | ||||||
| @@ -15,6 +16,7 @@ from esphome.const import ( | |||||||
|     UNIT_LUX, |     UNIT_LUX, | ||||||
|     UNIT_PARTS_PER_MILLION, |     UNIT_PARTS_PER_MILLION, | ||||||
| ) | ) | ||||||
|  | from esphome.types import ConfigType | ||||||
|  |  | ||||||
| DEPENDENCIES = airthings_wave_base.DEPENDENCIES | DEPENDENCIES = airthings_wave_base.DEPENDENCIES | ||||||
|  |  | ||||||
| @@ -25,35 +27,59 @@ AirthingsWavePlus = airthings_wave_plus_ns.class_( | |||||||
|     "AirthingsWavePlus", airthings_wave_base.AirthingsWaveBase |     "AirthingsWavePlus", airthings_wave_base.AirthingsWaveBase | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | CONF_DEVICE_TYPE = "device_type" | ||||||
|  | WaveDeviceType = airthings_wave_plus_ns.enum("WaveDeviceType") | ||||||
|  | DEVICE_TYPES = { | ||||||
|  |     "WAVE_PLUS": WaveDeviceType.WAVE_PLUS, | ||||||
|  |     "WAVE_GEN2": WaveDeviceType.WAVE_GEN2, | ||||||
|  | } | ||||||
|  |  | ||||||
| CONFIG_SCHEMA = airthings_wave_base.BASE_SCHEMA.extend( |  | ||||||
|     { | def validate_wave_gen2_config(config: ConfigType) -> ConfigType: | ||||||
|         cv.GenerateID(): cv.declare_id(AirthingsWavePlus), |     """Validate that Wave Gen2 devices don't have CO2 or TVOC sensors.""" | ||||||
|         cv.Optional(CONF_RADON): sensor.sensor_schema( |     if config[CONF_DEVICE_TYPE] == "WAVE_GEN2": | ||||||
|             unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, |         if CONF_CO2 in config: | ||||||
|             icon=ICON_RADIOACTIVE, |             raise cv.Invalid("Wave Gen2 devices do not support CO2 sensor") | ||||||
|             accuracy_decimals=0, |         # Check for TVOC in the base schema config | ||||||
|             state_class=STATE_CLASS_MEASUREMENT, |         if CONF_TVOC in config: | ||||||
|         ), |             raise cv.Invalid("Wave Gen2 devices do not support TVOC sensor") | ||||||
|         cv.Optional(CONF_RADON_LONG_TERM): sensor.sensor_schema( |     return config | ||||||
|             unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, |  | ||||||
|             icon=ICON_RADIOACTIVE, |  | ||||||
|             accuracy_decimals=0, | CONFIG_SCHEMA = cv.All( | ||||||
|             state_class=STATE_CLASS_MEASUREMENT, |     airthings_wave_base.BASE_SCHEMA.extend( | ||||||
|         ), |         { | ||||||
|         cv.Optional(CONF_CO2): sensor.sensor_schema( |             cv.GenerateID(): cv.declare_id(AirthingsWavePlus), | ||||||
|             unit_of_measurement=UNIT_PARTS_PER_MILLION, |             cv.Optional(CONF_RADON): sensor.sensor_schema( | ||||||
|             accuracy_decimals=0, |                 unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, | ||||||
|             device_class=DEVICE_CLASS_CARBON_DIOXIDE, |                 icon=ICON_RADIOACTIVE, | ||||||
|             state_class=STATE_CLASS_MEASUREMENT, |                 accuracy_decimals=0, | ||||||
|         ), |                 state_class=STATE_CLASS_MEASUREMENT, | ||||||
|         cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( |             ), | ||||||
|             unit_of_measurement=UNIT_LUX, |             cv.Optional(CONF_RADON_LONG_TERM): sensor.sensor_schema( | ||||||
|             accuracy_decimals=0, |                 unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, | ||||||
|             device_class=DEVICE_CLASS_ILLUMINANCE, |                 icon=ICON_RADIOACTIVE, | ||||||
|             state_class=STATE_CLASS_MEASUREMENT, |                 accuracy_decimals=0, | ||||||
|         ), |                 state_class=STATE_CLASS_MEASUREMENT, | ||||||
|     } |             ), | ||||||
|  |             cv.Optional(CONF_CO2): sensor.sensor_schema( | ||||||
|  |                 unit_of_measurement=UNIT_PARTS_PER_MILLION, | ||||||
|  |                 accuracy_decimals=0, | ||||||
|  |                 device_class=DEVICE_CLASS_CARBON_DIOXIDE, | ||||||
|  |                 state_class=STATE_CLASS_MEASUREMENT, | ||||||
|  |             ), | ||||||
|  |             cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( | ||||||
|  |                 unit_of_measurement=UNIT_LUX, | ||||||
|  |                 accuracy_decimals=0, | ||||||
|  |                 device_class=DEVICE_CLASS_ILLUMINANCE, | ||||||
|  |                 state_class=STATE_CLASS_MEASUREMENT, | ||||||
|  |             ), | ||||||
|  |             cv.Optional(CONF_DEVICE_TYPE, default="WAVE_PLUS"): cv.enum( | ||||||
|  |                 DEVICE_TYPES, upper=True | ||||||
|  |             ), | ||||||
|  |         } | ||||||
|  |     ), | ||||||
|  |     validate_wave_gen2_config, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -73,3 +99,4 @@ async def to_code(config): | |||||||
|     if config_illuminance := config.get(CONF_ILLUMINANCE): |     if config_illuminance := config.get(CONF_ILLUMINANCE): | ||||||
|         sens = await sensor.new_sensor(config_illuminance) |         sens = await sensor.new_sensor(config_illuminance) | ||||||
|         cg.add(var.set_illuminance(sens)) |         cg.add(var.set_illuminance(sens)) | ||||||
|  |     cg.add(var.set_device_type(config[CONF_DEVICE_TYPE])) | ||||||
|   | |||||||
| @@ -23,7 +23,7 @@ void APDS9960::setup() { | |||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (id != 0xAB && id != 0x9C && id != 0xA8) {  // APDS9960 all should have one of these IDs |   if (id != 0xAB && id != 0x9C && id != 0xA8 && id != 0x9E) {  // APDS9960 all should have one of these IDs | ||||||
|     this->error_code_ = WRONG_ID; |     this->error_code_ = WRONG_ID; | ||||||
|     this->mark_failed(); |     this->mark_failed(); | ||||||
|     return; |     return; | ||||||
|   | |||||||
| @@ -24,8 +24,9 @@ from esphome.const import ( | |||||||
|     CONF_TRIGGER_ID, |     CONF_TRIGGER_ID, | ||||||
|     CONF_VARIABLES, |     CONF_VARIABLES, | ||||||
| ) | ) | ||||||
| from esphome.core import coroutine_with_priority | from esphome.core import CORE, coroutine_with_priority | ||||||
|  |  | ||||||
|  | DOMAIN = "api" | ||||||
| DEPENDENCIES = ["network"] | DEPENDENCIES = ["network"] | ||||||
| AUTO_LOAD = ["socket"] | AUTO_LOAD = ["socket"] | ||||||
| CODEOWNERS = ["@OttoWinter"] | CODEOWNERS = ["@OttoWinter"] | ||||||
| @@ -51,6 +52,7 @@ SERVICE_ARG_NATIVE_TYPES = { | |||||||
| } | } | ||||||
| CONF_ENCRYPTION = "encryption" | CONF_ENCRYPTION = "encryption" | ||||||
| CONF_BATCH_DELAY = "batch_delay" | CONF_BATCH_DELAY = "batch_delay" | ||||||
|  | CONF_CUSTOM_SERVICES = "custom_services" | ||||||
|  |  | ||||||
|  |  | ||||||
| def validate_encryption_key(value): | def validate_encryption_key(value): | ||||||
| @@ -115,6 +117,7 @@ CONFIG_SCHEMA = cv.All( | |||||||
|                 cv.positive_time_period_milliseconds, |                 cv.positive_time_period_milliseconds, | ||||||
|                 cv.Range(max=cv.TimePeriod(milliseconds=65535)), |                 cv.Range(max=cv.TimePeriod(milliseconds=65535)), | ||||||
|             ), |             ), | ||||||
|  |             cv.Optional(CONF_CUSTOM_SERVICES, default=False): cv.boolean, | ||||||
|             cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation( |             cv.Optional(CONF_ON_CLIENT_CONNECTED): automation.validate_automation( | ||||||
|                 single=True |                 single=True | ||||||
|             ), |             ), | ||||||
| @@ -139,8 +142,11 @@ async def to_code(config): | |||||||
|     cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) |     cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) | ||||||
|     cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) |     cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) | ||||||
|  |  | ||||||
|  |     # Set USE_API_SERVICES if any services are enabled | ||||||
|  |     if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: | ||||||
|  |         cg.add_define("USE_API_SERVICES") | ||||||
|  |  | ||||||
|     if actions := config.get(CONF_ACTIONS, []): |     if actions := config.get(CONF_ACTIONS, []): | ||||||
|         cg.add_define("USE_API_YAML_SERVICES") |  | ||||||
|         for conf in actions: |         for conf in actions: | ||||||
|             template_args = [] |             template_args = [] | ||||||
|             func_args = [] |             func_args = [] | ||||||
| @@ -317,7 +323,11 @@ async def api_connected_to_code(config, condition_id, template_arg, args): | |||||||
|  |  | ||||||
|  |  | ||||||
| def FILTER_SOURCE_FILES() -> list[str]: | def FILTER_SOURCE_FILES() -> list[str]: | ||||||
|     """Filter out api_pb2_dump.cpp when proto message dumping is not enabled.""" |     """Filter out api_pb2_dump.cpp when proto message dumping is not enabled, | ||||||
|  |     user_services.cpp when no services are defined, and protocol-specific | ||||||
|  |     implementations based on encryption configuration.""" | ||||||
|  |     files_to_filter: list[str] = [] | ||||||
|  |  | ||||||
|     # api_pb2_dump.cpp is only needed when HAS_PROTO_MESSAGE_DUMP is defined |     # api_pb2_dump.cpp is only needed when HAS_PROTO_MESSAGE_DUMP is defined | ||||||
|     # This is a particularly large file that still needs to be opened and read |     # This is a particularly large file that still needs to be opened and read | ||||||
|     # all the way to the end even when ifdef'd out |     # all the way to the end even when ifdef'd out | ||||||
| @@ -325,6 +335,23 @@ def FILTER_SOURCE_FILES() -> list[str]: | |||||||
|     # HAS_PROTO_MESSAGE_DUMP is defined when ESPHOME_LOG_HAS_VERY_VERBOSE is set, |     # HAS_PROTO_MESSAGE_DUMP is defined when ESPHOME_LOG_HAS_VERY_VERBOSE is set, | ||||||
|     # which happens when the logger level is VERY_VERBOSE |     # which happens when the logger level is VERY_VERBOSE | ||||||
|     if get_logger_level() != "VERY_VERBOSE": |     if get_logger_level() != "VERY_VERBOSE": | ||||||
|         return ["api_pb2_dump.cpp"] |         files_to_filter.append("api_pb2_dump.cpp") | ||||||
|  |  | ||||||
|     return [] |     # user_services.cpp is only needed when services are defined | ||||||
|  |     config = CORE.config.get(DOMAIN, {}) | ||||||
|  |     if config and not config.get(CONF_ACTIONS) and not config[CONF_CUSTOM_SERVICES]: | ||||||
|  |         files_to_filter.append("user_services.cpp") | ||||||
|  |  | ||||||
|  |     # Filter protocol-specific implementations based on encryption configuration | ||||||
|  |     encryption_config = config.get(CONF_ENCRYPTION) if config else None | ||||||
|  |  | ||||||
|  |     # If encryption is not configured at all, we only need plaintext | ||||||
|  |     if encryption_config is None: | ||||||
|  |         files_to_filter.append("api_frame_helper_noise.cpp") | ||||||
|  |     # If encryption is configured with a key, we only need noise | ||||||
|  |     elif encryption_config.get(CONF_KEY): | ||||||
|  |         files_to_filter.append("api_frame_helper_plaintext.cpp") | ||||||
|  |     # If encryption is configured but no key is provided, we need both | ||||||
|  |     # (this allows a plaintext client to provide a noise key) | ||||||
|  |  | ||||||
|  |     return files_to_filter | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -16,10 +16,34 @@ | |||||||
| namespace esphome { | namespace esphome { | ||||||
| namespace api { | namespace api { | ||||||
|  |  | ||||||
|  | // Client information structure | ||||||
|  | struct ClientInfo { | ||||||
|  |   std::string name;      // Client name from Hello message | ||||||
|  |   std::string peername;  // IP:port from socket | ||||||
|  |  | ||||||
|  |   std::string get_combined_info() const { | ||||||
|  |     if (name == peername) { | ||||||
|  |       // Before Hello message, both are the same | ||||||
|  |       return name; | ||||||
|  |     } | ||||||
|  |     return name + " (" + peername + ")"; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
| // Keepalive timeout in milliseconds | // Keepalive timeout in milliseconds | ||||||
| static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; | static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; | ||||||
| // Maximum number of entities to process in a single batch during initial state/info sending | // Maximum number of entities to process in a single batch during initial state/info sending | ||||||
| static constexpr size_t MAX_INITIAL_PER_BATCH = 20; | // This was increased from 20 to 24 after removing the unique_id field from entity info messages, | ||||||
|  | // which reduced message sizes allowing more entities per batch without exceeding packet limits | ||||||
|  | static constexpr size_t MAX_INITIAL_PER_BATCH = 24; | ||||||
|  | // Maximum number of packets to process in a single batch (platform-dependent) | ||||||
|  | // This limit exists to prevent stack overflow from the PacketInfo array in process_batch_ | ||||||
|  | // Each PacketInfo is 8 bytes, so 64 * 8 = 512 bytes, 32 * 8 = 256 bytes | ||||||
|  | #if defined(USE_ESP32) || defined(USE_HOST) | ||||||
|  | static constexpr size_t MAX_PACKETS_PER_BATCH = 64;  // ESP32 has 8KB+ stack, HOST has plenty | ||||||
|  | #else | ||||||
|  | static constexpr size_t MAX_PACKETS_PER_BATCH = 32;  // ESP8266/RP2040/etc have smaller stacks | ||||||
|  | #endif | ||||||
|  |  | ||||||
| class APIConnection : public APIServerConnection { | class APIConnection : public APIServerConnection { | ||||||
|  public: |  public: | ||||||
| @@ -33,7 +57,7 @@ class APIConnection : public APIServerConnection { | |||||||
|  |  | ||||||
|   bool send_list_info_done() { |   bool send_list_info_done() { | ||||||
|     return this->schedule_message_(nullptr, &APIConnection::try_send_list_info_done, |     return this->schedule_message_(nullptr, &APIConnection::try_send_list_info_done, | ||||||
|                                    ListEntitiesDoneResponse::MESSAGE_TYPE); |                                    ListEntitiesDoneResponse::MESSAGE_TYPE, ListEntitiesDoneResponse::ESTIMATED_SIZE); | ||||||
|   } |   } | ||||||
| #ifdef USE_BINARY_SENSOR | #ifdef USE_BINARY_SENSOR | ||||||
|   bool send_binary_sensor_state(binary_sensor::BinarySensor *binary_sensor); |   bool send_binary_sensor_state(binary_sensor::BinarySensor *binary_sensor); | ||||||
| @@ -111,12 +135,11 @@ class APIConnection : public APIServerConnection { | |||||||
|   void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { |   void send_homeassistant_service_call(const HomeassistantServiceResponse &call) { | ||||||
|     if (!this->flags_.service_call_subscription) |     if (!this->flags_.service_call_subscription) | ||||||
|       return; |       return; | ||||||
|     this->send_message(call); |     this->send_message(call, HomeassistantServiceResponse::MESSAGE_TYPE); | ||||||
|   } |   } | ||||||
| #ifdef USE_BLUETOOTH_PROXY | #ifdef USE_BLUETOOTH_PROXY | ||||||
|   void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; |   void subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) override; | ||||||
|   void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; |   void unsubscribe_bluetooth_le_advertisements(const UnsubscribeBluetoothLEAdvertisementsRequest &msg) override; | ||||||
|   bool send_bluetooth_le_advertisement(const BluetoothLEAdvertisementResponse &msg); |  | ||||||
|  |  | ||||||
|   void bluetooth_device_request(const BluetoothDeviceRequest &msg) override; |   void bluetooth_device_request(const BluetoothDeviceRequest &msg) override; | ||||||
|   void bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) override; |   void bluetooth_gatt_read(const BluetoothGATTReadRequest &msg) override; | ||||||
| @@ -133,7 +156,7 @@ class APIConnection : public APIServerConnection { | |||||||
| #ifdef USE_HOMEASSISTANT_TIME | #ifdef USE_HOMEASSISTANT_TIME | ||||||
|   void send_time_request() { |   void send_time_request() { | ||||||
|     GetTimeRequest req; |     GetTimeRequest req; | ||||||
|     this->send_message(req); |     this->send_message(req, GetTimeRequest::MESSAGE_TYPE); | ||||||
|   } |   } | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
| @@ -195,7 +218,9 @@ class APIConnection : public APIServerConnection { | |||||||
|     // TODO |     // TODO | ||||||
|     return {}; |     return {}; | ||||||
|   } |   } | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|   void execute_service(const ExecuteServiceRequest &msg) override; |   void execute_service(const ExecuteServiceRequest &msg) override; | ||||||
|  | #endif | ||||||
| #ifdef USE_API_NOISE | #ifdef USE_API_NOISE | ||||||
|   NoiseEncryptionSetKeyResponse noise_encryption_set_key(const NoiseEncryptionSetKeyRequest &msg) override; |   NoiseEncryptionSetKeyResponse noise_encryption_set_key(const NoiseEncryptionSetKeyRequest &msg) override; | ||||||
| #endif | #endif | ||||||
| @@ -207,6 +232,7 @@ class APIConnection : public APIServerConnection { | |||||||
|     return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED || |     return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED || | ||||||
|            this->is_authenticated(); |            this->is_authenticated(); | ||||||
|   } |   } | ||||||
|  |   uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } | ||||||
|   void on_fatal_error() override; |   void on_fatal_error() override; | ||||||
|   void on_unauthenticated_access() override; |   void on_unauthenticated_access() override; | ||||||
|   void on_no_setup_connection() override; |   void on_no_setup_connection() override; | ||||||
| @@ -256,51 +282,54 @@ class APIConnection : public APIServerConnection { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   bool try_to_clear_buffer(bool log_out_of_space); |   bool try_to_clear_buffer(bool log_out_of_space); | ||||||
|   bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) override; |   bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; | ||||||
|  |  | ||||||
|   std::string get_client_combined_info() const { |   std::string get_client_combined_info() const { return this->client_info_.get_combined_info(); } | ||||||
|     if (this->client_info_ == this->client_peername_) { |  | ||||||
|       // Before Hello message, both are the same (just IP:port) |  | ||||||
|       return this->client_info_; |  | ||||||
|     } |  | ||||||
|     return this->client_info_ + " (" + this->client_peername_ + ")"; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Buffer allocator methods for batch processing |   // Buffer allocator methods for batch processing | ||||||
|   ProtoWriteBuffer allocate_single_message_buffer(uint16_t size); |   ProtoWriteBuffer allocate_single_message_buffer(uint16_t size); | ||||||
|   ProtoWriteBuffer allocate_batch_message_buffer(uint16_t size); |   ProtoWriteBuffer allocate_batch_message_buffer(uint16_t size); | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   // Helper function to fill common entity info fields |   // Helper function to handle authentication completion | ||||||
|   static void fill_entity_info_base(esphome::EntityBase *entity, InfoResponseProtoMessage &response) { |   void complete_authentication_(); | ||||||
|     // Set common fields that are shared by all entity types |  | ||||||
|     response.key = entity->get_object_id_hash(); |  | ||||||
|     response.object_id = entity->get_object_id(); |  | ||||||
|  |  | ||||||
|     if (entity->has_own_name()) |  | ||||||
|       response.name = entity->get_name(); |  | ||||||
|  |  | ||||||
|     // Set common EntityBase properties |  | ||||||
|     response.icon = entity->get_icon(); |  | ||||||
|     response.disabled_by_default = entity->is_disabled_by_default(); |  | ||||||
|     response.entity_category = static_cast<enums::EntityCategory>(entity->get_entity_category()); |  | ||||||
| #ifdef USE_DEVICES |  | ||||||
|     response.device_id = entity->get_device_id(); |  | ||||||
| #endif |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Helper function to fill common entity state fields |  | ||||||
|   static void fill_entity_state_base(esphome::EntityBase *entity, StateResponseProtoMessage &response) { |  | ||||||
|     response.key = entity->get_object_id_hash(); |  | ||||||
| #ifdef USE_DEVICES |  | ||||||
|     response.device_id = entity->get_device_id(); |  | ||||||
| #endif |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Non-template helper to encode any ProtoMessage |   // Non-template helper to encode any ProtoMessage | ||||||
|   static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint16_t message_type, APIConnection *conn, |   static uint16_t encode_message_to_buffer(ProtoMessage &msg, uint8_t message_type, APIConnection *conn, | ||||||
|                                            uint32_t remaining_size, bool is_single); |                                            uint32_t remaining_size, bool is_single); | ||||||
|  |  | ||||||
|  |   // Helper to fill entity state base and encode message | ||||||
|  |   static uint16_t fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, uint8_t message_type, | ||||||
|  |                                                APIConnection *conn, uint32_t remaining_size, bool is_single) { | ||||||
|  |     msg.key = entity->get_object_id_hash(); | ||||||
|  | #ifdef USE_DEVICES | ||||||
|  |     msg.device_id = entity->get_device_id(); | ||||||
|  | #endif | ||||||
|  |     return encode_message_to_buffer(msg, message_type, conn, remaining_size, is_single); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Helper to fill entity info base and encode message | ||||||
|  |   static uint16_t fill_and_encode_entity_info(EntityBase *entity, InfoResponseProtoMessage &msg, uint8_t message_type, | ||||||
|  |                                               APIConnection *conn, uint32_t remaining_size, bool is_single) { | ||||||
|  |     // Set common fields that are shared by all entity types | ||||||
|  |     msg.key = entity->get_object_id_hash(); | ||||||
|  |     msg.object_id = entity->get_object_id(); | ||||||
|  |  | ||||||
|  |     if (entity->has_own_name()) | ||||||
|  |       msg.name = entity->get_name(); | ||||||
|  |  | ||||||
|  |       // Set common EntityBase properties | ||||||
|  | #ifdef USE_ENTITY_ICON | ||||||
|  |     msg.icon = entity->get_icon(); | ||||||
|  | #endif | ||||||
|  |     msg.disabled_by_default = entity->is_disabled_by_default(); | ||||||
|  |     msg.entity_category = static_cast<enums::EntityCategory>(entity->get_entity_category()); | ||||||
|  | #ifdef USE_DEVICES | ||||||
|  |     msg.device_id = entity->get_device_id(); | ||||||
|  | #endif | ||||||
|  |     return encode_message_to_buffer(msg, message_type, conn, remaining_size, is_single); | ||||||
|  |   } | ||||||
|  |  | ||||||
| #ifdef USE_VOICE_ASSISTANT | #ifdef USE_VOICE_ASSISTANT | ||||||
|   // Helper to check voice assistant validity and connection ownership |   // Helper to check voice assistant validity and connection ownership | ||||||
|   inline bool check_voice_assistant_api_connection_() const; |   inline bool check_voice_assistant_api_connection_() const; | ||||||
| @@ -443,9 +472,6 @@ class APIConnection : public APIServerConnection { | |||||||
|   static uint16_t try_send_disconnect_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, |   static uint16_t try_send_disconnect_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, | ||||||
|                                               bool is_single); |                                               bool is_single); | ||||||
|  |  | ||||||
|   // Helper function to get estimated message size for buffer pre-allocation |  | ||||||
|   static uint16_t get_estimated_message_size(uint16_t message_type); |  | ||||||
|  |  | ||||||
|   // Batch message method for ping requests |   // Batch message method for ping requests | ||||||
|   static uint16_t try_send_ping_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, |   static uint16_t try_send_ping_request(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, | ||||||
|                                         bool is_single); |                                         bool is_single); | ||||||
| @@ -464,9 +490,8 @@ class APIConnection : public APIServerConnection { | |||||||
|   std::unique_ptr<camera::CameraImageReader> image_reader_; |   std::unique_ptr<camera::CameraImageReader> image_reader_; | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
|   // Group 3: Strings (12 bytes each on 32-bit, 4-byte aligned) |   // Group 3: Client info struct (24 bytes on 32-bit: 2 strings × 12 bytes each) | ||||||
|   std::string client_info_; |   ClientInfo client_info_; | ||||||
|   std::string client_peername_; |  | ||||||
|  |  | ||||||
|   // Group 4: 4-byte types |   // Group 4: 4-byte types | ||||||
|   uint32_t last_traffic_; |   uint32_t last_traffic_; | ||||||
| @@ -505,10 +530,10 @@ class APIConnection : public APIServerConnection { | |||||||
|  |  | ||||||
|     // Call operator - uses message_type to determine union type |     // Call operator - uses message_type to determine union type | ||||||
|     uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, |     uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, | ||||||
|                         uint16_t message_type) const; |                         uint8_t message_type) const; | ||||||
|  |  | ||||||
|     // Manual cleanup method - must be called before destruction for string types |     // Manual cleanup method - must be called before destruction for string types | ||||||
|     void cleanup(uint16_t message_type) { |     void cleanup(uint8_t message_type) { | ||||||
| #ifdef USE_EVENT | #ifdef USE_EVENT | ||||||
|       if (message_type == EventResponse::MESSAGE_TYPE && data_.string_ptr != nullptr) { |       if (message_type == EventResponse::MESSAGE_TYPE && data_.string_ptr != nullptr) { | ||||||
|         delete data_.string_ptr; |         delete data_.string_ptr; | ||||||
| @@ -529,11 +554,12 @@ class APIConnection : public APIServerConnection { | |||||||
|     struct BatchItem { |     struct BatchItem { | ||||||
|       EntityBase *entity;      // Entity pointer |       EntityBase *entity;      // Entity pointer | ||||||
|       MessageCreator creator;  // Function that creates the message when needed |       MessageCreator creator;  // Function that creates the message when needed | ||||||
|       uint16_t message_type;   // Message type for overhead calculation |       uint8_t message_type;    // Message type for overhead calculation (max 255) | ||||||
|  |       uint8_t estimated_size;  // Estimated message size (max 255 bytes) | ||||||
|  |  | ||||||
|       // Constructor for creating BatchItem |       // Constructor for creating BatchItem | ||||||
|       BatchItem(EntityBase *entity, MessageCreator creator, uint16_t message_type) |       BatchItem(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) | ||||||
|           : entity(entity), creator(std::move(creator)), message_type(message_type) {} |           : entity(entity), creator(std::move(creator)), message_type(message_type), estimated_size(estimated_size) {} | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     std::vector<BatchItem> items; |     std::vector<BatchItem> items; | ||||||
| @@ -559,9 +585,9 @@ class APIConnection : public APIServerConnection { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Add item to the batch |     // Add item to the batch | ||||||
|     void add_item(EntityBase *entity, MessageCreator creator, uint16_t message_type); |     void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); | ||||||
|     // Add item to the front of the batch (for high priority messages like ping) |     // Add item to the front of the batch (for high priority messages like ping) | ||||||
|     void add_item_front(EntityBase *entity, MessageCreator creator, uint16_t message_type); |     void add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); | ||||||
|  |  | ||||||
|     // Clear all items with proper cleanup |     // Clear all items with proper cleanup | ||||||
|     void clear() { |     void clear() { | ||||||
| @@ -630,7 +656,7 @@ class APIConnection : public APIServerConnection { | |||||||
|   // to send in one go. This is the maximum size of a single packet |   // to send in one go. This is the maximum size of a single packet | ||||||
|   // that can be sent over the network. |   // that can be sent over the network. | ||||||
|   // This is to avoid fragmentation of the packet. |   // This is to avoid fragmentation of the packet. | ||||||
|   static constexpr size_t MAX_PACKET_SIZE = 1390;  // MTU |   static constexpr size_t MAX_BATCH_PACKET_SIZE = 1390;  // MTU | ||||||
|  |  | ||||||
|   bool schedule_batch_(); |   bool schedule_batch_(); | ||||||
|   void process_batch_(); |   void process_batch_(); | ||||||
| @@ -641,9 +667,9 @@ class APIConnection : public APIServerConnection { | |||||||
|  |  | ||||||
| #ifdef HAS_PROTO_MESSAGE_DUMP | #ifdef HAS_PROTO_MESSAGE_DUMP | ||||||
|   // Helper to log a proto message from a MessageCreator object |   // Helper to log a proto message from a MessageCreator object | ||||||
|   void log_proto_message_(EntityBase *entity, const MessageCreator &creator, uint16_t message_type) { |   void log_proto_message_(EntityBase *entity, const MessageCreator &creator, uint8_t message_type) { | ||||||
|     this->flags_.log_only_mode = true; |     this->flags_.log_only_mode = true; | ||||||
|     creator(entity, this, MAX_PACKET_SIZE, true, message_type); |     creator(entity, this, MAX_BATCH_PACKET_SIZE, true, message_type); | ||||||
|     this->flags_.log_only_mode = false; |     this->flags_.log_only_mode = false; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -654,7 +680,8 @@ class APIConnection : public APIServerConnection { | |||||||
| #endif | #endif | ||||||
|  |  | ||||||
|   // Helper method to send a message either immediately or via batching |   // Helper method to send a message either immediately or via batching | ||||||
|   bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint16_t message_type) { |   bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, | ||||||
|  |                            uint8_t estimated_size) { | ||||||
|     // Try to send immediately if: |     // Try to send immediately if: | ||||||
|     // 1. We should try to send immediately (should_try_send_immediately = true) |     // 1. We should try to send immediately (should_try_send_immediately = true) | ||||||
|     // 2. Batch delay is 0 (user has opted in to immediate sending) |     // 2. Batch delay is 0 (user has opted in to immediate sending) | ||||||
| @@ -662,7 +689,7 @@ class APIConnection : public APIServerConnection { | |||||||
|     if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && |     if (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0 && | ||||||
|         this->helper_->can_write_without_blocking()) { |         this->helper_->can_write_without_blocking()) { | ||||||
|       // Now actually encode and send |       // Now actually encode and send | ||||||
|       if (creator(entity, this, MAX_PACKET_SIZE, true) && |       if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && | ||||||
|           this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { |           this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { | ||||||
| #ifdef HAS_PROTO_MESSAGE_DUMP | #ifdef HAS_PROTO_MESSAGE_DUMP | ||||||
|         // Log the message in verbose mode |         // Log the message in verbose mode | ||||||
| @@ -675,23 +702,25 @@ class APIConnection : public APIServerConnection { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Fall back to scheduled batching |     // Fall back to scheduled batching | ||||||
|     return this->schedule_message_(entity, creator, message_type); |     return this->schedule_message_(entity, creator, message_type, estimated_size); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Helper function to schedule a deferred message with known message type |   // Helper function to schedule a deferred message with known message type | ||||||
|   bool schedule_message_(EntityBase *entity, MessageCreator creator, uint16_t message_type) { |   bool schedule_message_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) { | ||||||
|     this->deferred_batch_.add_item(entity, std::move(creator), message_type); |     this->deferred_batch_.add_item(entity, std::move(creator), message_type, estimated_size); | ||||||
|     return this->schedule_batch_(); |     return this->schedule_batch_(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Overload for function pointers (for info messages and current state reads) |   // Overload for function pointers (for info messages and current state reads) | ||||||
|   bool schedule_message_(EntityBase *entity, MessageCreatorPtr function_ptr, uint16_t message_type) { |   bool schedule_message_(EntityBase *entity, MessageCreatorPtr function_ptr, uint8_t message_type, | ||||||
|     return schedule_message_(entity, MessageCreator(function_ptr), message_type); |                          uint8_t estimated_size) { | ||||||
|  |     return schedule_message_(entity, MessageCreator(function_ptr), message_type, estimated_size); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Helper function to schedule a high priority message at the front of the batch |   // Helper function to schedule a high priority message at the front of the batch | ||||||
|   bool schedule_message_front_(EntityBase *entity, MessageCreatorPtr function_ptr, uint16_t message_type) { |   bool schedule_message_front_(EntityBase *entity, MessageCreatorPtr function_ptr, uint8_t message_type, | ||||||
|     this->deferred_batch_.add_item_front(entity, MessageCreator(function_ptr), message_type); |                                uint8_t estimated_size) { | ||||||
|  |     this->deferred_batch_.add_item_front(entity, MessageCreator(function_ptr), message_type, estimated_size); | ||||||
|     return this->schedule_batch_(); |     return this->schedule_batch_(); | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -8,17 +8,19 @@ | |||||||
|  |  | ||||||
| #include "esphome/core/defines.h" | #include "esphome/core/defines.h" | ||||||
| #ifdef USE_API | #ifdef USE_API | ||||||
| #ifdef USE_API_NOISE |  | ||||||
| #include "noise/protocol.h" |  | ||||||
| #endif |  | ||||||
|  |  | ||||||
| #include "api_noise_context.h" |  | ||||||
| #include "esphome/components/socket/socket.h" | #include "esphome/components/socket/socket.h" | ||||||
| #include "esphome/core/application.h" | #include "esphome/core/application.h" | ||||||
|  | #include "esphome/core/log.h" | ||||||
|  |  | ||||||
| namespace esphome { | namespace esphome { | ||||||
| namespace api { | namespace api { | ||||||
|  |  | ||||||
|  | // uncomment to log raw packets | ||||||
|  | //#define HELPER_LOG_PACKETS | ||||||
|  |  | ||||||
|  | // Forward declaration | ||||||
|  | struct ClientInfo; | ||||||
|  |  | ||||||
| class ProtoWriteBuffer; | class ProtoWriteBuffer; | ||||||
|  |  | ||||||
| struct ReadPacketBuffer { | struct ReadPacketBuffer { | ||||||
| @@ -30,19 +32,16 @@ struct ReadPacketBuffer { | |||||||
|  |  | ||||||
| // Packed packet info structure to minimize memory usage | // Packed packet info structure to minimize memory usage | ||||||
| struct PacketInfo { | struct PacketInfo { | ||||||
|   uint16_t message_type;  // 2 bytes |   uint16_t offset;        // Offset in buffer where message starts | ||||||
|   uint16_t offset;        // 2 bytes (sufficient for packet size ~1460 bytes) |   uint16_t payload_size;  // Size of the message payload | ||||||
|   uint16_t payload_size;  // 2 bytes (up to 65535 bytes) |   uint8_t message_type;   // Message type (0-255) | ||||||
|   uint16_t padding;       // 2 byte (for alignment) |  | ||||||
|  |  | ||||||
|   PacketInfo(uint16_t type, uint16_t off, uint16_t size) |   PacketInfo(uint8_t type, uint16_t off, uint16_t size) : offset(off), payload_size(size), message_type(type) {} | ||||||
|       : message_type(type), offset(off), payload_size(size), padding(0) {} |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| enum class APIError : uint16_t { | enum class APIError : uint16_t { | ||||||
|   OK = 0, |   OK = 0, | ||||||
|   WOULD_BLOCK = 1001, |   WOULD_BLOCK = 1001, | ||||||
|   BAD_HANDSHAKE_PACKET_LEN = 1002, |  | ||||||
|   BAD_INDICATOR = 1003, |   BAD_INDICATOR = 1003, | ||||||
|   BAD_DATA_PACKET = 1004, |   BAD_DATA_PACKET = 1004, | ||||||
|   TCP_NODELAY_FAILED = 1005, |   TCP_NODELAY_FAILED = 1005, | ||||||
| @@ -53,16 +52,19 @@ enum class APIError : uint16_t { | |||||||
|   BAD_ARG = 1010, |   BAD_ARG = 1010, | ||||||
|   SOCKET_READ_FAILED = 1011, |   SOCKET_READ_FAILED = 1011, | ||||||
|   SOCKET_WRITE_FAILED = 1012, |   SOCKET_WRITE_FAILED = 1012, | ||||||
|  |   OUT_OF_MEMORY = 1018, | ||||||
|  |   CONNECTION_CLOSED = 1022, | ||||||
|  | #ifdef USE_API_NOISE | ||||||
|  |   BAD_HANDSHAKE_PACKET_LEN = 1002, | ||||||
|   HANDSHAKESTATE_READ_FAILED = 1013, |   HANDSHAKESTATE_READ_FAILED = 1013, | ||||||
|   HANDSHAKESTATE_WRITE_FAILED = 1014, |   HANDSHAKESTATE_WRITE_FAILED = 1014, | ||||||
|   HANDSHAKESTATE_BAD_STATE = 1015, |   HANDSHAKESTATE_BAD_STATE = 1015, | ||||||
|   CIPHERSTATE_DECRYPT_FAILED = 1016, |   CIPHERSTATE_DECRYPT_FAILED = 1016, | ||||||
|   CIPHERSTATE_ENCRYPT_FAILED = 1017, |   CIPHERSTATE_ENCRYPT_FAILED = 1017, | ||||||
|   OUT_OF_MEMORY = 1018, |  | ||||||
|   HANDSHAKESTATE_SETUP_FAILED = 1019, |   HANDSHAKESTATE_SETUP_FAILED = 1019, | ||||||
|   HANDSHAKESTATE_SPLIT_FAILED = 1020, |   HANDSHAKESTATE_SPLIT_FAILED = 1020, | ||||||
|   BAD_HANDSHAKE_ERROR_BYTE = 1021, |   BAD_HANDSHAKE_ERROR_BYTE = 1021, | ||||||
|   CONNECTION_CLOSED = 1022, | #endif | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const char *api_error_to_str(APIError err); | const char *api_error_to_str(APIError err); | ||||||
| @@ -70,7 +72,8 @@ const char *api_error_to_str(APIError err); | |||||||
| class APIFrameHelper { | class APIFrameHelper { | ||||||
|  public: |  public: | ||||||
|   APIFrameHelper() = default; |   APIFrameHelper() = default; | ||||||
|   explicit APIFrameHelper(std::unique_ptr<socket::Socket> socket) : socket_owned_(std::move(socket)) { |   explicit APIFrameHelper(std::unique_ptr<socket::Socket> socket, const ClientInfo *client_info) | ||||||
|  |       : socket_owned_(std::move(socket)), client_info_(client_info) { | ||||||
|     socket_ = socket_owned_.get(); |     socket_ = socket_owned_.get(); | ||||||
|   } |   } | ||||||
|   virtual ~APIFrameHelper() = default; |   virtual ~APIFrameHelper() = default; | ||||||
| @@ -96,9 +99,7 @@ class APIFrameHelper { | |||||||
|     } |     } | ||||||
|     return APIError::OK; |     return APIError::OK; | ||||||
|   } |   } | ||||||
|   // Give this helper a name for logging |   virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; | ||||||
|   void set_log_info(std::string info) { info_ = std::move(info); } |  | ||||||
|   virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0; |  | ||||||
|   // Write multiple protobuf packets in a single operation |   // Write multiple protobuf packets in a single operation | ||||||
|   // packets contains (message_type, offset, length) for each message in the buffer |   // packets contains (message_type, offset, length) for each message in the buffer | ||||||
|   // The buffer contains all messages with appropriate padding before each |   // The buffer contains all messages with appropriate padding before each | ||||||
| @@ -111,29 +112,28 @@ class APIFrameHelper { | |||||||
|   bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } |   bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   // Struct for holding parsed frame data |  | ||||||
|   struct ParsedFrame { |  | ||||||
|     std::vector<uint8_t> msg; |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   // Buffer containing data to be sent |   // Buffer containing data to be sent | ||||||
|   struct SendBuffer { |   struct SendBuffer { | ||||||
|     std::vector<uint8_t> data; |     std::unique_ptr<uint8_t[]> data; | ||||||
|     uint16_t offset{0};  // Current offset within the buffer (uint16_t to reduce memory usage) |     uint16_t size{0};    // Total size of the buffer | ||||||
|  |     uint16_t offset{0};  // Current offset within the buffer | ||||||
|  |  | ||||||
|     // Using uint16_t reduces memory usage since ESPHome API messages are limited to UINT16_MAX (65535) bytes |     // Using uint16_t reduces memory usage since ESPHome API messages are limited to UINT16_MAX (65535) bytes | ||||||
|     uint16_t remaining() const { return static_cast<uint16_t>(data.size()) - offset; } |     uint16_t remaining() const { return size - offset; } | ||||||
|     const uint8_t *current_data() const { return data.data() + offset; } |     const uint8_t *current_data() const { return data.get() + offset; } | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   // Common implementation for writing raw data to socket |   // Common implementation for writing raw data to socket | ||||||
|   APIError write_raw_(const struct iovec *iov, int iovcnt); |   APIError write_raw_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); | ||||||
|  |  | ||||||
|   // Try to send data from the tx buffer |   // Try to send data from the tx buffer | ||||||
|   APIError try_send_tx_buf_(); |   APIError try_send_tx_buf_(); | ||||||
|  |  | ||||||
|   // Helper method to buffer data from IOVs |   // Helper method to buffer data from IOVs | ||||||
|   void buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); |   void buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len, uint16_t offset); | ||||||
|  |  | ||||||
|  |   // Common socket write error handling | ||||||
|  |   APIError handle_socket_write_error_(); | ||||||
|   template<typename StateEnum> |   template<typename StateEnum> | ||||||
|   APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector<uint8_t> &tx_buf, |   APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector<uint8_t> &tx_buf, | ||||||
|                       const std::string &info, StateEnum &state, StateEnum failed_state); |                       const std::string &info, StateEnum &state, StateEnum failed_state); | ||||||
| @@ -163,10 +163,13 @@ class APIFrameHelper { | |||||||
|  |  | ||||||
|   // Containers (size varies, but typically 12+ bytes on 32-bit) |   // Containers (size varies, but typically 12+ bytes on 32-bit) | ||||||
|   std::deque<SendBuffer> tx_buf_; |   std::deque<SendBuffer> tx_buf_; | ||||||
|   std::string info_; |  | ||||||
|   std::vector<struct iovec> reusable_iovs_; |   std::vector<struct iovec> reusable_iovs_; | ||||||
|   std::vector<uint8_t> rx_buf_; |   std::vector<uint8_t> rx_buf_; | ||||||
|  |  | ||||||
|  |   // Pointer to client info (4 bytes on 32-bit) | ||||||
|  |   // Note: The pointed-to ClientInfo object must outlive this APIFrameHelper instance. | ||||||
|  |   const ClientInfo *client_info_{nullptr}; | ||||||
|  |  | ||||||
|   // Group smaller types together |   // Group smaller types together | ||||||
|   uint16_t rx_buf_len_ = 0; |   uint16_t rx_buf_len_ = 0; | ||||||
|   State state_{State::INITIALIZE}; |   State state_{State::INITIALIZE}; | ||||||
| @@ -181,105 +184,7 @@ class APIFrameHelper { | |||||||
|   APIError handle_socket_read_result_(ssize_t received); |   APIError handle_socket_read_result_(ssize_t received); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| #ifdef USE_API_NOISE |  | ||||||
| class APINoiseFrameHelper : public APIFrameHelper { |  | ||||||
|  public: |  | ||||||
|   APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, std::shared_ptr<APINoiseContext> ctx) |  | ||||||
|       : APIFrameHelper(std::move(socket)), ctx_(std::move(ctx)) { |  | ||||||
|     // Noise header structure: |  | ||||||
|     // Pos 0: indicator (0x01) |  | ||||||
|     // Pos 1-2: encrypted payload size (16-bit big-endian) |  | ||||||
|     // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) |  | ||||||
|     // Pos 7+: actual payload data |  | ||||||
|     frame_header_padding_ = 7; |  | ||||||
|   } |  | ||||||
|   ~APINoiseFrameHelper() override; |  | ||||||
|   APIError init() override; |  | ||||||
|   APIError loop() override; |  | ||||||
|   APIError read_packet(ReadPacketBuffer *buffer) override; |  | ||||||
|   APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; |  | ||||||
|   APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span<const PacketInfo> packets) override; |  | ||||||
|   // Get the frame header padding required by this protocol |  | ||||||
|   uint8_t frame_header_padding() override { return frame_header_padding_; } |  | ||||||
|   // Get the frame footer size required by this protocol |  | ||||||
|   uint8_t frame_footer_size() override { return frame_footer_size_; } |  | ||||||
|  |  | ||||||
|  protected: |  | ||||||
|   APIError state_action_(); |  | ||||||
|   APIError try_read_frame_(ParsedFrame *frame); |  | ||||||
|   APIError write_frame_(const uint8_t *data, uint16_t len); |  | ||||||
|   APIError init_handshake_(); |  | ||||||
|   APIError check_handshake_finished_(); |  | ||||||
|   void send_explicit_handshake_reject_(const std::string &reason); |  | ||||||
|  |  | ||||||
|   // Pointers first (4 bytes each) |  | ||||||
|   NoiseHandshakeState *handshake_{nullptr}; |  | ||||||
|   NoiseCipherState *send_cipher_{nullptr}; |  | ||||||
|   NoiseCipherState *recv_cipher_{nullptr}; |  | ||||||
|  |  | ||||||
|   // Shared pointer (8 bytes on 32-bit = 4 bytes control block pointer + 4 bytes object pointer) |  | ||||||
|   std::shared_ptr<APINoiseContext> ctx_; |  | ||||||
|  |  | ||||||
|   // Vector (12 bytes on 32-bit) |  | ||||||
|   std::vector<uint8_t> prologue_; |  | ||||||
|  |  | ||||||
|   // NoiseProtocolId (size depends on implementation) |  | ||||||
|   NoiseProtocolId nid_; |  | ||||||
|  |  | ||||||
|   // Group small types together |  | ||||||
|   // Fixed-size header buffer for noise protocol: |  | ||||||
|   // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) |  | ||||||
|   // Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase |  | ||||||
|   uint8_t rx_header_buf_[3]; |  | ||||||
|   uint8_t rx_header_buf_len_ = 0; |  | ||||||
|   // 4 bytes total, no padding |  | ||||||
| }; |  | ||||||
| #endif  // USE_API_NOISE |  | ||||||
|  |  | ||||||
| #ifdef USE_API_PLAINTEXT |  | ||||||
| class APIPlaintextFrameHelper : public APIFrameHelper { |  | ||||||
|  public: |  | ||||||
|   APIPlaintextFrameHelper(std::unique_ptr<socket::Socket> socket) : APIFrameHelper(std::move(socket)) { |  | ||||||
|     // Plaintext header structure (worst case): |  | ||||||
|     // Pos 0: indicator (0x00) |  | ||||||
|     // Pos 1-3: payload size varint (up to 3 bytes) |  | ||||||
|     // Pos 4-5: message type varint (up to 2 bytes) |  | ||||||
|     // Pos 6+: actual payload data |  | ||||||
|     frame_header_padding_ = 6; |  | ||||||
|   } |  | ||||||
|   ~APIPlaintextFrameHelper() override = default; |  | ||||||
|   APIError init() override; |  | ||||||
|   APIError loop() override; |  | ||||||
|   APIError read_packet(ReadPacketBuffer *buffer) override; |  | ||||||
|   APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; |  | ||||||
|   APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span<const PacketInfo> packets) override; |  | ||||||
|   uint8_t frame_header_padding() override { return frame_header_padding_; } |  | ||||||
|   // Get the frame footer size required by this protocol |  | ||||||
|   uint8_t frame_footer_size() override { return frame_footer_size_; } |  | ||||||
|  |  | ||||||
|  protected: |  | ||||||
|   APIError try_read_frame_(ParsedFrame *frame); |  | ||||||
|  |  | ||||||
|   // Group 2-byte aligned types |  | ||||||
|   uint16_t rx_header_parsed_type_ = 0; |  | ||||||
|   uint16_t rx_header_parsed_len_ = 0; |  | ||||||
|  |  | ||||||
|   // Group 1-byte types together |  | ||||||
|   // Fixed-size header buffer for plaintext protocol: |  | ||||||
|   // We now store the indicator byte + the two varints. |  | ||||||
|   // To match noise protocol's maximum message size (UINT16_MAX = 65535), we need: |  | ||||||
|   // 1 byte for indicator + 3 bytes for message size varint (supports up to 2097151) + 2 bytes for message type varint |  | ||||||
|   // |  | ||||||
|   // While varints could theoretically be up to 10 bytes each for 64-bit values, |  | ||||||
|   // attempting to process messages with headers that large would likely crash the |  | ||||||
|   // ESP32 due to memory constraints. |  | ||||||
|   uint8_t rx_header_buf_[6];  // 1 byte indicator + 5 bytes for varints (3 for size + 2 for type) |  | ||||||
|   uint8_t rx_header_buf_pos_ = 0; |  | ||||||
|   bool rx_header_parsed_ = false; |  | ||||||
|   // 8 bytes total, no padding needed |  | ||||||
| }; |  | ||||||
| #endif |  | ||||||
|  |  | ||||||
| }  // namespace api | }  // namespace api | ||||||
| }  // namespace esphome | }  // namespace esphome | ||||||
| #endif |  | ||||||
|  | #endif  // USE_API | ||||||
|   | |||||||
							
								
								
									
										577
									
								
								esphome/components/api/api_frame_helper_noise.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										577
									
								
								esphome/components/api/api_frame_helper_noise.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,577 @@ | |||||||
|  | #include "api_frame_helper_noise.h" | ||||||
|  | #ifdef USE_API | ||||||
|  | #ifdef USE_API_NOISE | ||||||
|  | #include "api_connection.h"  // For ClientInfo struct | ||||||
|  | #include "esphome/core/application.h" | ||||||
|  | #include "esphome/core/hal.h" | ||||||
|  | #include "esphome/core/helpers.h" | ||||||
|  | #include "esphome/core/log.h" | ||||||
|  | #include "proto.h" | ||||||
|  | #include <cstring> | ||||||
|  | #include <cinttypes> | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace api { | ||||||
|  |  | ||||||
|  | static const char *const TAG = "api.noise"; | ||||||
|  | static const char *const PROLOGUE_INIT = "NoiseAPIInit"; | ||||||
|  |  | ||||||
|  | #define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) | ||||||
|  |  | ||||||
|  | #ifdef HELPER_LOG_PACKETS | ||||||
|  | #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) | ||||||
|  | #define LOG_PACKET_SENDING(data, len) ESP_LOGVV(TAG, "Sending raw: %s", format_hex_pretty(data, len).c_str()) | ||||||
|  | #else | ||||||
|  | #define LOG_PACKET_RECEIVED(buffer) ((void) 0) | ||||||
|  | #define LOG_PACKET_SENDING(data, len) ((void) 0) | ||||||
|  | #endif | ||||||
|  |  | ||||||
|  | /// Convert a noise error code to a readable error | ||||||
|  | std::string noise_err_to_str(int err) { | ||||||
|  |   if (err == NOISE_ERROR_NO_MEMORY) | ||||||
|  |     return "NO_MEMORY"; | ||||||
|  |   if (err == NOISE_ERROR_UNKNOWN_ID) | ||||||
|  |     return "UNKNOWN_ID"; | ||||||
|  |   if (err == NOISE_ERROR_UNKNOWN_NAME) | ||||||
|  |     return "UNKNOWN_NAME"; | ||||||
|  |   if (err == NOISE_ERROR_MAC_FAILURE) | ||||||
|  |     return "MAC_FAILURE"; | ||||||
|  |   if (err == NOISE_ERROR_NOT_APPLICABLE) | ||||||
|  |     return "NOT_APPLICABLE"; | ||||||
|  |   if (err == NOISE_ERROR_SYSTEM) | ||||||
|  |     return "SYSTEM"; | ||||||
|  |   if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) | ||||||
|  |     return "REMOTE_KEY_REQUIRED"; | ||||||
|  |   if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) | ||||||
|  |     return "LOCAL_KEY_REQUIRED"; | ||||||
|  |   if (err == NOISE_ERROR_PSK_REQUIRED) | ||||||
|  |     return "PSK_REQUIRED"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_LENGTH) | ||||||
|  |     return "INVALID_LENGTH"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_PARAM) | ||||||
|  |     return "INVALID_PARAM"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_STATE) | ||||||
|  |     return "INVALID_STATE"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_NONCE) | ||||||
|  |     return "INVALID_NONCE"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) | ||||||
|  |     return "INVALID_PRIVATE_KEY"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) | ||||||
|  |     return "INVALID_PUBLIC_KEY"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_FORMAT) | ||||||
|  |     return "INVALID_FORMAT"; | ||||||
|  |   if (err == NOISE_ERROR_INVALID_SIGNATURE) | ||||||
|  |     return "INVALID_SIGNATURE"; | ||||||
|  |   return to_string(err); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Initialize the frame helper, returns OK if successful. | ||||||
|  | APIError APINoiseFrameHelper::init() { | ||||||
|  |   APIError err = init_common_(); | ||||||
|  |   if (err != APIError::OK) { | ||||||
|  |     return err; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // init prologue | ||||||
|  |   prologue_.insert(prologue_.end(), PROLOGUE_INIT, PROLOGUE_INIT + strlen(PROLOGUE_INIT)); | ||||||
|  |  | ||||||
|  |   state_ = State::CLIENT_HELLO; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  | // Helper for handling handshake frame errors | ||||||
|  | APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { | ||||||
|  |   if (aerr == APIError::BAD_INDICATOR) { | ||||||
|  |     send_explicit_handshake_reject_("Bad indicator byte"); | ||||||
|  |   } else if (aerr == APIError::BAD_HANDSHAKE_PACKET_LEN) { | ||||||
|  |     send_explicit_handshake_reject_("Bad handshake packet len"); | ||||||
|  |   } | ||||||
|  |   return aerr; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Helper for handling noise library errors | ||||||
|  | APIError APINoiseFrameHelper::handle_noise_error_(int err, const char *func_name, APIError api_err) { | ||||||
|  |   if (err != 0) { | ||||||
|  |     state_ = State::FAILED; | ||||||
|  |     HELPER_LOG("%s failed: %s", func_name, noise_err_to_str(err).c_str()); | ||||||
|  |     return api_err; | ||||||
|  |   } | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Run through handshake messages (if in that phase) | ||||||
|  | APIError APINoiseFrameHelper::loop() { | ||||||
|  |   // During handshake phase, process as many actions as possible until we can't progress | ||||||
|  |   // socket_->ready() stays true until next main loop, but state_action() will return | ||||||
|  |   // WOULD_BLOCK when no more data is available to read | ||||||
|  |   while (state_ != State::DATA && this->socket_->ready()) { | ||||||
|  |     APIError err = state_action_(); | ||||||
|  |     if (err == APIError::WOULD_BLOCK) { | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |     if (err != APIError::OK) { | ||||||
|  |       return err; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Use base class implementation for buffer sending | ||||||
|  |   return APIFrameHelper::loop(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** Read a packet into the rx_buf_. If successful, stores frame data in the frame parameter | ||||||
|  |  * | ||||||
|  |  * @param frame: The struct to hold the frame information in. | ||||||
|  |  *   msg_start: points to the start of the payload - this pointer is only valid until the next | ||||||
|  |  *     try_receive_raw_ call | ||||||
|  |  * | ||||||
|  |  * @return 0 if a full packet is in rx_buf_ | ||||||
|  |  * @return -1 if error, check errno. | ||||||
|  |  * | ||||||
|  |  * errno EWOULDBLOCK: Packet could not be read without blocking. Try again later. | ||||||
|  |  * errno ENOMEM: Not enough memory for reading packet. | ||||||
|  |  * errno API_ERROR_BAD_INDICATOR: Bad indicator byte at start of frame. | ||||||
|  |  * errno API_ERROR_HANDSHAKE_PACKET_LEN: Packet too big for this phase. | ||||||
|  |  */ | ||||||
|  | APIError APINoiseFrameHelper::try_read_frame_(std::vector<uint8_t> *frame) { | ||||||
|  |   if (frame == nullptr) { | ||||||
|  |     HELPER_LOG("Bad argument for try_read_frame_"); | ||||||
|  |     return APIError::BAD_ARG; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // read header | ||||||
|  |   if (rx_header_buf_len_ < 3) { | ||||||
|  |     // no header information yet | ||||||
|  |     uint8_t to_read = 3 - rx_header_buf_len_; | ||||||
|  |     ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read); | ||||||
|  |     APIError err = handle_socket_read_result_(received); | ||||||
|  |     if (err != APIError::OK) { | ||||||
|  |       return err; | ||||||
|  |     } | ||||||
|  |     rx_header_buf_len_ += static_cast<uint8_t>(received); | ||||||
|  |     if (static_cast<uint8_t>(received) != to_read) { | ||||||
|  |       // not a full read | ||||||
|  |       return APIError::WOULD_BLOCK; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (rx_header_buf_[0] != 0x01) { | ||||||
|  |       state_ = State::FAILED; | ||||||
|  |       HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); | ||||||
|  |       return APIError::BAD_INDICATOR; | ||||||
|  |     } | ||||||
|  |     // header reading done | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // read body | ||||||
|  |   uint16_t msg_size = (((uint16_t) rx_header_buf_[1]) << 8) | rx_header_buf_[2]; | ||||||
|  |  | ||||||
|  |   if (state_ != State::DATA && msg_size > 128) { | ||||||
|  |     // for handshake message only permit up to 128 bytes | ||||||
|  |     state_ = State::FAILED; | ||||||
|  |     HELPER_LOG("Bad packet len for handshake: %d", msg_size); | ||||||
|  |     return APIError::BAD_HANDSHAKE_PACKET_LEN; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // reserve space for body | ||||||
|  |   if (rx_buf_.size() != msg_size) { | ||||||
|  |     rx_buf_.resize(msg_size); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (rx_buf_len_ < msg_size) { | ||||||
|  |     // more data to read | ||||||
|  |     uint16_t to_read = msg_size - rx_buf_len_; | ||||||
|  |     ssize_t received = this->socket_->read(&rx_buf_[rx_buf_len_], to_read); | ||||||
|  |     APIError err = handle_socket_read_result_(received); | ||||||
|  |     if (err != APIError::OK) { | ||||||
|  |       return err; | ||||||
|  |     } | ||||||
|  |     rx_buf_len_ += static_cast<uint16_t>(received); | ||||||
|  |     if (static_cast<uint16_t>(received) != to_read) { | ||||||
|  |       // not all read | ||||||
|  |       return APIError::WOULD_BLOCK; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   LOG_PACKET_RECEIVED(rx_buf_); | ||||||
|  |   *frame = std::move(rx_buf_); | ||||||
|  |   // consume msg | ||||||
|  |   rx_buf_ = {}; | ||||||
|  |   rx_buf_len_ = 0; | ||||||
|  |   rx_header_buf_len_ = 0; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** To be called from read/write methods. | ||||||
|  |  * | ||||||
|  |  * This method runs through the internal handshake methods, if in that state. | ||||||
|  |  * | ||||||
|  |  * If the handshake is still active when this method returns and a read/write can't take place at | ||||||
|  |  * the moment, returns WOULD_BLOCK. | ||||||
|  |  * If an error occurred, returns that error. Only returns OK if the transport is ready for data | ||||||
|  |  * traffic. | ||||||
|  |  */ | ||||||
|  | APIError APINoiseFrameHelper::state_action_() { | ||||||
|  |   int err; | ||||||
|  |   APIError aerr; | ||||||
|  |   if (state_ == State::INITIALIZE) { | ||||||
|  |     HELPER_LOG("Bad state for method: %d", (int) state_); | ||||||
|  |     return APIError::BAD_STATE; | ||||||
|  |   } | ||||||
|  |   if (state_ == State::CLIENT_HELLO) { | ||||||
|  |     // waiting for client hello | ||||||
|  |     std::vector<uint8_t> frame; | ||||||
|  |     aerr = try_read_frame_(&frame); | ||||||
|  |     if (aerr != APIError::OK) { | ||||||
|  |       return handle_handshake_frame_error_(aerr); | ||||||
|  |     } | ||||||
|  |     // ignore contents, may be used in future for flags | ||||||
|  |     // Reserve space for: existing prologue + 2 size bytes + frame data | ||||||
|  |     prologue_.reserve(prologue_.size() + 2 + frame.size()); | ||||||
|  |     prologue_.push_back((uint8_t) (frame.size() >> 8)); | ||||||
|  |     prologue_.push_back((uint8_t) frame.size()); | ||||||
|  |     prologue_.insert(prologue_.end(), frame.begin(), frame.end()); | ||||||
|  |  | ||||||
|  |     state_ = State::SERVER_HELLO; | ||||||
|  |   } | ||||||
|  |   if (state_ == State::SERVER_HELLO) { | ||||||
|  |     // send server hello | ||||||
|  |     const std::string &name = App.get_name(); | ||||||
|  |     const std::string &mac = get_mac_address(); | ||||||
|  |  | ||||||
|  |     std::vector<uint8_t> msg; | ||||||
|  |     // Reserve space for: 1 byte proto + name + null + mac + null | ||||||
|  |     msg.reserve(1 + name.size() + 1 + mac.size() + 1); | ||||||
|  |  | ||||||
|  |     // chosen proto | ||||||
|  |     msg.push_back(0x01); | ||||||
|  |  | ||||||
|  |     // node name, terminated by null byte | ||||||
|  |     const uint8_t *name_ptr = reinterpret_cast<const uint8_t *>(name.c_str()); | ||||||
|  |     msg.insert(msg.end(), name_ptr, name_ptr + name.size() + 1); | ||||||
|  |     // node mac, terminated by null byte | ||||||
|  |     const uint8_t *mac_ptr = reinterpret_cast<const uint8_t *>(mac.c_str()); | ||||||
|  |     msg.insert(msg.end(), mac_ptr, mac_ptr + mac.size() + 1); | ||||||
|  |  | ||||||
|  |     aerr = write_frame_(msg.data(), msg.size()); | ||||||
|  |     if (aerr != APIError::OK) | ||||||
|  |       return aerr; | ||||||
|  |  | ||||||
|  |     // start handshake | ||||||
|  |     aerr = init_handshake_(); | ||||||
|  |     if (aerr != APIError::OK) | ||||||
|  |       return aerr; | ||||||
|  |  | ||||||
|  |     state_ = State::HANDSHAKE; | ||||||
|  |   } | ||||||
|  |   if (state_ == State::HANDSHAKE) { | ||||||
|  |     int action = noise_handshakestate_get_action(handshake_); | ||||||
|  |     if (action == NOISE_ACTION_READ_MESSAGE) { | ||||||
|  |       // waiting for handshake msg | ||||||
|  |       std::vector<uint8_t> frame; | ||||||
|  |       aerr = try_read_frame_(&frame); | ||||||
|  |       if (aerr != APIError::OK) { | ||||||
|  |         return handle_handshake_frame_error_(aerr); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (frame.empty()) { | ||||||
|  |         send_explicit_handshake_reject_("Empty handshake message"); | ||||||
|  |         return APIError::BAD_HANDSHAKE_ERROR_BYTE; | ||||||
|  |       } else if (frame[0] != 0x00) { | ||||||
|  |         HELPER_LOG("Bad handshake error byte: %u", frame[0]); | ||||||
|  |         send_explicit_handshake_reject_("Bad handshake error byte"); | ||||||
|  |         return APIError::BAD_HANDSHAKE_ERROR_BYTE; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       NoiseBuffer mbuf; | ||||||
|  |       noise_buffer_init(mbuf); | ||||||
|  |       noise_buffer_set_input(mbuf, frame.data() + 1, frame.size() - 1); | ||||||
|  |       err = noise_handshakestate_read_message(handshake_, &mbuf, nullptr); | ||||||
|  |       if (err != 0) { | ||||||
|  |         // Special handling for MAC failure | ||||||
|  |         send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? "Handshake MAC failure" : "Handshake error"); | ||||||
|  |         return handle_noise_error_(err, "noise_handshakestate_read_message", APIError::HANDSHAKESTATE_READ_FAILED); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       aerr = check_handshake_finished_(); | ||||||
|  |       if (aerr != APIError::OK) | ||||||
|  |         return aerr; | ||||||
|  |     } else if (action == NOISE_ACTION_WRITE_MESSAGE) { | ||||||
|  |       uint8_t buffer[65]; | ||||||
|  |       NoiseBuffer mbuf; | ||||||
|  |       noise_buffer_init(mbuf); | ||||||
|  |       noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); | ||||||
|  |  | ||||||
|  |       err = noise_handshakestate_write_message(handshake_, &mbuf, nullptr); | ||||||
|  |       APIError aerr_write = | ||||||
|  |           handle_noise_error_(err, "noise_handshakestate_write_message", APIError::HANDSHAKESTATE_WRITE_FAILED); | ||||||
|  |       if (aerr_write != APIError::OK) | ||||||
|  |         return aerr_write; | ||||||
|  |       buffer[0] = 0x00;  // success | ||||||
|  |  | ||||||
|  |       aerr = write_frame_(buffer, mbuf.size + 1); | ||||||
|  |       if (aerr != APIError::OK) | ||||||
|  |         return aerr; | ||||||
|  |       aerr = check_handshake_finished_(); | ||||||
|  |       if (aerr != APIError::OK) | ||||||
|  |         return aerr; | ||||||
|  |     } else { | ||||||
|  |       // bad state for action | ||||||
|  |       state_ = State::FAILED; | ||||||
|  |       HELPER_LOG("Bad action for handshake: %d", action); | ||||||
|  |       return APIError::HANDSHAKESTATE_BAD_STATE; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   if (state_ == State::CLOSED || state_ == State::FAILED) { | ||||||
|  |     return APIError::BAD_STATE; | ||||||
|  |   } | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  | void APINoiseFrameHelper::send_explicit_handshake_reject_(const std::string &reason) { | ||||||
|  |   std::vector<uint8_t> data; | ||||||
|  |   data.resize(reason.length() + 1); | ||||||
|  |   data[0] = 0x01;  // failure | ||||||
|  |  | ||||||
|  |   // Copy error message in bulk | ||||||
|  |   if (!reason.empty()) { | ||||||
|  |     std::memcpy(data.data() + 1, reason.c_str(), reason.length()); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // temporarily remove failed state | ||||||
|  |   auto orig_state = state_; | ||||||
|  |   state_ = State::EXPLICIT_REJECT; | ||||||
|  |   write_frame_(data.data(), data.size()); | ||||||
|  |   state_ = orig_state; | ||||||
|  | } | ||||||
|  | APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { | ||||||
|  |   int err; | ||||||
|  |   APIError aerr; | ||||||
|  |   aerr = state_action_(); | ||||||
|  |   if (aerr != APIError::OK) { | ||||||
|  |     return aerr; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (state_ != State::DATA) { | ||||||
|  |     return APIError::WOULD_BLOCK; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   std::vector<uint8_t> frame; | ||||||
|  |   aerr = try_read_frame_(&frame); | ||||||
|  |   if (aerr != APIError::OK) | ||||||
|  |     return aerr; | ||||||
|  |  | ||||||
|  |   NoiseBuffer mbuf; | ||||||
|  |   noise_buffer_init(mbuf); | ||||||
|  |   noise_buffer_set_inout(mbuf, frame.data(), frame.size(), frame.size()); | ||||||
|  |   err = noise_cipherstate_decrypt(recv_cipher_, &mbuf); | ||||||
|  |   APIError decrypt_err = handle_noise_error_(err, "noise_cipherstate_decrypt", APIError::CIPHERSTATE_DECRYPT_FAILED); | ||||||
|  |   if (decrypt_err != APIError::OK) | ||||||
|  |     return decrypt_err; | ||||||
|  |  | ||||||
|  |   uint16_t msg_size = mbuf.size; | ||||||
|  |   uint8_t *msg_data = frame.data(); | ||||||
|  |   if (msg_size < 4) { | ||||||
|  |     state_ = State::FAILED; | ||||||
|  |     HELPER_LOG("Bad data packet: size %d too short", msg_size); | ||||||
|  |     return APIError::BAD_DATA_PACKET; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   uint16_t type = (((uint16_t) msg_data[0]) << 8) | msg_data[1]; | ||||||
|  |   uint16_t data_len = (((uint16_t) msg_data[2]) << 8) | msg_data[3]; | ||||||
|  |   if (data_len > msg_size - 4) { | ||||||
|  |     state_ = State::FAILED; | ||||||
|  |     HELPER_LOG("Bad data packet: data_len %u greater than msg_size %u", data_len, msg_size); | ||||||
|  |     return APIError::BAD_DATA_PACKET; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   buffer->container = std::move(frame); | ||||||
|  |   buffer->data_offset = 4; | ||||||
|  |   buffer->data_len = data_len; | ||||||
|  |   buffer->type = type; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  | APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { | ||||||
|  |   // Resize to include MAC space (required for Noise encryption) | ||||||
|  |   buffer.get_buffer()->resize(buffer.get_buffer()->size() + frame_footer_size_); | ||||||
|  |   PacketInfo packet{type, 0, | ||||||
|  |                     static_cast<uint16_t>(buffer.get_buffer()->size() - frame_header_padding_ - frame_footer_size_)}; | ||||||
|  |   return write_protobuf_packets(buffer, std::span<const PacketInfo>(&packet, 1)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | APIError APINoiseFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer, std::span<const PacketInfo> packets) { | ||||||
|  |   APIError aerr = state_action_(); | ||||||
|  |   if (aerr != APIError::OK) { | ||||||
|  |     return aerr; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (state_ != State::DATA) { | ||||||
|  |     return APIError::WOULD_BLOCK; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (packets.empty()) { | ||||||
|  |     return APIError::OK; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   std::vector<uint8_t> *raw_buffer = buffer.get_buffer(); | ||||||
|  |   uint8_t *buffer_data = raw_buffer->data();  // Cache buffer pointer | ||||||
|  |  | ||||||
|  |   this->reusable_iovs_.clear(); | ||||||
|  |   this->reusable_iovs_.reserve(packets.size()); | ||||||
|  |   uint16_t total_write_len = 0; | ||||||
|  |  | ||||||
|  |   // We need to encrypt each packet in place | ||||||
|  |   for (const auto &packet : packets) { | ||||||
|  |     // The buffer already has padding at offset | ||||||
|  |     uint8_t *buf_start = buffer_data + packet.offset; | ||||||
|  |  | ||||||
|  |     // Write noise header | ||||||
|  |     buf_start[0] = 0x01;  // indicator | ||||||
|  |     // buf_start[1], buf_start[2] to be set after encryption | ||||||
|  |  | ||||||
|  |     // Write message header (to be encrypted) | ||||||
|  |     const uint8_t msg_offset = 3; | ||||||
|  |     buf_start[msg_offset] = static_cast<uint8_t>(packet.message_type >> 8);      // type high byte | ||||||
|  |     buf_start[msg_offset + 1] = static_cast<uint8_t>(packet.message_type);       // type low byte | ||||||
|  |     buf_start[msg_offset + 2] = static_cast<uint8_t>(packet.payload_size >> 8);  // data_len high byte | ||||||
|  |     buf_start[msg_offset + 3] = static_cast<uint8_t>(packet.payload_size);       // data_len low byte | ||||||
|  |     // payload data is already in the buffer starting at offset + 7 | ||||||
|  |  | ||||||
|  |     // Make sure we have space for MAC | ||||||
|  |     // The buffer should already have been sized appropriately | ||||||
|  |  | ||||||
|  |     // Encrypt the message in place | ||||||
|  |     NoiseBuffer mbuf; | ||||||
|  |     noise_buffer_init(mbuf); | ||||||
|  |     noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + packet.payload_size, | ||||||
|  |                            4 + packet.payload_size + frame_footer_size_); | ||||||
|  |  | ||||||
|  |     int err = noise_cipherstate_encrypt(send_cipher_, &mbuf); | ||||||
|  |     APIError aerr = handle_noise_error_(err, "noise_cipherstate_encrypt", APIError::CIPHERSTATE_ENCRYPT_FAILED); | ||||||
|  |     if (aerr != APIError::OK) | ||||||
|  |       return aerr; | ||||||
|  |  | ||||||
|  |     // Fill in the encrypted size | ||||||
|  |     buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8); | ||||||
|  |     buf_start[2] = static_cast<uint8_t>(mbuf.size); | ||||||
|  |  | ||||||
|  |     // Add iovec for this encrypted packet | ||||||
|  |     size_t packet_len = static_cast<size_t>(3 + mbuf.size);  // indicator + size + encrypted data | ||||||
|  |     this->reusable_iovs_.push_back({buf_start, packet_len}); | ||||||
|  |     total_write_len += packet_len; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Send all encrypted packets in one writev call | ||||||
|  |   return this->write_raw_(this->reusable_iovs_.data(), this->reusable_iovs_.size(), total_write_len); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { | ||||||
|  |   uint8_t header[3]; | ||||||
|  |   header[0] = 0x01;  // indicator | ||||||
|  |   header[1] = (uint8_t) (len >> 8); | ||||||
|  |   header[2] = (uint8_t) len; | ||||||
|  |  | ||||||
|  |   struct iovec iov[2]; | ||||||
|  |   iov[0].iov_base = header; | ||||||
|  |   iov[0].iov_len = 3; | ||||||
|  |   if (len == 0) { | ||||||
|  |     return this->write_raw_(iov, 1, 3);  // Just header | ||||||
|  |   } | ||||||
|  |   iov[1].iov_base = const_cast<uint8_t *>(data); | ||||||
|  |   iov[1].iov_len = len; | ||||||
|  |  | ||||||
|  |   return this->write_raw_(iov, 2, 3 + len);  // Header + data | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** Initiate the data structures for the handshake. | ||||||
|  |  * | ||||||
|  |  * @return 0 on success, -1 on error (check errno) | ||||||
|  |  */ | ||||||
|  | APIError APINoiseFrameHelper::init_handshake_() { | ||||||
|  |   int err; | ||||||
|  |   memset(&nid_, 0, sizeof(nid_)); | ||||||
|  |   // const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; | ||||||
|  |   // err = noise_protocol_name_to_id(&nid_, proto, strlen(proto)); | ||||||
|  |   nid_.pattern_id = NOISE_PATTERN_NN; | ||||||
|  |   nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY; | ||||||
|  |   nid_.dh_id = NOISE_DH_CURVE25519; | ||||||
|  |   nid_.prefix_id = NOISE_PREFIX_STANDARD; | ||||||
|  |   nid_.hybrid_id = NOISE_DH_NONE; | ||||||
|  |   nid_.hash_id = NOISE_HASH_SHA256; | ||||||
|  |   nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; | ||||||
|  |  | ||||||
|  |   err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); | ||||||
|  |   APIError aerr = handle_noise_error_(err, "noise_handshakestate_new_by_id", APIError::HANDSHAKESTATE_SETUP_FAILED); | ||||||
|  |   if (aerr != APIError::OK) | ||||||
|  |     return aerr; | ||||||
|  |  | ||||||
|  |   const auto &psk = ctx_->get_psk(); | ||||||
|  |   err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size()); | ||||||
|  |   aerr = handle_noise_error_(err, "noise_handshakestate_set_pre_shared_key", APIError::HANDSHAKESTATE_SETUP_FAILED); | ||||||
|  |   if (aerr != APIError::OK) | ||||||
|  |     return aerr; | ||||||
|  |  | ||||||
|  |   err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size()); | ||||||
|  |   aerr = handle_noise_error_(err, "noise_handshakestate_set_prologue", APIError::HANDSHAKESTATE_SETUP_FAILED); | ||||||
|  |   if (aerr != APIError::OK) | ||||||
|  |     return aerr; | ||||||
|  |   // set_prologue copies it into handshakestate, so we can get rid of it now | ||||||
|  |   prologue_ = {}; | ||||||
|  |  | ||||||
|  |   err = noise_handshakestate_start(handshake_); | ||||||
|  |   aerr = handle_noise_error_(err, "noise_handshakestate_start", APIError::HANDSHAKESTATE_SETUP_FAILED); | ||||||
|  |   if (aerr != APIError::OK) | ||||||
|  |     return aerr; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | APIError APINoiseFrameHelper::check_handshake_finished_() { | ||||||
|  |   assert(state_ == State::HANDSHAKE); | ||||||
|  |  | ||||||
|  |   int action = noise_handshakestate_get_action(handshake_); | ||||||
|  |   if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE) | ||||||
|  |     return APIError::OK; | ||||||
|  |   if (action != NOISE_ACTION_SPLIT) { | ||||||
|  |     state_ = State::FAILED; | ||||||
|  |     HELPER_LOG("Bad action for handshake: %d", action); | ||||||
|  |     return APIError::HANDSHAKESTATE_BAD_STATE; | ||||||
|  |   } | ||||||
|  |   int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_); | ||||||
|  |   APIError aerr = handle_noise_error_(err, "noise_handshakestate_split", APIError::HANDSHAKESTATE_SPLIT_FAILED); | ||||||
|  |   if (aerr != APIError::OK) | ||||||
|  |     return aerr; | ||||||
|  |  | ||||||
|  |   frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_); | ||||||
|  |  | ||||||
|  |   HELPER_LOG("Handshake complete!"); | ||||||
|  |   noise_handshakestate_free(handshake_); | ||||||
|  |   handshake_ = nullptr; | ||||||
|  |   state_ = State::DATA; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | APINoiseFrameHelper::~APINoiseFrameHelper() { | ||||||
|  |   if (handshake_ != nullptr) { | ||||||
|  |     noise_handshakestate_free(handshake_); | ||||||
|  |     handshake_ = nullptr; | ||||||
|  |   } | ||||||
|  |   if (send_cipher_ != nullptr) { | ||||||
|  |     noise_cipherstate_free(send_cipher_); | ||||||
|  |     send_cipher_ = nullptr; | ||||||
|  |   } | ||||||
|  |   if (recv_cipher_ != nullptr) { | ||||||
|  |     noise_cipherstate_free(recv_cipher_); | ||||||
|  |     recv_cipher_ = nullptr; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | extern "C" { | ||||||
|  | // declare how noise generates random bytes (here with a good HWRNG based on the RF system) | ||||||
|  | void noise_rand_bytes(void *output, size_t len) { | ||||||
|  |   if (!esphome::random_bytes(reinterpret_cast<uint8_t *>(output), len)) { | ||||||
|  |     ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting"); | ||||||
|  |     arch_restart(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | }  // namespace api | ||||||
|  | }  // namespace esphome | ||||||
|  | #endif  // USE_API_NOISE | ||||||
|  | #endif  // USE_API | ||||||
							
								
								
									
										70
									
								
								esphome/components/api/api_frame_helper_noise.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								esphome/components/api/api_frame_helper_noise.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | |||||||
|  | #pragma once | ||||||
|  | #include "api_frame_helper.h" | ||||||
|  | #ifdef USE_API | ||||||
|  | #ifdef USE_API_NOISE | ||||||
|  | #include "noise/protocol.h" | ||||||
|  | #include "api_noise_context.h" | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace api { | ||||||
|  |  | ||||||
|  | class APINoiseFrameHelper : public APIFrameHelper { | ||||||
|  |  public: | ||||||
|  |   APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, std::shared_ptr<APINoiseContext> ctx, | ||||||
|  |                       const ClientInfo *client_info) | ||||||
|  |       : APIFrameHelper(std::move(socket), client_info), ctx_(std::move(ctx)) { | ||||||
|  |     // Noise header structure: | ||||||
|  |     // Pos 0: indicator (0x01) | ||||||
|  |     // Pos 1-2: encrypted payload size (16-bit big-endian) | ||||||
|  |     // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) | ||||||
|  |     // Pos 7+: actual payload data | ||||||
|  |     frame_header_padding_ = 7; | ||||||
|  |   } | ||||||
|  |   ~APINoiseFrameHelper() override; | ||||||
|  |   APIError init() override; | ||||||
|  |   APIError loop() override; | ||||||
|  |   APIError read_packet(ReadPacketBuffer *buffer) override; | ||||||
|  |   APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; | ||||||
|  |   APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span<const PacketInfo> packets) override; | ||||||
|  |   // Get the frame header padding required by this protocol | ||||||
|  |   uint8_t frame_header_padding() override { return frame_header_padding_; } | ||||||
|  |   // Get the frame footer size required by this protocol | ||||||
|  |   uint8_t frame_footer_size() override { return frame_footer_size_; } | ||||||
|  |  | ||||||
|  |  protected: | ||||||
|  |   APIError state_action_(); | ||||||
|  |   APIError try_read_frame_(std::vector<uint8_t> *frame); | ||||||
|  |   APIError write_frame_(const uint8_t *data, uint16_t len); | ||||||
|  |   APIError init_handshake_(); | ||||||
|  |   APIError check_handshake_finished_(); | ||||||
|  |   void send_explicit_handshake_reject_(const std::string &reason); | ||||||
|  |   APIError handle_handshake_frame_error_(APIError aerr); | ||||||
|  |   APIError handle_noise_error_(int err, const char *func_name, APIError api_err); | ||||||
|  |  | ||||||
|  |   // Pointers first (4 bytes each) | ||||||
|  |   NoiseHandshakeState *handshake_{nullptr}; | ||||||
|  |   NoiseCipherState *send_cipher_{nullptr}; | ||||||
|  |   NoiseCipherState *recv_cipher_{nullptr}; | ||||||
|  |  | ||||||
|  |   // Shared pointer (8 bytes on 32-bit = 4 bytes control block pointer + 4 bytes object pointer) | ||||||
|  |   std::shared_ptr<APINoiseContext> ctx_; | ||||||
|  |  | ||||||
|  |   // Vector (12 bytes on 32-bit) | ||||||
|  |   std::vector<uint8_t> prologue_; | ||||||
|  |  | ||||||
|  |   // NoiseProtocolId (size depends on implementation) | ||||||
|  |   NoiseProtocolId nid_; | ||||||
|  |  | ||||||
|  |   // Group small types together | ||||||
|  |   // Fixed-size header buffer for noise protocol: | ||||||
|  |   // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) | ||||||
|  |   // Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase | ||||||
|  |   uint8_t rx_header_buf_[3]; | ||||||
|  |   uint8_t rx_header_buf_len_ = 0; | ||||||
|  |   // 4 bytes total, no padding | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | }  // namespace api | ||||||
|  | }  // namespace esphome | ||||||
|  | #endif  // USE_API_NOISE | ||||||
|  | #endif  // USE_API | ||||||
							
								
								
									
										292
									
								
								esphome/components/api/api_frame_helper_plaintext.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								esphome/components/api/api_frame_helper_plaintext.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,292 @@ | |||||||
|  | #include "api_frame_helper_plaintext.h" | ||||||
|  | #ifdef USE_API | ||||||
|  | #ifdef USE_API_PLAINTEXT | ||||||
|  | #include "api_connection.h"  // For ClientInfo struct | ||||||
|  | #include "esphome/core/application.h" | ||||||
|  | #include "esphome/core/hal.h" | ||||||
|  | #include "esphome/core/helpers.h" | ||||||
|  | #include "esphome/core/log.h" | ||||||
|  | #include "proto.h" | ||||||
|  | #include <cstring> | ||||||
|  | #include <cinttypes> | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace api { | ||||||
|  |  | ||||||
|  | static const char *const TAG = "api.plaintext"; | ||||||
|  |  | ||||||
|  | #define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->client_info_->get_combined_info().c_str(), ##__VA_ARGS__) | ||||||
|  |  | ||||||
|  | #ifdef HELPER_LOG_PACKETS | ||||||
|  | #define LOG_PACKET_RECEIVED(buffer) ESP_LOGVV(TAG, "Received frame: %s", format_hex_pretty(buffer).c_str()) | ||||||
|  | #define LOG_PACKET_SENDING(data, len) ESP_LOGVV(TAG, "Sending raw: %s", format_hex_pretty(data, len).c_str()) | ||||||
|  | #else | ||||||
|  | #define LOG_PACKET_RECEIVED(buffer) ((void) 0) | ||||||
|  | #define LOG_PACKET_SENDING(data, len) ((void) 0) | ||||||
|  | #endif | ||||||
|  |  | ||||||
|  | /// Initialize the frame helper, returns OK if successful. | ||||||
|  | APIError APIPlaintextFrameHelper::init() { | ||||||
|  |   APIError err = init_common_(); | ||||||
|  |   if (err != APIError::OK) { | ||||||
|  |     return err; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   state_ = State::DATA; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  | APIError APIPlaintextFrameHelper::loop() { | ||||||
|  |   if (state_ != State::DATA) { | ||||||
|  |     return APIError::BAD_STATE; | ||||||
|  |   } | ||||||
|  |   // Use base class implementation for buffer sending | ||||||
|  |   return APIFrameHelper::loop(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** Read a packet into the rx_buf_. If successful, stores frame data in the frame parameter | ||||||
|  |  * | ||||||
|  |  * @param frame: The struct to hold the frame information in. | ||||||
|  |  *   msg: store the parsed frame in that struct | ||||||
|  |  * | ||||||
|  |  * @return See APIError | ||||||
|  |  * | ||||||
|  |  * error API_ERROR_BAD_INDICATOR: Bad indicator byte at start of frame. | ||||||
|  |  */ | ||||||
|  | APIError APIPlaintextFrameHelper::try_read_frame_(std::vector<uint8_t> *frame) { | ||||||
|  |   if (frame == nullptr) { | ||||||
|  |     HELPER_LOG("Bad argument for try_read_frame_"); | ||||||
|  |     return APIError::BAD_ARG; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // read header | ||||||
|  |   while (!rx_header_parsed_) { | ||||||
|  |     // Now that we know when the socket is ready, we can read up to 3 bytes | ||||||
|  |     // into the rx_header_buf_ before we have to switch back to reading | ||||||
|  |     // one byte at a time to ensure we don't read past the message and | ||||||
|  |     // into the next one. | ||||||
|  |  | ||||||
|  |     // Read directly into rx_header_buf_ at the current position | ||||||
|  |     // Try to get to at least 3 bytes total (indicator + 2 varint bytes), then read one byte at a time | ||||||
|  |     ssize_t received = | ||||||
|  |         this->socket_->read(&rx_header_buf_[rx_header_buf_pos_], rx_header_buf_pos_ < 3 ? 3 - rx_header_buf_pos_ : 1); | ||||||
|  |     APIError err = handle_socket_read_result_(received); | ||||||
|  |     if (err != APIError::OK) { | ||||||
|  |       return err; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // If this was the first read, validate the indicator byte | ||||||
|  |     if (rx_header_buf_pos_ == 0 && received > 0) { | ||||||
|  |       if (rx_header_buf_[0] != 0x00) { | ||||||
|  |         state_ = State::FAILED; | ||||||
|  |         HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); | ||||||
|  |         return APIError::BAD_INDICATOR; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     rx_header_buf_pos_ += received; | ||||||
|  |  | ||||||
|  |     // Check for buffer overflow | ||||||
|  |     if (rx_header_buf_pos_ >= sizeof(rx_header_buf_)) { | ||||||
|  |       state_ = State::FAILED; | ||||||
|  |       HELPER_LOG("Header buffer overflow"); | ||||||
|  |       return APIError::BAD_DATA_PACKET; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Need at least 3 bytes total (indicator + 2 varint bytes) before trying to parse | ||||||
|  |     if (rx_header_buf_pos_ < 3) { | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // At this point, we have at least 3 bytes total: | ||||||
|  |     //   - Validated indicator byte (0x00) stored at position 0 | ||||||
|  |     //   - At least 2 bytes in the buffer for the varints | ||||||
|  |     // Buffer layout: | ||||||
|  |     //   [0]: indicator byte (0x00) | ||||||
|  |     //   [1-3]: Message size varint (variable length) | ||||||
|  |     //     - 2 bytes would only allow up to 16383, which is less than noise's UINT16_MAX (65535) | ||||||
|  |     //     - 3 bytes allows up to 2097151, ensuring we support at least as much as noise | ||||||
|  |     //   [2-5]: Message type varint (variable length) | ||||||
|  |     // We now attempt to parse both varints. If either is incomplete, | ||||||
|  |     // we'll continue reading more bytes. | ||||||
|  |  | ||||||
|  |     // Skip indicator byte at position 0 | ||||||
|  |     uint8_t varint_pos = 1; | ||||||
|  |     uint32_t consumed = 0; | ||||||
|  |  | ||||||
|  |     auto msg_size_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed); | ||||||
|  |     if (!msg_size_varint.has_value()) { | ||||||
|  |       // not enough data there yet | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (msg_size_varint->as_uint32() > std::numeric_limits<uint16_t>::max()) { | ||||||
|  |       state_ = State::FAILED; | ||||||
|  |       HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), | ||||||
|  |                  std::numeric_limits<uint16_t>::max()); | ||||||
|  |       return APIError::BAD_DATA_PACKET; | ||||||
|  |     } | ||||||
|  |     rx_header_parsed_len_ = msg_size_varint->as_uint16(); | ||||||
|  |  | ||||||
|  |     // Move to next varint position | ||||||
|  |     varint_pos += consumed; | ||||||
|  |  | ||||||
|  |     auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[varint_pos], rx_header_buf_pos_ - varint_pos, &consumed); | ||||||
|  |     if (!msg_type_varint.has_value()) { | ||||||
|  |       // not enough data there yet | ||||||
|  |       continue; | ||||||
|  |     } | ||||||
|  |     if (msg_type_varint->as_uint32() > std::numeric_limits<uint16_t>::max()) { | ||||||
|  |       state_ = State::FAILED; | ||||||
|  |       HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum %u", msg_type_varint->as_uint32(), | ||||||
|  |                  std::numeric_limits<uint16_t>::max()); | ||||||
|  |       return APIError::BAD_DATA_PACKET; | ||||||
|  |     } | ||||||
|  |     rx_header_parsed_type_ = msg_type_varint->as_uint16(); | ||||||
|  |     rx_header_parsed_ = true; | ||||||
|  |   } | ||||||
|  |   // header reading done | ||||||
|  |  | ||||||
|  |   // reserve space for body | ||||||
|  |   if (rx_buf_.size() != rx_header_parsed_len_) { | ||||||
|  |     rx_buf_.resize(rx_header_parsed_len_); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (rx_buf_len_ < rx_header_parsed_len_) { | ||||||
|  |     // more data to read | ||||||
|  |     uint16_t to_read = rx_header_parsed_len_ - rx_buf_len_; | ||||||
|  |     ssize_t received = this->socket_->read(&rx_buf_[rx_buf_len_], to_read); | ||||||
|  |     APIError err = handle_socket_read_result_(received); | ||||||
|  |     if (err != APIError::OK) { | ||||||
|  |       return err; | ||||||
|  |     } | ||||||
|  |     rx_buf_len_ += static_cast<uint16_t>(received); | ||||||
|  |     if (static_cast<uint16_t>(received) != to_read) { | ||||||
|  |       // not all read | ||||||
|  |       return APIError::WOULD_BLOCK; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   LOG_PACKET_RECEIVED(rx_buf_); | ||||||
|  |   *frame = std::move(rx_buf_); | ||||||
|  |   // consume msg | ||||||
|  |   rx_buf_ = {}; | ||||||
|  |   rx_buf_len_ = 0; | ||||||
|  |   rx_header_buf_pos_ = 0; | ||||||
|  |   rx_header_parsed_ = false; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  | APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { | ||||||
|  |   APIError aerr; | ||||||
|  |  | ||||||
|  |   if (state_ != State::DATA) { | ||||||
|  |     return APIError::WOULD_BLOCK; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   std::vector<uint8_t> frame; | ||||||
|  |   aerr = try_read_frame_(&frame); | ||||||
|  |   if (aerr != APIError::OK) { | ||||||
|  |     if (aerr == APIError::BAD_INDICATOR) { | ||||||
|  |       // Make sure to tell the remote that we don't | ||||||
|  |       // understand the indicator byte so it knows | ||||||
|  |       // we do not support it. | ||||||
|  |       struct iovec iov[1]; | ||||||
|  |       // The \x00 first byte is the marker for plaintext. | ||||||
|  |       // | ||||||
|  |       // The remote will know how to handle the indicator byte, | ||||||
|  |       // but it likely won't understand the rest of the message. | ||||||
|  |       // | ||||||
|  |       // We must send at least 3 bytes to be read, so we add | ||||||
|  |       // a message after the indicator byte to ensures its long | ||||||
|  |       // enough and can aid in debugging. | ||||||
|  |       const char msg[] = "\x00" | ||||||
|  |                          "Bad indicator byte"; | ||||||
|  |       iov[0].iov_base = (void *) msg; | ||||||
|  |       iov[0].iov_len = 19; | ||||||
|  |       this->write_raw_(iov, 1, 19); | ||||||
|  |     } | ||||||
|  |     return aerr; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   buffer->container = std::move(frame); | ||||||
|  |   buffer->data_offset = 0; | ||||||
|  |   buffer->data_len = rx_header_parsed_len_; | ||||||
|  |   buffer->type = rx_header_parsed_type_; | ||||||
|  |   return APIError::OK; | ||||||
|  | } | ||||||
|  | APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { | ||||||
|  |   PacketInfo packet{type, 0, static_cast<uint16_t>(buffer.get_buffer()->size() - frame_header_padding_)}; | ||||||
|  |   return write_protobuf_packets(buffer, std::span<const PacketInfo>(&packet, 1)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | APIError APIPlaintextFrameHelper::write_protobuf_packets(ProtoWriteBuffer buffer, std::span<const PacketInfo> packets) { | ||||||
|  |   if (state_ != State::DATA) { | ||||||
|  |     return APIError::BAD_STATE; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (packets.empty()) { | ||||||
|  |     return APIError::OK; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   std::vector<uint8_t> *raw_buffer = buffer.get_buffer(); | ||||||
|  |   uint8_t *buffer_data = raw_buffer->data();  // Cache buffer pointer | ||||||
|  |  | ||||||
|  |   this->reusable_iovs_.clear(); | ||||||
|  |   this->reusable_iovs_.reserve(packets.size()); | ||||||
|  |   uint16_t total_write_len = 0; | ||||||
|  |  | ||||||
|  |   for (const auto &packet : packets) { | ||||||
|  |     // Calculate varint sizes for header layout | ||||||
|  |     uint8_t size_varint_len = api::ProtoSize::varint(static_cast<uint32_t>(packet.payload_size)); | ||||||
|  |     uint8_t type_varint_len = api::ProtoSize::varint(static_cast<uint32_t>(packet.message_type)); | ||||||
|  |     uint8_t total_header_len = 1 + size_varint_len + type_varint_len; | ||||||
|  |  | ||||||
|  |     // Calculate where to start writing the header | ||||||
|  |     // The header starts at the latest possible position to minimize unused padding | ||||||
|  |     // | ||||||
|  |     // Example 1 (small values): total_header_len = 3, header_offset = 6 - 3 = 3 | ||||||
|  |     // [0-2]  - Unused padding | ||||||
|  |     // [3]    - 0x00 indicator byte | ||||||
|  |     // [4]    - Payload size varint (1 byte, for sizes 0-127) | ||||||
|  |     // [5]    - Message type varint (1 byte, for types 0-127) | ||||||
|  |     // [6...] - Actual payload data | ||||||
|  |     // | ||||||
|  |     // Example 2 (medium values): total_header_len = 4, header_offset = 6 - 4 = 2 | ||||||
|  |     // [0-1]  - Unused padding | ||||||
|  |     // [2]    - 0x00 indicator byte | ||||||
|  |     // [3-4]  - Payload size varint (2 bytes, for sizes 128-16383) | ||||||
|  |     // [5]    - Message type varint (1 byte, for types 0-127) | ||||||
|  |     // [6...] - Actual payload data | ||||||
|  |     // | ||||||
|  |     // Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0 | ||||||
|  |     // [0]    - 0x00 indicator byte | ||||||
|  |     // [1-3]  - Payload size varint (3 bytes, for sizes 16384-2097151) | ||||||
|  |     // [4-5]  - Message type varint (2 bytes, for types 128-32767) | ||||||
|  |     // [6...] - Actual payload data | ||||||
|  |     // | ||||||
|  |     // The message starts at offset + frame_header_padding_ | ||||||
|  |     // So we write the header starting at offset + frame_header_padding_ - total_header_len | ||||||
|  |     uint8_t *buf_start = buffer_data + packet.offset; | ||||||
|  |     uint32_t header_offset = frame_header_padding_ - total_header_len; | ||||||
|  |  | ||||||
|  |     // Write the plaintext header | ||||||
|  |     buf_start[header_offset] = 0x00;  // indicator | ||||||
|  |  | ||||||
|  |     // Encode varints directly into buffer | ||||||
|  |     ProtoVarInt(packet.payload_size).encode_to_buffer_unchecked(buf_start + header_offset + 1, size_varint_len); | ||||||
|  |     ProtoVarInt(packet.message_type) | ||||||
|  |         .encode_to_buffer_unchecked(buf_start + header_offset + 1 + size_varint_len, type_varint_len); | ||||||
|  |  | ||||||
|  |     // Add iovec for this packet (header + payload) | ||||||
|  |     size_t packet_len = static_cast<size_t>(total_header_len + packet.payload_size); | ||||||
|  |     this->reusable_iovs_.push_back({buf_start + header_offset, packet_len}); | ||||||
|  |     total_write_len += packet_len; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Send all packets in one writev call | ||||||
|  |   return write_raw_(this->reusable_iovs_.data(), this->reusable_iovs_.size(), total_write_len); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | }  // namespace api | ||||||
|  | }  // namespace esphome | ||||||
|  | #endif  // USE_API_PLAINTEXT | ||||||
|  | #endif  // USE_API | ||||||
							
								
								
									
										55
									
								
								esphome/components/api/api_frame_helper_plaintext.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								esphome/components/api/api_frame_helper_plaintext.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | #pragma once | ||||||
|  | #include "api_frame_helper.h" | ||||||
|  | #ifdef USE_API | ||||||
|  | #ifdef USE_API_PLAINTEXT | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace api { | ||||||
|  |  | ||||||
|  | class APIPlaintextFrameHelper : public APIFrameHelper { | ||||||
|  |  public: | ||||||
|  |   APIPlaintextFrameHelper(std::unique_ptr<socket::Socket> socket, const ClientInfo *client_info) | ||||||
|  |       : APIFrameHelper(std::move(socket), client_info) { | ||||||
|  |     // Plaintext header structure (worst case): | ||||||
|  |     // Pos 0: indicator (0x00) | ||||||
|  |     // Pos 1-3: payload size varint (up to 3 bytes) | ||||||
|  |     // Pos 4-5: message type varint (up to 2 bytes) | ||||||
|  |     // Pos 6+: actual payload data | ||||||
|  |     frame_header_padding_ = 6; | ||||||
|  |   } | ||||||
|  |   ~APIPlaintextFrameHelper() override = default; | ||||||
|  |   APIError init() override; | ||||||
|  |   APIError loop() override; | ||||||
|  |   APIError read_packet(ReadPacketBuffer *buffer) override; | ||||||
|  |   APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; | ||||||
|  |   APIError write_protobuf_packets(ProtoWriteBuffer buffer, std::span<const PacketInfo> packets) override; | ||||||
|  |   uint8_t frame_header_padding() override { return frame_header_padding_; } | ||||||
|  |   // Get the frame footer size required by this protocol | ||||||
|  |   uint8_t frame_footer_size() override { return frame_footer_size_; } | ||||||
|  |  | ||||||
|  |  protected: | ||||||
|  |   APIError try_read_frame_(std::vector<uint8_t> *frame); | ||||||
|  |  | ||||||
|  |   // Group 2-byte aligned types | ||||||
|  |   uint16_t rx_header_parsed_type_ = 0; | ||||||
|  |   uint16_t rx_header_parsed_len_ = 0; | ||||||
|  |  | ||||||
|  |   // Group 1-byte types together | ||||||
|  |   // Fixed-size header buffer for plaintext protocol: | ||||||
|  |   // We now store the indicator byte + the two varints. | ||||||
|  |   // To match noise protocol's maximum message size (UINT16_MAX = 65535), we need: | ||||||
|  |   // 1 byte for indicator + 3 bytes for message size varint (supports up to 2097151) + 2 bytes for message type varint | ||||||
|  |   // | ||||||
|  |   // While varints could theoretically be up to 10 bytes each for 64-bit values, | ||||||
|  |   // attempting to process messages with headers that large would likely crash the | ||||||
|  |   // ESP32 due to memory constraints. | ||||||
|  |   uint8_t rx_header_buf_[6];  // 1 byte indicator + 5 bytes for varints (3 for size + 2 for type) | ||||||
|  |   uint8_t rx_header_buf_pos_ = 0; | ||||||
|  |   bool rx_header_parsed_ = false; | ||||||
|  |   // 8 bytes total, no padding needed | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | }  // namespace api | ||||||
|  | }  // namespace esphome | ||||||
|  | #endif  // USE_API_PLAINTEXT | ||||||
|  | #endif  // USE_API | ||||||
| @@ -23,3 +23,8 @@ extend google.protobuf.MessageOptions { | |||||||
|     optional bool no_delay = 1040 [default=false]; |     optional bool no_delay = 1040 [default=false]; | ||||||
|     optional string base_class = 1041; |     optional string base_class = 1041; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | extend google.protobuf.FieldOptions { | ||||||
|  |     optional string field_ifdef = 1042; | ||||||
|  |     optional uint32 fixed_array_size = 50007; | ||||||
|  | } | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -195,6 +195,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, | |||||||
|       this->on_home_assistant_state_response(msg); |       this->on_home_assistant_state_response(msg); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|     case 42: { |     case 42: { | ||||||
|       ExecuteServiceRequest msg; |       ExecuteServiceRequest msg; | ||||||
|       msg.decode(msg_data, msg_size); |       msg.decode(msg_data, msg_size); | ||||||
| @@ -204,6 +205,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, | |||||||
|       this->on_execute_service_request(msg); |       this->on_execute_service_request(msg); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|  | #endif | ||||||
| #ifdef USE_CAMERA | #ifdef USE_CAMERA | ||||||
|     case 45: { |     case 45: { | ||||||
|       CameraImageRequest msg; |       CameraImageRequest msg; | ||||||
| @@ -596,32 +598,32 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, | |||||||
|  |  | ||||||
| void APIServerConnection::on_hello_request(const HelloRequest &msg) { | void APIServerConnection::on_hello_request(const HelloRequest &msg) { | ||||||
|   HelloResponse ret = this->hello(msg); |   HelloResponse ret = this->hello(msg); | ||||||
|   if (!this->send_message(ret)) { |   if (!this->send_message(ret, HelloResponse::MESSAGE_TYPE)) { | ||||||
|     this->on_fatal_error(); |     this->on_fatal_error(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| void APIServerConnection::on_connect_request(const ConnectRequest &msg) { | void APIServerConnection::on_connect_request(const ConnectRequest &msg) { | ||||||
|   ConnectResponse ret = this->connect(msg); |   ConnectResponse ret = this->connect(msg); | ||||||
|   if (!this->send_message(ret)) { |   if (!this->send_message(ret, ConnectResponse::MESSAGE_TYPE)) { | ||||||
|     this->on_fatal_error(); |     this->on_fatal_error(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) { | void APIServerConnection::on_disconnect_request(const DisconnectRequest &msg) { | ||||||
|   DisconnectResponse ret = this->disconnect(msg); |   DisconnectResponse ret = this->disconnect(msg); | ||||||
|   if (!this->send_message(ret)) { |   if (!this->send_message(ret, DisconnectResponse::MESSAGE_TYPE)) { | ||||||
|     this->on_fatal_error(); |     this->on_fatal_error(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| void APIServerConnection::on_ping_request(const PingRequest &msg) { | void APIServerConnection::on_ping_request(const PingRequest &msg) { | ||||||
|   PingResponse ret = this->ping(msg); |   PingResponse ret = this->ping(msg); | ||||||
|   if (!this->send_message(ret)) { |   if (!this->send_message(ret, PingResponse::MESSAGE_TYPE)) { | ||||||
|     this->on_fatal_error(); |     this->on_fatal_error(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) { | void APIServerConnection::on_device_info_request(const DeviceInfoRequest &msg) { | ||||||
|   if (this->check_connection_setup_()) { |   if (this->check_connection_setup_()) { | ||||||
|     DeviceInfoResponse ret = this->device_info(msg); |     DeviceInfoResponse ret = this->device_info(msg); | ||||||
|     if (!this->send_message(ret)) { |     if (!this->send_message(ret, DeviceInfoResponse::MESSAGE_TYPE)) { | ||||||
|       this->on_fatal_error(); |       this->on_fatal_error(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -655,21 +657,23 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc | |||||||
| void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { | void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { | ||||||
|   if (this->check_connection_setup_()) { |   if (this->check_connection_setup_()) { | ||||||
|     GetTimeResponse ret = this->get_time(msg); |     GetTimeResponse ret = this->get_time(msg); | ||||||
|     if (!this->send_message(ret)) { |     if (!this->send_message(ret, GetTimeResponse::MESSAGE_TYPE)) { | ||||||
|       this->on_fatal_error(); |       this->on_fatal_error(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
| void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { | void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { | ||||||
|   if (this->check_authenticated_()) { |   if (this->check_authenticated_()) { | ||||||
|     this->execute_service(msg); |     this->execute_service(msg); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | #endif | ||||||
| #ifdef USE_API_NOISE | #ifdef USE_API_NOISE | ||||||
| void APIServerConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) { | void APIServerConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) { | ||||||
|   if (this->check_authenticated_()) { |   if (this->check_authenticated_()) { | ||||||
|     NoiseEncryptionSetKeyResponse ret = this->noise_encryption_set_key(msg); |     NoiseEncryptionSetKeyResponse ret = this->noise_encryption_set_key(msg); | ||||||
|     if (!this->send_message(ret)) { |     if (!this->send_message(ret, NoiseEncryptionSetKeyResponse::MESSAGE_TYPE)) { | ||||||
|       this->on_fatal_error(); |       this->on_fatal_error(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -863,7 +867,7 @@ void APIServerConnection::on_subscribe_bluetooth_connections_free_request( | |||||||
|     const SubscribeBluetoothConnectionsFreeRequest &msg) { |     const SubscribeBluetoothConnectionsFreeRequest &msg) { | ||||||
|   if (this->check_authenticated_()) { |   if (this->check_authenticated_()) { | ||||||
|     BluetoothConnectionsFreeResponse ret = this->subscribe_bluetooth_connections_free(msg); |     BluetoothConnectionsFreeResponse ret = this->subscribe_bluetooth_connections_free(msg); | ||||||
|     if (!this->send_message(ret)) { |     if (!this->send_message(ret, BluetoothConnectionsFreeResponse::MESSAGE_TYPE)) { | ||||||
|       this->on_fatal_error(); |       this->on_fatal_error(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -895,7 +899,7 @@ void APIServerConnection::on_subscribe_voice_assistant_request(const SubscribeVo | |||||||
| void APIServerConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) { | void APIServerConnection::on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) { | ||||||
|   if (this->check_authenticated_()) { |   if (this->check_authenticated_()) { | ||||||
|     VoiceAssistantConfigurationResponse ret = this->voice_assistant_get_configuration(msg); |     VoiceAssistantConfigurationResponse ret = this->voice_assistant_get_configuration(msg); | ||||||
|     if (!this->send_message(ret)) { |     if (!this->send_message(ret, VoiceAssistantConfigurationResponse::MESSAGE_TYPE)) { | ||||||
|       this->on_fatal_error(); |       this->on_fatal_error(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -18,11 +18,11 @@ class APIServerConnectionBase : public ProtoService { | |||||||
|  public: |  public: | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
|   template<typename T> bool send_message(const T &msg) { |   bool send_message(const ProtoMessage &msg, uint8_t message_type) { | ||||||
| #ifdef HAS_PROTO_MESSAGE_DUMP | #ifdef HAS_PROTO_MESSAGE_DUMP | ||||||
|     this->log_send_message_(msg.message_name(), msg.dump()); |     this->log_send_message_(msg.message_name(), msg.dump()); | ||||||
| #endif | #endif | ||||||
|     return this->send_message_(msg, T::MESSAGE_TYPE); |     return this->send_message_(msg, message_type); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   virtual void on_hello_request(const HelloRequest &value){}; |   virtual void on_hello_request(const HelloRequest &value){}; | ||||||
| @@ -69,7 +69,9 @@ class APIServerConnectionBase : public ProtoService { | |||||||
|   virtual void on_get_time_request(const GetTimeRequest &value){}; |   virtual void on_get_time_request(const GetTimeRequest &value){}; | ||||||
|   virtual void on_get_time_response(const GetTimeResponse &value){}; |   virtual void on_get_time_response(const GetTimeResponse &value){}; | ||||||
|  |  | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|   virtual void on_execute_service_request(const ExecuteServiceRequest &value){}; |   virtual void on_execute_service_request(const ExecuteServiceRequest &value){}; | ||||||
|  | #endif | ||||||
|  |  | ||||||
| #ifdef USE_CAMERA | #ifdef USE_CAMERA | ||||||
|   virtual void on_camera_image_request(const CameraImageRequest &value){}; |   virtual void on_camera_image_request(const CameraImageRequest &value){}; | ||||||
| @@ -216,7 +218,9 @@ class APIServerConnection : public APIServerConnectionBase { | |||||||
|   virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; |   virtual void subscribe_homeassistant_services(const SubscribeHomeassistantServicesRequest &msg) = 0; | ||||||
|   virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; |   virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; | ||||||
|   virtual GetTimeResponse get_time(const GetTimeRequest &msg) = 0; |   virtual GetTimeResponse get_time(const GetTimeRequest &msg) = 0; | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|   virtual void execute_service(const ExecuteServiceRequest &msg) = 0; |   virtual void execute_service(const ExecuteServiceRequest &msg) = 0; | ||||||
|  | #endif | ||||||
| #ifdef USE_API_NOISE | #ifdef USE_API_NOISE | ||||||
|   virtual NoiseEncryptionSetKeyResponse noise_encryption_set_key(const NoiseEncryptionSetKeyRequest &msg) = 0; |   virtual NoiseEncryptionSetKeyResponse noise_encryption_set_key(const NoiseEncryptionSetKeyRequest &msg) = 0; | ||||||
| #endif | #endif | ||||||
| @@ -333,7 +337,9 @@ class APIServerConnection : public APIServerConnectionBase { | |||||||
|   void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override; |   void on_subscribe_homeassistant_services_request(const SubscribeHomeassistantServicesRequest &msg) override; | ||||||
|   void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; |   void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; | ||||||
|   void on_get_time_request(const GetTimeRequest &msg) override; |   void on_get_time_request(const GetTimeRequest &msg) override; | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|   void on_execute_service_request(const ExecuteServiceRequest &msg) override; |   void on_execute_service_request(const ExecuteServiceRequest &msg) override; | ||||||
|  | #endif | ||||||
| #ifdef USE_API_NOISE | #ifdef USE_API_NOISE | ||||||
|   void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) override; |   void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) override; | ||||||
| #endif | #endif | ||||||
|   | |||||||
| @@ -1,359 +0,0 @@ | |||||||
| #pragma once |  | ||||||
|  |  | ||||||
| #include "proto.h" |  | ||||||
| #include <cstdint> |  | ||||||
| #include <string> |  | ||||||
|  |  | ||||||
| namespace esphome { |  | ||||||
| namespace api { |  | ||||||
|  |  | ||||||
| class ProtoSize { |  | ||||||
|  public: |  | ||||||
|   /** |  | ||||||
|    * @brief ProtoSize class for Protocol Buffer serialization size calculation |  | ||||||
|    * |  | ||||||
|    * This class provides static methods to calculate the exact byte counts needed |  | ||||||
|    * for encoding various Protocol Buffer field types. All methods are designed to be |  | ||||||
|    * efficient for the common case where many fields have default values. |  | ||||||
|    * |  | ||||||
|    * Implements Protocol Buffer encoding size calculation according to: |  | ||||||
|    * https://protobuf.dev/programming-guides/encoding/ |  | ||||||
|    * |  | ||||||
|    * Key features: |  | ||||||
|    * - Early-return optimization for zero/default values |  | ||||||
|    * - Direct total_size updates to avoid unnecessary additions |  | ||||||
|    * - Specialized handling for different field types according to protobuf spec |  | ||||||
|    * - Templated helpers for repeated fields and messages |  | ||||||
|    */ |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint |  | ||||||
|    * |  | ||||||
|    * @param value The uint32_t value to calculate size for |  | ||||||
|    * @return The number of bytes needed to encode the value |  | ||||||
|    */ |  | ||||||
|   static inline uint32_t varint(uint32_t value) { |  | ||||||
|     // Optimized varint size calculation using leading zeros |  | ||||||
|     // Each 7 bits requires one byte in the varint encoding |  | ||||||
|     if (value < 128) |  | ||||||
|       return 1;  // 7 bits, common case for small values |  | ||||||
|  |  | ||||||
|     // For larger values, count bytes needed based on the position of the highest bit set |  | ||||||
|     if (value < 16384) { |  | ||||||
|       return 2;  // 14 bits |  | ||||||
|     } else if (value < 2097152) { |  | ||||||
|       return 3;  // 21 bits |  | ||||||
|     } else if (value < 268435456) { |  | ||||||
|       return 4;  // 28 bits |  | ||||||
|     } else { |  | ||||||
|       return 5;  // 32 bits (maximum for uint32_t) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates the size in bytes needed to encode a uint64_t value as a varint |  | ||||||
|    * |  | ||||||
|    * @param value The uint64_t value to calculate size for |  | ||||||
|    * @return The number of bytes needed to encode the value |  | ||||||
|    */ |  | ||||||
|   static inline uint32_t varint(uint64_t value) { |  | ||||||
|     // Handle common case of values fitting in uint32_t (vast majority of use cases) |  | ||||||
|     if (value <= UINT32_MAX) { |  | ||||||
|       return varint(static_cast<uint32_t>(value)); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // For larger values, determine size based on highest bit position |  | ||||||
|     if (value < (1ULL << 35)) { |  | ||||||
|       return 5;  // 35 bits |  | ||||||
|     } else if (value < (1ULL << 42)) { |  | ||||||
|       return 6;  // 42 bits |  | ||||||
|     } else if (value < (1ULL << 49)) { |  | ||||||
|       return 7;  // 49 bits |  | ||||||
|     } else if (value < (1ULL << 56)) { |  | ||||||
|       return 8;  // 56 bits |  | ||||||
|     } else if (value < (1ULL << 63)) { |  | ||||||
|       return 9;  // 63 bits |  | ||||||
|     } else { |  | ||||||
|       return 10;  // 64 bits (maximum for uint64_t) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates the size in bytes needed to encode an int32_t value as a varint |  | ||||||
|    * |  | ||||||
|    * Special handling is needed for negative values, which are sign-extended to 64 bits |  | ||||||
|    * in Protocol Buffers, resulting in a 10-byte varint. |  | ||||||
|    * |  | ||||||
|    * @param value The int32_t value to calculate size for |  | ||||||
|    * @return The number of bytes needed to encode the value |  | ||||||
|    */ |  | ||||||
|   static inline uint32_t varint(int32_t value) { |  | ||||||
|     // Negative values are sign-extended to 64 bits in protocol buffers, |  | ||||||
|     // which always results in a 10-byte varint for negative int32 |  | ||||||
|     if (value < 0) { |  | ||||||
|       return 10;  // Negative int32 is always 10 bytes long |  | ||||||
|     } |  | ||||||
|     // For non-negative values, use the uint32_t implementation |  | ||||||
|     return varint(static_cast<uint32_t>(value)); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates the size in bytes needed to encode an int64_t value as a varint |  | ||||||
|    * |  | ||||||
|    * @param value The int64_t value to calculate size for |  | ||||||
|    * @return The number of bytes needed to encode the value |  | ||||||
|    */ |  | ||||||
|   static inline uint32_t varint(int64_t value) { |  | ||||||
|     // For int64_t, we convert to uint64_t and calculate the size |  | ||||||
|     // This works because the bit pattern determines the encoding size, |  | ||||||
|     // and we've handled negative int32 values as a special case above |  | ||||||
|     return varint(static_cast<uint64_t>(value)); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates the size in bytes needed to encode a field ID and wire type |  | ||||||
|    * |  | ||||||
|    * @param field_id The field identifier |  | ||||||
|    * @param type The wire type value (from the WireType enum in the protobuf spec) |  | ||||||
|    * @return The number of bytes needed to encode the field ID and wire type |  | ||||||
|    */ |  | ||||||
|   static inline uint32_t field(uint32_t field_id, uint32_t type) { |  | ||||||
|     uint32_t tag = (field_id << 3) | (type & 0b111); |  | ||||||
|     return varint(tag); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Common parameters for all add_*_field methods |  | ||||||
|    * |  | ||||||
|    * All add_*_field methods follow these common patterns: |  | ||||||
|    * |  | ||||||
|    * @param total_size Reference to the total message size to update |  | ||||||
|    * @param field_id_size Pre-calculated size of the field ID in bytes |  | ||||||
|    * @param value The value to calculate size for (type varies) |  | ||||||
|    * @param force Whether to calculate size even if the value is default/zero/empty |  | ||||||
|    * |  | ||||||
|    * Each method follows this implementation pattern: |  | ||||||
|    * 1. Skip calculation if value is default (0, false, empty) and not forced |  | ||||||
|    * 2. Calculate the size based on the field's encoding rules |  | ||||||
|    * 3. Add the field_id_size + calculated value size to total_size |  | ||||||
|    */ |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of an int32 field to the total message size |  | ||||||
|    */ |  | ||||||
|   static inline void add_int32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value, bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (value == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Calculate and directly add to total_size |  | ||||||
|     if (value < 0) { |  | ||||||
|       // Negative values are encoded as 10-byte varints in protobuf |  | ||||||
|       total_size += field_id_size + 10; |  | ||||||
|     } else { |  | ||||||
|       // For non-negative values, use the standard varint size |  | ||||||
|       total_size += field_id_size + varint(static_cast<uint32_t>(value)); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a uint32 field to the total message size |  | ||||||
|    */ |  | ||||||
|   static inline void add_uint32_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value, |  | ||||||
|                                       bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (value == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Calculate and directly add to total_size |  | ||||||
|     total_size += field_id_size + varint(value); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a boolean field to the total message size |  | ||||||
|    */ |  | ||||||
|   static inline void add_bool_field(uint32_t &total_size, uint32_t field_id_size, bool value, bool force = false) { |  | ||||||
|     // Skip calculation if value is false and not forced |  | ||||||
|     if (!value && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Boolean fields always use 1 byte when true |  | ||||||
|     total_size += field_id_size + 1; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a fixed field to the total message size |  | ||||||
|    * |  | ||||||
|    * Fixed fields always take exactly N bytes (4 for fixed32/float, 8 for fixed64/double). |  | ||||||
|    * |  | ||||||
|    * @tparam NumBytes The number of bytes for this fixed field (4 or 8) |  | ||||||
|    * @param is_nonzero Whether the value is non-zero |  | ||||||
|    */ |  | ||||||
|   template<uint32_t NumBytes> |  | ||||||
|   static inline void add_fixed_field(uint32_t &total_size, uint32_t field_id_size, bool is_nonzero, |  | ||||||
|                                      bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (!is_nonzero && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Fixed fields always take exactly NumBytes |  | ||||||
|     total_size += field_id_size + NumBytes; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of an enum field to the total message size |  | ||||||
|    * |  | ||||||
|    * Enum fields are encoded as uint32 varints. |  | ||||||
|    */ |  | ||||||
|   static inline void add_enum_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value, bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (value == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Enums are encoded as uint32 |  | ||||||
|     total_size += field_id_size + varint(value); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a sint32 field to the total message size |  | ||||||
|    * |  | ||||||
|    * Sint32 fields use ZigZag encoding, which is more efficient for negative values. |  | ||||||
|    */ |  | ||||||
|   static inline void add_sint32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value, bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (value == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) |  | ||||||
|     uint32_t zigzag = (static_cast<uint32_t>(value) << 1) ^ (static_cast<uint32_t>(value >> 31)); |  | ||||||
|     total_size += field_id_size + varint(zigzag); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of an int64 field to the total message size |  | ||||||
|    */ |  | ||||||
|   static inline void add_int64_field(uint32_t &total_size, uint32_t field_id_size, int64_t value, bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (value == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Calculate and directly add to total_size |  | ||||||
|     total_size += field_id_size + varint(value); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a uint64 field to the total message size |  | ||||||
|    */ |  | ||||||
|   static inline void add_uint64_field(uint32_t &total_size, uint32_t field_id_size, uint64_t value, |  | ||||||
|                                       bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (value == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Calculate and directly add to total_size |  | ||||||
|     total_size += field_id_size + varint(value); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a sint64 field to the total message size |  | ||||||
|    * |  | ||||||
|    * Sint64 fields use ZigZag encoding, which is more efficient for negative values. |  | ||||||
|    */ |  | ||||||
|   static inline void add_sint64_field(uint32_t &total_size, uint32_t field_id_size, int64_t value, bool force = false) { |  | ||||||
|     // Skip calculation if value is zero and not forced |  | ||||||
|     if (value == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // ZigZag encoding for sint64: (n << 1) ^ (n >> 63) |  | ||||||
|     uint64_t zigzag = (static_cast<uint64_t>(value) << 1) ^ (static_cast<uint64_t>(value >> 63)); |  | ||||||
|     total_size += field_id_size + varint(zigzag); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a string/bytes field to the total message size |  | ||||||
|    */ |  | ||||||
|   static inline void add_string_field(uint32_t &total_size, uint32_t field_id_size, const std::string &str, |  | ||||||
|                                       bool force = false) { |  | ||||||
|     // Skip calculation if string is empty and not forced |  | ||||||
|     if (str.empty() && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Calculate and directly add to total_size |  | ||||||
|     const uint32_t str_size = static_cast<uint32_t>(str.size()); |  | ||||||
|     total_size += field_id_size + varint(str_size) + str_size; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a nested message field to the total message size |  | ||||||
|    * |  | ||||||
|    * This helper function directly updates the total_size reference if the nested size |  | ||||||
|    * is greater than zero or force is true. |  | ||||||
|    * |  | ||||||
|    * @param nested_size The pre-calculated size of the nested message |  | ||||||
|    */ |  | ||||||
|   static inline void add_message_field(uint32_t &total_size, uint32_t field_id_size, uint32_t nested_size, |  | ||||||
|                                        bool force = false) { |  | ||||||
|     // Skip calculation if nested message is empty and not forced |  | ||||||
|     if (nested_size == 0 && !force) { |  | ||||||
|       return;  // No need to update total_size |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // Calculate and directly add to total_size |  | ||||||
|     // Field ID + length varint + nested message content |  | ||||||
|     total_size += field_id_size + varint(nested_size) + nested_size; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the size of a nested message field to the total message size |  | ||||||
|    * |  | ||||||
|    * This version takes a ProtoMessage object, calculates its size internally, |  | ||||||
|    * and updates the total_size reference. This eliminates the need for a temporary variable |  | ||||||
|    * at the call site. |  | ||||||
|    * |  | ||||||
|    * @param message The nested message object |  | ||||||
|    */ |  | ||||||
|   static inline void add_message_object(uint32_t &total_size, uint32_t field_id_size, const ProtoMessage &message, |  | ||||||
|                                         bool force = false) { |  | ||||||
|     uint32_t nested_size = 0; |  | ||||||
|     message.calculate_size(nested_size); |  | ||||||
|  |  | ||||||
|     // Use the base implementation with the calculated nested_size |  | ||||||
|     add_message_field(total_size, field_id_size, nested_size, force); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * @brief Calculates and adds the sizes of all messages in a repeated field to the total message size |  | ||||||
|    * |  | ||||||
|    * This helper processes a vector of message objects, calculating the size for each message |  | ||||||
|    * and adding it to the total size. |  | ||||||
|    * |  | ||||||
|    * @tparam MessageType The type of the nested messages in the vector |  | ||||||
|    * @param messages Vector of message objects |  | ||||||
|    */ |  | ||||||
|   template<typename MessageType> |  | ||||||
|   static inline void add_repeated_message(uint32_t &total_size, uint32_t field_id_size, |  | ||||||
|                                           const std::vector<MessageType> &messages) { |  | ||||||
|     // Skip if the vector is empty |  | ||||||
|     if (messages.empty()) { |  | ||||||
|       return; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     // For repeated fields, always use force=true |  | ||||||
|     for (const auto &message : messages) { |  | ||||||
|       add_message_object(total_size, field_id_size, message, true); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| }  // namespace api |  | ||||||
| }  // namespace esphome |  | ||||||
| @@ -24,14 +24,6 @@ static const char *const TAG = "api"; | |||||||
| // APIServer | // APIServer | ||||||
| APIServer *global_api_server = nullptr;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) | APIServer *global_api_server = nullptr;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) | ||||||
|  |  | ||||||
| #ifndef USE_API_YAML_SERVICES |  | ||||||
| // Global empty vector to avoid guard variables (saves 8 bytes) |  | ||||||
| // This is initialized at program startup before any threads |  | ||||||
| static const std::vector<UserServiceDescriptor *> empty_user_services{}; |  | ||||||
|  |  | ||||||
| const std::vector<UserServiceDescriptor *> &get_empty_user_services_instance() { return empty_user_services; } |  | ||||||
| #endif |  | ||||||
|  |  | ||||||
| APIServer::APIServer() { | APIServer::APIServer() { | ||||||
|   global_api_server = this; |   global_api_server = this; | ||||||
|   // Pre-allocate shared write buffer |   // Pre-allocate shared write buffer | ||||||
| @@ -39,7 +31,6 @@ APIServer::APIServer() { | |||||||
| } | } | ||||||
|  |  | ||||||
| void APIServer::setup() { | void APIServer::setup() { | ||||||
|   ESP_LOGCONFIG(TAG, "Running setup"); |  | ||||||
|   this->setup_controller(); |   this->setup_controller(); | ||||||
|  |  | ||||||
| #ifdef USE_API_NOISE | #ifdef USE_API_NOISE | ||||||
| @@ -113,7 +104,7 @@ void APIServer::setup() { | |||||||
|             return; |             return; | ||||||
|           } |           } | ||||||
|           for (auto &c : this->clients_) { |           for (auto &c : this->clients_) { | ||||||
|             if (!c->flags_.remove) |             if (!c->flags_.remove && c->get_log_subscription_level() >= level) | ||||||
|               c->try_send_log_message(level, tag, message, message_len); |               c->try_send_log_message(level, tag, message, message_len); | ||||||
|           } |           } | ||||||
|         }); |         }); | ||||||
| @@ -193,9 +184,9 @@ void APIServer::loop() { | |||||||
|  |  | ||||||
|     // Rare case: handle disconnection |     // Rare case: handle disconnection | ||||||
| #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER | #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER | ||||||
|     this->client_disconnected_trigger_->trigger(client->client_info_, client->client_peername_); |     this->client_disconnected_trigger_->trigger(client->client_info_.name, client->client_info_.peername); | ||||||
| #endif | #endif | ||||||
|     ESP_LOGV(TAG, "Remove connection %s", client->client_info_.c_str()); |     ESP_LOGV(TAG, "Remove connection %s", client->client_info_.name.c_str()); | ||||||
|  |  | ||||||
|     // Swap with the last element and pop (avoids expensive vector shifts) |     // Swap with the last element and pop (avoids expensive vector shifts) | ||||||
|     if (client_index < this->clients_.size() - 1) { |     if (client_index < this->clients_.size() - 1) { | ||||||
| @@ -213,22 +204,20 @@ void APIServer::loop() { | |||||||
|  |  | ||||||
| void APIServer::dump_config() { | void APIServer::dump_config() { | ||||||
|   ESP_LOGCONFIG(TAG, |   ESP_LOGCONFIG(TAG, | ||||||
|                 "API Server:\n" |                 "Server:\n" | ||||||
|                 "  Address: %s:%u", |                 "  Address: %s:%u", | ||||||
|                 network::get_use_address().c_str(), this->port_); |                 network::get_use_address().c_str(), this->port_); | ||||||
| #ifdef USE_API_NOISE | #ifdef USE_API_NOISE | ||||||
|   ESP_LOGCONFIG(TAG, "  Using noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); |   ESP_LOGCONFIG(TAG, "  Noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); | ||||||
|   if (!this->noise_ctx_->has_psk()) { |   if (!this->noise_ctx_->has_psk()) { | ||||||
|     ESP_LOGCONFIG(TAG, "  Supports noise encryption: YES"); |     ESP_LOGCONFIG(TAG, "  Supports encryption: YES"); | ||||||
|   } |   } | ||||||
| #else | #else | ||||||
|   ESP_LOGCONFIG(TAG, "  Using noise encryption: NO"); |   ESP_LOGCONFIG(TAG, "  Noise encryption: NO"); | ||||||
| #endif | #endif | ||||||
| } | } | ||||||
|  |  | ||||||
| #ifdef USE_API_PASSWORD | #ifdef USE_API_PASSWORD | ||||||
| bool APIServer::uses_password() const { return !this->password_.empty(); } |  | ||||||
|  |  | ||||||
| bool APIServer::check_password(const std::string &password) const { | bool APIServer::check_password(const std::string &password) const { | ||||||
|   // depend only on input password length |   // depend only on input password length | ||||||
|   const char *a = this->password_.c_str(); |   const char *a = this->password_.c_str(); | ||||||
| @@ -436,10 +425,11 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { | |||||||
|   ESP_LOGD(TAG, "Noise PSK saved"); |   ESP_LOGD(TAG, "Noise PSK saved"); | ||||||
|   if (make_active) { |   if (make_active) { | ||||||
|     this->set_timeout(100, [this, psk]() { |     this->set_timeout(100, [this, psk]() { | ||||||
|       ESP_LOGW(TAG, "Disconnecting all clients to reset connections"); |       ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); | ||||||
|       this->set_noise_psk(psk); |       this->set_noise_psk(psk); | ||||||
|       for (auto &c : this->clients_) { |       for (auto &c : this->clients_) { | ||||||
|         c->send_message(DisconnectRequest()); |         DisconnectRequest req; | ||||||
|  |         c->send_message(req, DisconnectRequest::MESSAGE_TYPE); | ||||||
|       } |       } | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| @@ -472,10 +462,12 @@ void APIServer::on_shutdown() { | |||||||
|  |  | ||||||
|   // Send disconnect requests to all connected clients |   // Send disconnect requests to all connected clients | ||||||
|   for (auto &c : this->clients_) { |   for (auto &c : this->clients_) { | ||||||
|     if (!c->send_message(DisconnectRequest())) { |     DisconnectRequest req; | ||||||
|  |     if (!c->send_message(req, DisconnectRequest::MESSAGE_TYPE)) { | ||||||
|       // If we can't send the disconnect request directly (tx_buffer full), |       // 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 |       // 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); |       c->schedule_message_front_(nullptr, &APIConnection::try_send_disconnect_request, DisconnectRequest::MESSAGE_TYPE, | ||||||
|  |                                  DisconnectRequest::ESTIMATED_SIZE); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -12,7 +12,9 @@ | |||||||
| #include "esphome/core/log.h" | #include "esphome/core/log.h" | ||||||
| #include "list_entities.h" | #include "list_entities.h" | ||||||
| #include "subscribe_state.h" | #include "subscribe_state.h" | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
| #include "user_services.h" | #include "user_services.h" | ||||||
|  | #endif | ||||||
|  |  | ||||||
| #include <vector> | #include <vector> | ||||||
|  |  | ||||||
| @@ -25,11 +27,6 @@ struct SavedNoisePsk { | |||||||
| } PACKED;  // NOLINT | } PACKED;  // NOLINT | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
| #ifndef USE_API_YAML_SERVICES |  | ||||||
| // Forward declaration of helper function |  | ||||||
| const std::vector<UserServiceDescriptor *> &get_empty_user_services_instance(); |  | ||||||
| #endif |  | ||||||
|  |  | ||||||
| class APIServer : public Component, public Controller { | class APIServer : public Component, public Controller { | ||||||
|  public: |  public: | ||||||
|   APIServer(); |   APIServer(); | ||||||
| @@ -42,7 +39,6 @@ class APIServer : public Component, public Controller { | |||||||
|   bool teardown() override; |   bool teardown() override; | ||||||
| #ifdef USE_API_PASSWORD | #ifdef USE_API_PASSWORD | ||||||
|   bool check_password(const std::string &password) const; |   bool check_password(const std::string &password) const; | ||||||
|   bool uses_password() const; |  | ||||||
|   void set_password(const std::string &password); |   void set_password(const std::string &password); | ||||||
| #endif | #endif | ||||||
|   void set_port(uint16_t port); |   void set_port(uint16_t port); | ||||||
| @@ -112,18 +108,9 @@ class APIServer : public Component, public Controller { | |||||||
|   void on_media_player_update(media_player::MediaPlayer *obj) override; |   void on_media_player_update(media_player::MediaPlayer *obj) override; | ||||||
| #endif | #endif | ||||||
|   void send_homeassistant_service_call(const HomeassistantServiceResponse &call); |   void send_homeassistant_service_call(const HomeassistantServiceResponse &call); | ||||||
|   void register_user_service(UserServiceDescriptor *descriptor) { | #ifdef USE_API_SERVICES | ||||||
| #ifdef USE_API_YAML_SERVICES |   void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } | ||||||
|     // Vector is pre-allocated when services are defined in YAML |  | ||||||
|     this->user_services_.push_back(descriptor); |  | ||||||
| #else |  | ||||||
|     // Lazy allocate vector on first use for CustomAPIDevice |  | ||||||
|     if (!this->user_services_) { |  | ||||||
|       this->user_services_ = std::make_unique<std::vector<UserServiceDescriptor *>>(); |  | ||||||
|     } |  | ||||||
|     this->user_services_->push_back(descriptor); |  | ||||||
| #endif | #endif | ||||||
|   } |  | ||||||
| #ifdef USE_HOMEASSISTANT_TIME | #ifdef USE_HOMEASSISTANT_TIME | ||||||
|   void request_time(); |   void request_time(); | ||||||
| #endif | #endif | ||||||
| @@ -152,17 +139,9 @@ class APIServer : public Component, public Controller { | |||||||
|   void get_home_assistant_state(std::string entity_id, optional<std::string> attribute, |   void get_home_assistant_state(std::string entity_id, optional<std::string> attribute, | ||||||
|                                 std::function<void(std::string)> f); |                                 std::function<void(std::string)> f); | ||||||
|   const std::vector<HomeAssistantStateSubscription> &get_state_subs() const; |   const std::vector<HomeAssistantStateSubscription> &get_state_subs() const; | ||||||
|   const std::vector<UserServiceDescriptor *> &get_user_services() const { | #ifdef USE_API_SERVICES | ||||||
| #ifdef USE_API_YAML_SERVICES |   const std::vector<UserServiceDescriptor *> &get_user_services() const { return this->user_services_; } | ||||||
|     return this->user_services_; |  | ||||||
| #else |  | ||||||
|     if (this->user_services_) { |  | ||||||
|       return *this->user_services_; |  | ||||||
|     } |  | ||||||
|     // Return reference to global empty instance (no guard needed) |  | ||||||
|     return get_empty_user_services_instance(); |  | ||||||
| #endif | #endif | ||||||
|   } |  | ||||||
|  |  | ||||||
| #ifdef USE_API_CLIENT_CONNECTED_TRIGGER | #ifdef USE_API_CLIENT_CONNECTED_TRIGGER | ||||||
|   Trigger<std::string, std::string> *get_client_connected_trigger() const { return this->client_connected_trigger_; } |   Trigger<std::string, std::string> *get_client_connected_trigger() const { return this->client_connected_trigger_; } | ||||||
| @@ -194,14 +173,8 @@ class APIServer : public Component, public Controller { | |||||||
| #endif | #endif | ||||||
|   std::vector<uint8_t> shared_write_buffer_;  // Shared proto write buffer for all connections |   std::vector<uint8_t> shared_write_buffer_;  // Shared proto write buffer for all connections | ||||||
|   std::vector<HomeAssistantStateSubscription> state_subs_; |   std::vector<HomeAssistantStateSubscription> state_subs_; | ||||||
| #ifdef USE_API_YAML_SERVICES | #ifdef USE_API_SERVICES | ||||||
|   // When services are defined in YAML, we know at compile time that services will be registered |  | ||||||
|   std::vector<UserServiceDescriptor *> user_services_; |   std::vector<UserServiceDescriptor *> user_services_; | ||||||
| #else |  | ||||||
|   // Services can still be registered at runtime by CustomAPIDevice components even when not |  | ||||||
|   // defined in YAML. Using unique_ptr allows lazy allocation, saving 12 bytes in the common |  | ||||||
|   // case where no services (YAML or custom) are used. |  | ||||||
|   std::unique_ptr<std::vector<UserServiceDescriptor *>> user_services_; |  | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
|   // Group smaller types together |   // Group smaller types together | ||||||
|   | |||||||
| @@ -3,10 +3,13 @@ | |||||||
| #include <map> | #include <map> | ||||||
| #include "api_server.h" | #include "api_server.h" | ||||||
| #ifdef USE_API | #ifdef USE_API | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
| #include "user_services.h" | #include "user_services.h" | ||||||
|  | #endif | ||||||
| namespace esphome { | namespace esphome { | ||||||
| namespace api { | namespace api { | ||||||
|  |  | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
| template<typename T, typename... Ts> class CustomAPIDeviceService : public UserServiceBase<Ts...> { | template<typename T, typename... Ts> class CustomAPIDeviceService : public UserServiceBase<Ts...> { | ||||||
|  public: |  public: | ||||||
|   CustomAPIDeviceService(const std::string &name, const std::array<std::string, sizeof...(Ts)> &arg_names, T *obj, |   CustomAPIDeviceService(const std::string &name, const std::array<std::string, sizeof...(Ts)> &arg_names, T *obj, | ||||||
| @@ -19,6 +22,7 @@ template<typename T, typename... Ts> class CustomAPIDeviceService : public UserS | |||||||
|   T *obj_; |   T *obj_; | ||||||
|   void (T::*callback_)(Ts...); |   void (T::*callback_)(Ts...); | ||||||
| }; | }; | ||||||
|  | #endif  // USE_API_SERVICES | ||||||
|  |  | ||||||
| class CustomAPIDevice { | class CustomAPIDevice { | ||||||
|  public: |  public: | ||||||
| @@ -46,12 +50,14 @@ class CustomAPIDevice { | |||||||
|    * @param name The name of the service to register. |    * @param name The name of the service to register. | ||||||
|    * @param arg_names The name of the arguments for the service, must match the arguments of the function. |    * @param arg_names The name of the arguments for the service, must match the arguments of the function. | ||||||
|    */ |    */ | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|   template<typename T, typename... Ts> |   template<typename T, typename... Ts> | ||||||
|   void register_service(void (T::*callback)(Ts...), const std::string &name, |   void register_service(void (T::*callback)(Ts...), const std::string &name, | ||||||
|                         const std::array<std::string, sizeof...(Ts)> &arg_names) { |                         const std::array<std::string, sizeof...(Ts)> &arg_names) { | ||||||
|     auto *service = new CustomAPIDeviceService<T, Ts...>(name, arg_names, (T *) this, callback);  // NOLINT |     auto *service = new CustomAPIDeviceService<T, Ts...>(name, arg_names, (T *) this, callback);  // NOLINT | ||||||
|     global_api_server->register_user_service(service); |     global_api_server->register_user_service(service); | ||||||
|   } |   } | ||||||
|  | #endif | ||||||
|  |  | ||||||
|   /** Register a custom native API service that will show up in Home Assistant. |   /** Register a custom native API service that will show up in Home Assistant. | ||||||
|    * |    * | ||||||
| @@ -71,10 +77,12 @@ class CustomAPIDevice { | |||||||
|    * @param callback The member function to call when the service is triggered. |    * @param callback The member function to call when the service is triggered. | ||||||
|    * @param name The name of the arguments for the service, must match the arguments of the function. |    * @param name The name of the arguments for the service, must match the arguments of the function. | ||||||
|    */ |    */ | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|   template<typename T> void register_service(void (T::*callback)(), const std::string &name) { |   template<typename T> void register_service(void (T::*callback)(), const std::string &name) { | ||||||
|     auto *service = new CustomAPIDeviceService<T>(name, {}, (T *) this, callback);  // NOLINT |     auto *service = new CustomAPIDeviceService<T>(name, {}, (T *) this, callback);  // NOLINT | ||||||
|     global_api_server->register_user_service(service); |     global_api_server->register_user_service(service); | ||||||
|   } |   } | ||||||
|  | #endif | ||||||
|  |  | ||||||
|   /** Subscribe to the state (or attribute state) of an entity from Home Assistant. |   /** Subscribe to the state (or attribute state) of an entity from Home Assistant. | ||||||
|    * |    * | ||||||
|   | |||||||
| @@ -11,6 +11,18 @@ namespace esphome { | |||||||
| namespace api { | namespace api { | ||||||
|  |  | ||||||
| template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> { | template<typename... X> class TemplatableStringValue : public TemplatableValue<std::string, X...> { | ||||||
|  |  private: | ||||||
|  |   // Helper to convert value to string - handles the case where value is already a string | ||||||
|  |   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); } | ||||||
|  |  | ||||||
|  public: |  public: | ||||||
|   TemplatableStringValue() : TemplatableValue<std::string, X...>() {} |   TemplatableStringValue() : TemplatableValue<std::string, X...>() {} | ||||||
|  |  | ||||||
| @@ -19,7 +31,7 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s | |||||||
|  |  | ||||||
|   template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0> |   template<typename F, enable_if_t<is_invocable<F, X...>::value, int> = 0> | ||||||
|   TemplatableStringValue(F f) |   TemplatableStringValue(F f) | ||||||
|       : TemplatableValue<std::string, X...>([f](X... x) -> std::string { return to_string(f(x...)); }) {} |       : TemplatableValue<std::string, X...>([f](X... x) -> std::string { return value_to_string(f(x...)); }) {} | ||||||
| }; | }; | ||||||
|  |  | ||||||
| template<typename... Ts> class TemplatableKeyValuePair { | template<typename... Ts> class TemplatableKeyValuePair { | ||||||
|   | |||||||
| @@ -83,10 +83,12 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done( | |||||||
|  |  | ||||||
| ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {} | ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {} | ||||||
|  |  | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
| bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { | bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { | ||||||
|   auto resp = service->encode_list_service_response(); |   auto resp = service->encode_list_service_response(); | ||||||
|   return this->client_->send_message(resp); |   return this->client_->send_message(resp, ListEntitiesServicesResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
|  | #endif | ||||||
|  |  | ||||||
| }  // namespace api | }  // namespace api | ||||||
| }  // namespace esphome | }  // namespace esphome | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ class APIConnection; | |||||||
| #define LIST_ENTITIES_HANDLER(entity_type, EntityClass, ResponseType) \ | #define LIST_ENTITIES_HANDLER(entity_type, EntityClass, ResponseType) \ | ||||||
|   bool ListEntitiesIterator::on_##entity_type(EntityClass *entity) { /* NOLINT(bugprone-macro-parentheses) */ \ |   bool ListEntitiesIterator::on_##entity_type(EntityClass *entity) { /* NOLINT(bugprone-macro-parentheses) */ \ | ||||||
|     return this->client_->schedule_message_(entity, &APIConnection::try_send_##entity_type##_info, \ |     return this->client_->schedule_message_(entity, &APIConnection::try_send_##entity_type##_info, \ | ||||||
|                                             ResponseType::MESSAGE_TYPE); \ |                                             ResponseType::MESSAGE_TYPE, ResponseType::ESTIMATED_SIZE); \ | ||||||
|   } |   } | ||||||
|  |  | ||||||
| class ListEntitiesIterator : public ComponentIterator { | class ListEntitiesIterator : public ComponentIterator { | ||||||
| @@ -44,7 +44,9 @@ class ListEntitiesIterator : public ComponentIterator { | |||||||
| #ifdef USE_TEXT_SENSOR | #ifdef USE_TEXT_SENSOR | ||||||
|   bool on_text_sensor(text_sensor::TextSensor *entity) override; |   bool on_text_sensor(text_sensor::TextSensor *entity) override; | ||||||
| #endif | #endif | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
|   bool on_service(UserServiceDescriptor *service) override; |   bool on_service(UserServiceDescriptor *service) override; | ||||||
|  | #endif | ||||||
| #ifdef USE_CAMERA | #ifdef USE_CAMERA | ||||||
|   bool on_camera(camera::Camera *entity) override; |   bool on_camera(camera::Camera *entity) override; | ||||||
| #endif | #endif | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ namespace api { | |||||||
|  |  | ||||||
| static const char *const TAG = "api.proto"; | static const char *const TAG = "api.proto"; | ||||||
|  |  | ||||||
| void ProtoMessage::decode(const uint8_t *buffer, size_t length) { | void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { | ||||||
|   uint32_t i = 0; |   uint32_t i = 0; | ||||||
|   bool error = false; |   bool error = false; | ||||||
|   while (i < length) { |   while (i < length) { | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ | |||||||
| #include "esphome/core/helpers.h" | #include "esphome/core/helpers.h" | ||||||
| #include "esphome/core/log.h" | #include "esphome/core/log.h" | ||||||
|  |  | ||||||
|  | #include <cassert> | ||||||
| #include <vector> | #include <vector> | ||||||
|  |  | ||||||
| #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE | #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE | ||||||
| @@ -59,7 +60,6 @@ class ProtoVarInt { | |||||||
|   uint32_t as_uint32() const { return this->value_; } |   uint32_t as_uint32() const { return this->value_; } | ||||||
|   uint64_t as_uint64() const { return this->value_; } |   uint64_t as_uint64() const { return this->value_; } | ||||||
|   bool as_bool() const { return this->value_; } |   bool as_bool() const { return this->value_; } | ||||||
|   template<typename T> T as_enum() const { return static_cast<T>(this->as_uint32()); } |  | ||||||
|   int32_t as_int32() const { |   int32_t as_int32() const { | ||||||
|     // Not ZigZag encoded |     // Not ZigZag encoded | ||||||
|     return static_cast<int32_t>(this->as_int64()); |     return static_cast<int32_t>(this->as_int64()); | ||||||
| @@ -133,15 +133,25 @@ class ProtoVarInt { | |||||||
|   uint64_t value_; |   uint64_t value_; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | // Forward declaration for decode_to_message and encode_to_writer | ||||||
|  | class ProtoMessage; | ||||||
|  | class ProtoDecodableMessage; | ||||||
|  |  | ||||||
| class ProtoLengthDelimited { | class ProtoLengthDelimited { | ||||||
|  public: |  public: | ||||||
|   explicit ProtoLengthDelimited(const uint8_t *value, size_t length) : value_(value), length_(length) {} |   explicit ProtoLengthDelimited(const uint8_t *value, size_t length) : value_(value), length_(length) {} | ||||||
|   std::string as_string() const { return std::string(reinterpret_cast<const char *>(this->value_), this->length_); } |   std::string as_string() const { return std::string(reinterpret_cast<const char *>(this->value_), this->length_); } | ||||||
|   template<class C> C as_message() const { |  | ||||||
|     auto msg = C(); |   /** | ||||||
|     msg.decode(this->value_, this->length_); |    * Decode the length-delimited data into an existing ProtoDecodableMessage instance. | ||||||
|     return msg; |    * | ||||||
|   } |    * This method allows decoding without templates, enabling use in contexts | ||||||
|  |    * where the message type is not known at compile time. The ProtoDecodableMessage's | ||||||
|  |    * decode() method will be called with the raw data and length. | ||||||
|  |    * | ||||||
|  |    * @param msg The ProtoDecodableMessage instance to decode into | ||||||
|  |    */ | ||||||
|  |   void decode_to_message(ProtoDecodableMessage &msg) const; | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   const uint8_t *const value_; |   const uint8_t *const value_; | ||||||
| @@ -166,23 +176,7 @@ class Proto32Bit { | |||||||
|   const uint32_t value_; |   const uint32_t value_; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| class Proto64Bit { | // NOTE: Proto64Bit class removed - wire type 1 (64-bit fixed) not supported | ||||||
|  public: |  | ||||||
|   explicit Proto64Bit(uint64_t value) : value_(value) {} |  | ||||||
|   uint64_t as_fixed64() const { return this->value_; } |  | ||||||
|   int64_t as_sfixed64() const { return static_cast<int64_t>(this->value_); } |  | ||||||
|   double as_double() const { |  | ||||||
|     union { |  | ||||||
|       uint64_t raw; |  | ||||||
|       double value; |  | ||||||
|     } s{}; |  | ||||||
|     s.raw = this->value_; |  | ||||||
|     return s.value; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|  protected: |  | ||||||
|   const uint64_t value_; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| class ProtoWriteBuffer { | class ProtoWriteBuffer { | ||||||
|  public: |  public: | ||||||
| @@ -196,9 +190,9 @@ class ProtoWriteBuffer { | |||||||
|    * @param field_id Field number (tag) in the protobuf message |    * @param field_id Field number (tag) in the protobuf message | ||||||
|    * @param type Wire type value: |    * @param type Wire type value: | ||||||
|    *   - 0: Varint (int32, int64, uint32, uint64, sint32, sint64, bool, enum) |    *   - 0: Varint (int32, int64, uint32, uint64, sint32, sint64, bool, enum) | ||||||
|    *   - 1: 64-bit (fixed64, sfixed64, double) |  | ||||||
|    *   - 2: Length-delimited (string, bytes, embedded messages, packed repeated fields) |    *   - 2: Length-delimited (string, bytes, embedded messages, packed repeated fields) | ||||||
|    *   - 5: 32-bit (fixed32, sfixed32, float) |    *   - 5: 32-bit (fixed32, sfixed32, float) | ||||||
|  |    *   - Note: Wire type 1 (64-bit fixed) is not supported | ||||||
|    * |    * | ||||||
|    * Following https://protobuf.dev/programming-guides/encoding/#structure |    * Following https://protobuf.dev/programming-guides/encoding/#structure | ||||||
|    */ |    */ | ||||||
| @@ -249,23 +243,10 @@ class ProtoWriteBuffer { | |||||||
|     this->write((value >> 16) & 0xFF); |     this->write((value >> 16) & 0xFF); | ||||||
|     this->write((value >> 24) & 0xFF); |     this->write((value >> 24) & 0xFF); | ||||||
|   } |   } | ||||||
|   void encode_fixed64(uint32_t field_id, uint64_t value, bool force = false) { |   // NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally | ||||||
|     if (value == 0 && !force) |   // not supported to reduce overhead on embedded systems. All ESPHome devices are | ||||||
|       return; |   // 32-bit microcontrollers where 64-bit operations are expensive. If 64-bit support | ||||||
|  |   // is needed in the future, the necessary encoding/decoding functions must be added. | ||||||
|     this->encode_field_raw(field_id, 1);  // type 1: 64-bit fixed64 |  | ||||||
|     this->write((value >> 0) & 0xFF); |  | ||||||
|     this->write((value >> 8) & 0xFF); |  | ||||||
|     this->write((value >> 16) & 0xFF); |  | ||||||
|     this->write((value >> 24) & 0xFF); |  | ||||||
|     this->write((value >> 32) & 0xFF); |  | ||||||
|     this->write((value >> 40) & 0xFF); |  | ||||||
|     this->write((value >> 48) & 0xFF); |  | ||||||
|     this->write((value >> 56) & 0xFF); |  | ||||||
|   } |  | ||||||
|   template<typename T> void encode_enum(uint32_t field_id, T value, bool force = false) { |  | ||||||
|     this->encode_uint32(field_id, static_cast<uint32_t>(value), force); |  | ||||||
|   } |  | ||||||
|   void encode_float(uint32_t field_id, float value, bool force = false) { |   void encode_float(uint32_t field_id, float value, bool force = false) { | ||||||
|     if (value == 0.0f && !force) |     if (value == 0.0f && !force) | ||||||
|       return; |       return; | ||||||
| @@ -306,18 +287,7 @@ class ProtoWriteBuffer { | |||||||
|     } |     } | ||||||
|     this->encode_uint64(field_id, uvalue, force); |     this->encode_uint64(field_id, uvalue, force); | ||||||
|   } |   } | ||||||
|   template<class C> void encode_message(uint32_t field_id, const C &value, bool force = false) { |   void encode_message(uint32_t field_id, const ProtoMessage &value, bool force = false); | ||||||
|     this->encode_field_raw(field_id, 2);  // type 2: Length-delimited message |  | ||||||
|     size_t begin = this->buffer_->size(); |  | ||||||
|  |  | ||||||
|     value.encode(*this); |  | ||||||
|  |  | ||||||
|     const uint32_t nested_length = this->buffer_->size() - begin; |  | ||||||
|     // add size varint |  | ||||||
|     std::vector<uint8_t> var; |  | ||||||
|     ProtoVarInt(nested_length).encode(var); |  | ||||||
|     this->buffer_->insert(this->buffer_->begin() + begin, var.begin(), var.end()); |  | ||||||
|   } |  | ||||||
|   std::vector<uint8_t> *get_buffer() const { return buffer_; } |   std::vector<uint8_t> *get_buffer() const { return buffer_; } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
| @@ -329,7 +299,6 @@ class ProtoMessage { | |||||||
|   virtual ~ProtoMessage() = default; |   virtual ~ProtoMessage() = default; | ||||||
|   // Default implementation for messages with no fields |   // Default implementation for messages with no fields | ||||||
|   virtual void encode(ProtoWriteBuffer buffer) const {} |   virtual void encode(ProtoWriteBuffer buffer) const {} | ||||||
|   void decode(const uint8_t *buffer, size_t length); |  | ||||||
|   // Default implementation for messages with no fields |   // Default implementation for messages with no fields | ||||||
|   virtual void calculate_size(uint32_t &total_size) const {} |   virtual void calculate_size(uint32_t &total_size) const {} | ||||||
| #ifdef HAS_PROTO_MESSAGE_DUMP | #ifdef HAS_PROTO_MESSAGE_DUMP | ||||||
| @@ -337,14 +306,519 @@ class ProtoMessage { | |||||||
|   virtual void dump_to(std::string &out) const = 0; |   virtual void dump_to(std::string &out) const = 0; | ||||||
|   virtual const char *message_name() const { return "unknown"; } |   virtual const char *message_name() const { return "unknown"; } | ||||||
| #endif | #endif | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Base class for messages that support decoding | ||||||
|  | class ProtoDecodableMessage : public ProtoMessage { | ||||||
|  |  public: | ||||||
|  |   void decode(const uint8_t *buffer, size_t length); | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } |   virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } | ||||||
|   virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } |   virtual bool decode_length(uint32_t field_id, ProtoLengthDelimited value) { return false; } | ||||||
|   virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } |   virtual bool decode_32bit(uint32_t field_id, Proto32Bit value) { return false; } | ||||||
|   virtual bool decode_64bit(uint32_t field_id, Proto64Bit value) { return false; } |   // NOTE: decode_64bit removed - wire type 1 not supported | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | class ProtoSize { | ||||||
|  |  public: | ||||||
|  |   /** | ||||||
|  |    * @brief ProtoSize class for Protocol Buffer serialization size calculation | ||||||
|  |    * | ||||||
|  |    * This class provides static methods to calculate the exact byte counts needed | ||||||
|  |    * for encoding various Protocol Buffer field types. All methods are designed to be | ||||||
|  |    * efficient for the common case where many fields have default values. | ||||||
|  |    * | ||||||
|  |    * Implements Protocol Buffer encoding size calculation according to: | ||||||
|  |    * https://protobuf.dev/programming-guides/encoding/ | ||||||
|  |    * | ||||||
|  |    * Key features: | ||||||
|  |    * - Early-return optimization for zero/default values | ||||||
|  |    * - Direct total_size updates to avoid unnecessary additions | ||||||
|  |    * - Specialized handling for different field types according to protobuf spec | ||||||
|  |    * - Templated helpers for repeated fields and messages | ||||||
|  |    */ | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint | ||||||
|  |    * | ||||||
|  |    * @param value The uint32_t value to calculate size for | ||||||
|  |    * @return The number of bytes needed to encode the value | ||||||
|  |    */ | ||||||
|  |   static inline uint32_t varint(uint32_t value) { | ||||||
|  |     // Optimized varint size calculation using leading zeros | ||||||
|  |     // Each 7 bits requires one byte in the varint encoding | ||||||
|  |     if (value < 128) | ||||||
|  |       return 1;  // 7 bits, common case for small values | ||||||
|  |  | ||||||
|  |     // For larger values, count bytes needed based on the position of the highest bit set | ||||||
|  |     if (value < 16384) { | ||||||
|  |       return 2;  // 14 bits | ||||||
|  |     } else if (value < 2097152) { | ||||||
|  |       return 3;  // 21 bits | ||||||
|  |     } else if (value < 268435456) { | ||||||
|  |       return 4;  // 28 bits | ||||||
|  |     } else { | ||||||
|  |       return 5;  // 32 bits (maximum for uint32_t) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates the size in bytes needed to encode a uint64_t value as a varint | ||||||
|  |    * | ||||||
|  |    * @param value The uint64_t value to calculate size for | ||||||
|  |    * @return The number of bytes needed to encode the value | ||||||
|  |    */ | ||||||
|  |   static inline uint32_t varint(uint64_t value) { | ||||||
|  |     // Handle common case of values fitting in uint32_t (vast majority of use cases) | ||||||
|  |     if (value <= UINT32_MAX) { | ||||||
|  |       return varint(static_cast<uint32_t>(value)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // For larger values, determine size based on highest bit position | ||||||
|  |     if (value < (1ULL << 35)) { | ||||||
|  |       return 5;  // 35 bits | ||||||
|  |     } else if (value < (1ULL << 42)) { | ||||||
|  |       return 6;  // 42 bits | ||||||
|  |     } else if (value < (1ULL << 49)) { | ||||||
|  |       return 7;  // 49 bits | ||||||
|  |     } else if (value < (1ULL << 56)) { | ||||||
|  |       return 8;  // 56 bits | ||||||
|  |     } else if (value < (1ULL << 63)) { | ||||||
|  |       return 9;  // 63 bits | ||||||
|  |     } else { | ||||||
|  |       return 10;  // 64 bits (maximum for uint64_t) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates the size in bytes needed to encode an int32_t value as a varint | ||||||
|  |    * | ||||||
|  |    * Special handling is needed for negative values, which are sign-extended to 64 bits | ||||||
|  |    * in Protocol Buffers, resulting in a 10-byte varint. | ||||||
|  |    * | ||||||
|  |    * @param value The int32_t value to calculate size for | ||||||
|  |    * @return The number of bytes needed to encode the value | ||||||
|  |    */ | ||||||
|  |   static inline uint32_t varint(int32_t value) { | ||||||
|  |     // Negative values are sign-extended to 64 bits in protocol buffers, | ||||||
|  |     // which always results in a 10-byte varint for negative int32 | ||||||
|  |     if (value < 0) { | ||||||
|  |       return 10;  // Negative int32 is always 10 bytes long | ||||||
|  |     } | ||||||
|  |     // For non-negative values, use the uint32_t implementation | ||||||
|  |     return varint(static_cast<uint32_t>(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates the size in bytes needed to encode an int64_t value as a varint | ||||||
|  |    * | ||||||
|  |    * @param value The int64_t value to calculate size for | ||||||
|  |    * @return The number of bytes needed to encode the value | ||||||
|  |    */ | ||||||
|  |   static inline uint32_t varint(int64_t value) { | ||||||
|  |     // For int64_t, we convert to uint64_t and calculate the size | ||||||
|  |     // This works because the bit pattern determines the encoding size, | ||||||
|  |     // and we've handled negative int32 values as a special case above | ||||||
|  |     return varint(static_cast<uint64_t>(value)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates the size in bytes needed to encode a field ID and wire type | ||||||
|  |    * | ||||||
|  |    * @param field_id The field identifier | ||||||
|  |    * @param type The wire type value (from the WireType enum in the protobuf spec) | ||||||
|  |    * @return The number of bytes needed to encode the field ID and wire type | ||||||
|  |    */ | ||||||
|  |   static inline uint32_t field(uint32_t field_id, uint32_t type) { | ||||||
|  |     uint32_t tag = (field_id << 3) | (type & 0b111); | ||||||
|  |     return varint(tag); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Common parameters for all add_*_field methods | ||||||
|  |    * | ||||||
|  |    * All add_*_field methods follow these common patterns: | ||||||
|  |    * | ||||||
|  |    * @param total_size Reference to the total message size to update | ||||||
|  |    * @param field_id_size Pre-calculated size of the field ID in bytes | ||||||
|  |    * @param value The value to calculate size for (type varies) | ||||||
|  |    * @param force Whether to calculate size even if the value is default/zero/empty | ||||||
|  |    * | ||||||
|  |    * Each method follows this implementation pattern: | ||||||
|  |    * 1. Skip calculation if value is default (0, false, empty) and not forced | ||||||
|  |    * 2. Calculate the size based on the field's encoding rules | ||||||
|  |    * 3. Add the field_id_size + calculated value size to total_size | ||||||
|  |    */ | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of an int32 field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_int32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { | ||||||
|  |     // Skip calculation if value is zero | ||||||
|  |     if (value == 0) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Calculate and directly add to total_size | ||||||
|  |     if (value < 0) { | ||||||
|  |       // Negative values are encoded as 10-byte varints in protobuf | ||||||
|  |       total_size += field_id_size + 10; | ||||||
|  |     } else { | ||||||
|  |       // For non-negative values, use the standard varint size | ||||||
|  |       total_size += field_id_size + varint(static_cast<uint32_t>(value)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of an int32 field to the total message size (repeated field version) | ||||||
|  |    */ | ||||||
|  |   static inline void add_int32_field_repeated(uint32_t &total_size, uint32_t field_id_size, int32_t value) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     if (value < 0) { | ||||||
|  |       // Negative values are encoded as 10-byte varints in protobuf | ||||||
|  |       total_size += field_id_size + 10; | ||||||
|  |     } else { | ||||||
|  |       // For non-negative values, use the standard varint size | ||||||
|  |       total_size += field_id_size + varint(static_cast<uint32_t>(value)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a uint32 field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_uint32_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { | ||||||
|  |     // Skip calculation if value is zero | ||||||
|  |     if (value == 0) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Calculate and directly add to total_size | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a uint32 field to the total message size (repeated field version) | ||||||
|  |    */ | ||||||
|  |   static inline void add_uint32_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a boolean field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_bool_field(uint32_t &total_size, uint32_t field_id_size, bool value) { | ||||||
|  |     // Skip calculation if value is false | ||||||
|  |     if (!value) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Boolean fields always use 1 byte when true | ||||||
|  |     total_size += field_id_size + 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a boolean field to the total message size (repeated field version) | ||||||
|  |    */ | ||||||
|  |   static inline void add_bool_field_repeated(uint32_t &total_size, uint32_t field_id_size, bool value) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     // Boolean fields always use 1 byte | ||||||
|  |     total_size += field_id_size + 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a fixed field to the total message size | ||||||
|  |    * | ||||||
|  |    * Fixed fields always take exactly N bytes (4 for fixed32/float, 8 for fixed64/double). | ||||||
|  |    * | ||||||
|  |    * @tparam NumBytes The number of bytes for this fixed field (4 or 8) | ||||||
|  |    * @param is_nonzero Whether the value is non-zero | ||||||
|  |    */ | ||||||
|  |   template<uint32_t NumBytes> | ||||||
|  |   static inline void add_fixed_field(uint32_t &total_size, uint32_t field_id_size, bool is_nonzero) { | ||||||
|  |     // Skip calculation if value is zero | ||||||
|  |     if (!is_nonzero) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Fixed fields always take exactly NumBytes | ||||||
|  |     total_size += field_id_size + NumBytes; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a float field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_float_field(uint32_t &total_size, uint32_t field_id_size, float value) { | ||||||
|  |     if (value != 0.0f) { | ||||||
|  |       total_size += field_id_size + 4; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // NOTE: add_double_field removed - wire type 1 (64-bit: double) not supported | ||||||
|  |   // to reduce overhead on embedded systems | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a fixed32 field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_fixed32_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { | ||||||
|  |     if (value != 0) { | ||||||
|  |       total_size += field_id_size + 4; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // NOTE: add_fixed64_field removed - wire type 1 (64-bit: fixed64) not supported | ||||||
|  |   // to reduce overhead on embedded systems | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a sfixed32 field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_sfixed32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { | ||||||
|  |     if (value != 0) { | ||||||
|  |       total_size += field_id_size + 4; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // NOTE: add_sfixed64_field removed - wire type 1 (64-bit: sfixed64) not supported | ||||||
|  |   // to reduce overhead on embedded systems | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of an enum field to the total message size | ||||||
|  |    * | ||||||
|  |    * Enum fields are encoded as uint32 varints. | ||||||
|  |    */ | ||||||
|  |   static inline void add_enum_field(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { | ||||||
|  |     // Skip calculation if value is zero | ||||||
|  |     if (value == 0) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Enums are encoded as uint32 | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of an enum field to the total message size (repeated field version) | ||||||
|  |    * | ||||||
|  |    * Enum fields are encoded as uint32 varints. | ||||||
|  |    */ | ||||||
|  |   static inline void add_enum_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t value) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     // Enums are encoded as uint32 | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a sint32 field to the total message size | ||||||
|  |    * | ||||||
|  |    * Sint32 fields use ZigZag encoding, which is more efficient for negative values. | ||||||
|  |    */ | ||||||
|  |   static inline void add_sint32_field(uint32_t &total_size, uint32_t field_id_size, int32_t value) { | ||||||
|  |     // Skip calculation if value is zero | ||||||
|  |     if (value == 0) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) | ||||||
|  |     uint32_t zigzag = (static_cast<uint32_t>(value) << 1) ^ (static_cast<uint32_t>(value >> 31)); | ||||||
|  |     total_size += field_id_size + varint(zigzag); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a sint32 field to the total message size (repeated field version) | ||||||
|  |    * | ||||||
|  |    * Sint32 fields use ZigZag encoding, which is more efficient for negative values. | ||||||
|  |    */ | ||||||
|  |   static inline void add_sint32_field_repeated(uint32_t &total_size, uint32_t field_id_size, int32_t value) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     // ZigZag encoding for sint32: (n << 1) ^ (n >> 31) | ||||||
|  |     uint32_t zigzag = (static_cast<uint32_t>(value) << 1) ^ (static_cast<uint32_t>(value >> 31)); | ||||||
|  |     total_size += field_id_size + varint(zigzag); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of an int64 field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_int64_field(uint32_t &total_size, uint32_t field_id_size, int64_t value) { | ||||||
|  |     // Skip calculation if value is zero | ||||||
|  |     if (value == 0) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Calculate and directly add to total_size | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of an int64 field to the total message size (repeated field version) | ||||||
|  |    */ | ||||||
|  |   static inline void add_int64_field_repeated(uint32_t &total_size, uint32_t field_id_size, int64_t value) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a uint64 field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_uint64_field(uint32_t &total_size, uint32_t field_id_size, uint64_t value) { | ||||||
|  |     // Skip calculation if value is zero | ||||||
|  |     if (value == 0) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Calculate and directly add to total_size | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a uint64 field to the total message size (repeated field version) | ||||||
|  |    */ | ||||||
|  |   static inline void add_uint64_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint64_t value) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     total_size += field_id_size + varint(value); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // NOTE: sint64 support functions (add_sint64_field, add_sint64_field_repeated) removed | ||||||
|  |   // sint64 type is not supported by ESPHome API to reduce overhead on embedded systems | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a string/bytes field to the total message size | ||||||
|  |    */ | ||||||
|  |   static inline void add_string_field(uint32_t &total_size, uint32_t field_id_size, const std::string &str) { | ||||||
|  |     // Skip calculation if string is empty | ||||||
|  |     if (str.empty()) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Calculate and directly add to total_size | ||||||
|  |     const uint32_t str_size = static_cast<uint32_t>(str.size()); | ||||||
|  |     total_size += field_id_size + varint(str_size) + str_size; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a string/bytes field to the total message size (repeated field version) | ||||||
|  |    */ | ||||||
|  |   static inline void add_string_field_repeated(uint32_t &total_size, uint32_t field_id_size, const std::string &str) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     const uint32_t str_size = static_cast<uint32_t>(str.size()); | ||||||
|  |     total_size += field_id_size + varint(str_size) + str_size; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a nested message field to the total message size | ||||||
|  |    * | ||||||
|  |    * This helper function directly updates the total_size reference if the nested size | ||||||
|  |    * is greater than zero. | ||||||
|  |    * | ||||||
|  |    * @param nested_size The pre-calculated size of the nested message | ||||||
|  |    */ | ||||||
|  |   static inline void add_message_field(uint32_t &total_size, uint32_t field_id_size, uint32_t nested_size) { | ||||||
|  |     // Skip calculation if nested message is empty | ||||||
|  |     if (nested_size == 0) { | ||||||
|  |       return;  // No need to update total_size | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Calculate and directly add to total_size | ||||||
|  |     // Field ID + length varint + nested message content | ||||||
|  |     total_size += field_id_size + varint(nested_size) + nested_size; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a nested message field to the total message size (repeated field version) | ||||||
|  |    * | ||||||
|  |    * @param nested_size The pre-calculated size of the nested message | ||||||
|  |    */ | ||||||
|  |   static inline void add_message_field_repeated(uint32_t &total_size, uint32_t field_id_size, uint32_t nested_size) { | ||||||
|  |     // Always calculate size for repeated fields | ||||||
|  |     // Field ID + length varint + nested message content | ||||||
|  |     total_size += field_id_size + varint(nested_size) + nested_size; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a nested message field to the total message size | ||||||
|  |    * | ||||||
|  |    * This version takes a ProtoMessage object, calculates its size internally, | ||||||
|  |    * and updates the total_size reference. This eliminates the need for a temporary variable | ||||||
|  |    * at the call site. | ||||||
|  |    * | ||||||
|  |    * @param message The nested message object | ||||||
|  |    */ | ||||||
|  |   static inline void add_message_object(uint32_t &total_size, uint32_t field_id_size, const ProtoMessage &message) { | ||||||
|  |     uint32_t nested_size = 0; | ||||||
|  |     message.calculate_size(nested_size); | ||||||
|  |  | ||||||
|  |     // Use the base implementation with the calculated nested_size | ||||||
|  |     add_message_field(total_size, field_id_size, nested_size); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the size of a nested message field to the total message size (repeated field version) | ||||||
|  |    * | ||||||
|  |    * @param message The nested message object | ||||||
|  |    */ | ||||||
|  |   static inline void add_message_object_repeated(uint32_t &total_size, uint32_t field_id_size, | ||||||
|  |                                                  const ProtoMessage &message) { | ||||||
|  |     uint32_t nested_size = 0; | ||||||
|  |     message.calculate_size(nested_size); | ||||||
|  |  | ||||||
|  |     // Use the base implementation with the calculated nested_size | ||||||
|  |     add_message_field_repeated(total_size, field_id_size, nested_size); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * @brief Calculates and adds the sizes of all messages in a repeated field to the total message size | ||||||
|  |    * | ||||||
|  |    * This helper processes a vector of message objects, calculating the size for each message | ||||||
|  |    * and adding it to the total size. | ||||||
|  |    * | ||||||
|  |    * @tparam MessageType The type of the nested messages in the vector | ||||||
|  |    * @param messages Vector of message objects | ||||||
|  |    */ | ||||||
|  |   template<typename MessageType> | ||||||
|  |   static inline void add_repeated_message(uint32_t &total_size, uint32_t field_id_size, | ||||||
|  |                                           const std::vector<MessageType> &messages) { | ||||||
|  |     // Skip if the vector is empty | ||||||
|  |     if (messages.empty()) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Use the repeated field version for all messages | ||||||
|  |     for (const auto &message : messages) { | ||||||
|  |       add_message_object_repeated(total_size, field_id_size, message); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Implementation of encode_message - must be after ProtoMessage is defined | ||||||
|  | inline void ProtoWriteBuffer::encode_message(uint32_t field_id, const ProtoMessage &value, bool force) { | ||||||
|  |   this->encode_field_raw(field_id, 2);  // type 2: Length-delimited message | ||||||
|  |  | ||||||
|  |   // Calculate the message size first | ||||||
|  |   uint32_t msg_length_bytes = 0; | ||||||
|  |   value.calculate_size(msg_length_bytes); | ||||||
|  |  | ||||||
|  |   // Calculate how many bytes the length varint needs | ||||||
|  |   uint32_t varint_length_bytes = ProtoSize::varint(msg_length_bytes); | ||||||
|  |  | ||||||
|  |   // Reserve exact space for the length varint | ||||||
|  |   size_t begin = this->buffer_->size(); | ||||||
|  |   this->buffer_->resize(this->buffer_->size() + varint_length_bytes); | ||||||
|  |  | ||||||
|  |   // Write the length varint directly | ||||||
|  |   ProtoVarInt(msg_length_bytes).encode_to_buffer_unchecked(this->buffer_->data() + begin, varint_length_bytes); | ||||||
|  |  | ||||||
|  |   // Now encode the message content - it will append to the buffer | ||||||
|  |   value.encode(*this); | ||||||
|  |  | ||||||
|  |   // Verify that the encoded size matches what we calculated | ||||||
|  |   assert(this->buffer_->size() == begin + varint_length_bytes + msg_length_bytes); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Implementation of decode_to_message - must be after ProtoDecodableMessage is defined | ||||||
|  | inline void ProtoLengthDelimited::decode_to_message(ProtoDecodableMessage &msg) const { | ||||||
|  |   msg.decode(this->value_, this->length_); | ||||||
|  | } | ||||||
|  |  | ||||||
| template<typename T> const char *proto_enum_to_string(T value); | template<typename T> const char *proto_enum_to_string(T value); | ||||||
|  |  | ||||||
| class ProtoService { | class ProtoService { | ||||||
| @@ -363,11 +837,11 @@ class ProtoService { | |||||||
|    * @return A ProtoWriteBuffer object with the reserved size. |    * @return A ProtoWriteBuffer object with the reserved size. | ||||||
|    */ |    */ | ||||||
|   virtual ProtoWriteBuffer create_buffer(uint32_t reserve_size) = 0; |   virtual ProtoWriteBuffer create_buffer(uint32_t reserve_size) = 0; | ||||||
|   virtual bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) = 0; |   virtual bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) = 0; | ||||||
|   virtual void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) = 0; |   virtual void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) = 0; | ||||||
|  |  | ||||||
|   // Optimized method that pre-allocates buffer based on message size |   // Optimized method that pre-allocates buffer based on message size | ||||||
|   bool send_message_(const ProtoMessage &msg, uint16_t message_type) { |   bool send_message_(const ProtoMessage &msg, uint8_t message_type) { | ||||||
|     uint32_t msg_size = 0; |     uint32_t msg_size = 0; | ||||||
|     msg.calculate_size(msg_size); |     msg.calculate_size(msg_size); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ | |||||||
| #include "esphome/core/automation.h" | #include "esphome/core/automation.h" | ||||||
| #include "api_pb2.h" | #include "api_pb2.h" | ||||||
|  |  | ||||||
|  | #ifdef USE_API_SERVICES | ||||||
| namespace esphome { | namespace esphome { | ||||||
| namespace api { | namespace api { | ||||||
|  |  | ||||||
| @@ -15,6 +16,8 @@ class UserServiceDescriptor { | |||||||
|   virtual ListEntitiesServicesResponse encode_list_service_response() = 0; |   virtual ListEntitiesServicesResponse encode_list_service_response() = 0; | ||||||
|  |  | ||||||
|   virtual bool execute_service(const ExecuteServiceRequest &req) = 0; |   virtual bool execute_service(const ExecuteServiceRequest &req) = 0; | ||||||
|  |  | ||||||
|  |   bool is_internal() { return false; } | ||||||
| }; | }; | ||||||
|  |  | ||||||
| template<typename T> T get_execute_arg_value(const ExecuteServiceArgument &arg); | template<typename T> T get_execute_arg_value(const ExecuteServiceArgument &arg); | ||||||
| @@ -73,3 +76,4 @@ template<typename... Ts> class UserServiceTrigger : public UserServiceBase<Ts... | |||||||
|  |  | ||||||
| }  // namespace api | }  // namespace api | ||||||
| }  // namespace esphome | }  // namespace esphome | ||||||
|  | #endif  // USE_API_SERVICES | ||||||
|   | |||||||
| @@ -3,8 +3,6 @@ | |||||||
| #include "esphome/core/component.h" | #include "esphome/core/component.h" | ||||||
| #include "esphome/components/as3935/as3935.h" | #include "esphome/components/as3935/as3935.h" | ||||||
| #include "esphome/components/spi/spi.h" | #include "esphome/components/spi/spi.h" | ||||||
| #include "esphome/components/sensor/sensor.h" |  | ||||||
| #include "esphome/components/binary_sensor/binary_sensor.h" |  | ||||||
|  |  | ||||||
| namespace esphome { | namespace esphome { | ||||||
| namespace as3935_spi { | namespace as3935_spi { | ||||||
|   | |||||||
| @@ -31,7 +31,7 @@ CONFIG_SCHEMA = cv.All( | |||||||
| async def to_code(config): | async def to_code(config): | ||||||
|     if CORE.is_esp32 or CORE.is_libretiny: |     if CORE.is_esp32 or CORE.is_libretiny: | ||||||
|         # https://github.com/ESP32Async/AsyncTCP |         # https://github.com/ESP32Async/AsyncTCP | ||||||
|         cg.add_library("ESP32Async/AsyncTCP", "3.4.4") |         cg.add_library("ESP32Async/AsyncTCP", "3.4.5") | ||||||
|     elif CORE.is_esp8266: |     elif CORE.is_esp8266: | ||||||
|         # https://github.com/ESP32Async/ESPAsyncTCP |         # https://github.com/ESP32Async/ESPAsyncTCP | ||||||
|         cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0") |         cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0") | ||||||
|   | |||||||
| @@ -85,13 +85,13 @@ async def to_code(config): | |||||||
|     await cg.register_component(var, config) |     await cg.register_component(var, config) | ||||||
|  |  | ||||||
|     cg.add(var.set_active(config[CONF_ACTIVE])) |     cg.add(var.set_active(config[CONF_ACTIVE])) | ||||||
|     await esp32_ble_tracker.register_ble_device(var, config) |     await esp32_ble_tracker.register_raw_ble_device(var, config) | ||||||
|  |  | ||||||
|     for connection_conf in config.get(CONF_CONNECTIONS, []): |     for connection_conf in config.get(CONF_CONNECTIONS, []): | ||||||
|         connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) |         connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) | ||||||
|         await cg.register_component(connection_var, connection_conf) |         await cg.register_component(connection_var, connection_conf) | ||||||
|         cg.add(var.register_connection(connection_var)) |         cg.add(var.register_connection(connection_var)) | ||||||
|         await esp32_ble_tracker.register_client(connection_var, connection_conf) |         await esp32_ble_tracker.register_raw_client(connection_var, connection_conf) | ||||||
|  |  | ||||||
|     if config.get(CONF_CACHE_SERVICES): |     if config.get(CONF_CACHE_SERVICES): | ||||||
|         add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) |         add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) | ||||||
|   | |||||||
| @@ -13,11 +13,179 @@ namespace bluetooth_proxy { | |||||||
|  |  | ||||||
| static const char *const TAG = "bluetooth_proxy.connection"; | static const char *const TAG = "bluetooth_proxy.connection"; | ||||||
|  |  | ||||||
|  | static std::vector<uint64_t> get_128bit_uuid_vec(esp_bt_uuid_t uuid_source) { | ||||||
|  |   esp_bt_uuid_t uuid = espbt::ESPBTUUID::from_uuid(uuid_source).as_128bit().get_uuid(); | ||||||
|  |   return std::vector<uint64_t>{((uint64_t) uuid.uuid.uuid128[15] << 56) | ((uint64_t) uuid.uuid.uuid128[14] << 48) | | ||||||
|  |                                    ((uint64_t) uuid.uuid.uuid128[13] << 40) | ((uint64_t) uuid.uuid.uuid128[12] << 32) | | ||||||
|  |                                    ((uint64_t) uuid.uuid.uuid128[11] << 24) | ((uint64_t) uuid.uuid.uuid128[10] << 16) | | ||||||
|  |                                    ((uint64_t) uuid.uuid.uuid128[9] << 8) | ((uint64_t) uuid.uuid.uuid128[8]), | ||||||
|  |                                ((uint64_t) uuid.uuid.uuid128[7] << 56) | ((uint64_t) uuid.uuid.uuid128[6] << 48) | | ||||||
|  |                                    ((uint64_t) uuid.uuid.uuid128[5] << 40) | ((uint64_t) uuid.uuid.uuid128[4] << 32) | | ||||||
|  |                                    ((uint64_t) uuid.uuid.uuid128[3] << 24) | ((uint64_t) uuid.uuid.uuid128[2] << 16) | | ||||||
|  |                                    ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0])}; | ||||||
|  | } | ||||||
|  |  | ||||||
| void BluetoothConnection::dump_config() { | void BluetoothConnection::dump_config() { | ||||||
|   ESP_LOGCONFIG(TAG, "BLE Connection:"); |   ESP_LOGCONFIG(TAG, "BLE Connection:"); | ||||||
|   BLEClientBase::dump_config(); |   BLEClientBase::dump_config(); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | void BluetoothConnection::loop() { | ||||||
|  |   BLEClientBase::loop(); | ||||||
|  |  | ||||||
|  |   // Early return if no active connection or not in service discovery phase | ||||||
|  |   if (this->address_ == 0 || this->send_service_ < 0 || this->send_service_ > this->service_count_) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Handle service discovery | ||||||
|  |   this->send_service_for_discovery_(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void BluetoothConnection::reset_connection_(esp_err_t reason) { | ||||||
|  |   // Send disconnection notification | ||||||
|  |   this->proxy_->send_device_connection(this->address_, false, 0, reason); | ||||||
|  |  | ||||||
|  |   // Important: If we were in the middle of sending services, we do NOT send | ||||||
|  |   // send_gatt_services_done() here. This ensures the client knows that | ||||||
|  |   // the service discovery was interrupted and can retry. The client | ||||||
|  |   // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) | ||||||
|  |   // to detect incomplete service discovery rather than relying on us to | ||||||
|  |   // tell them about a partial list. | ||||||
|  |   this->set_address(0); | ||||||
|  |   this->send_service_ = DONE_SENDING_SERVICES; | ||||||
|  |   this->proxy_->send_connections_free(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void BluetoothConnection::send_service_for_discovery_() { | ||||||
|  |   if (this->send_service_ == this->service_count_) { | ||||||
|  |     this->send_service_ = DONE_SENDING_SERVICES; | ||||||
|  |     this->proxy_->send_gatt_services_done(this->address_); | ||||||
|  |     if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || | ||||||
|  |         this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { | ||||||
|  |       this->release_services(); | ||||||
|  |     } | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Early return if no API connection | ||||||
|  |   auto *api_conn = this->proxy_->get_api_connection(); | ||||||
|  |   if (api_conn == nullptr) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Send next service | ||||||
|  |   esp_gattc_service_elem_t service_result; | ||||||
|  |   uint16_t service_count = 1; | ||||||
|  |   esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, | ||||||
|  |                                                                &service_result, &service_count, this->send_service_); | ||||||
|  |   this->send_service_++; | ||||||
|  |  | ||||||
|  |   if (service_status != ESP_GATT_OK) { | ||||||
|  |     ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service error at offset=%d, status=%d", this->connection_index_, | ||||||
|  |              this->address_str().c_str(), this->send_service_ - 1, service_status); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (service_count == 0) { | ||||||
|  |     ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service missing, service_count=%d", this->connection_index_, | ||||||
|  |              this->address_str().c_str(), service_count); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   api::BluetoothGATTGetServicesResponse resp; | ||||||
|  |   resp.address = this->address_; | ||||||
|  |   resp.services.emplace_back(); | ||||||
|  |   auto &service_resp = resp.services.back(); | ||||||
|  |   service_resp.uuid = get_128bit_uuid_vec(service_result.uuid); | ||||||
|  |   service_resp.handle = service_result.start_handle; | ||||||
|  |  | ||||||
|  |   // Get the number of characteristics directly with one call | ||||||
|  |   uint16_t total_char_count = 0; | ||||||
|  |   esp_gatt_status_t char_count_status = | ||||||
|  |       esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, | ||||||
|  |                                    service_result.start_handle, service_result.end_handle, 0, &total_char_count); | ||||||
|  |  | ||||||
|  |   if (char_count_status == ESP_GATT_OK && total_char_count > 0) { | ||||||
|  |     // Only reserve if we successfully got a count | ||||||
|  |     service_resp.characteristics.reserve(total_char_count); | ||||||
|  |   } else if (char_count_status != ESP_GATT_OK) { | ||||||
|  |     ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", this->connection_index_, | ||||||
|  |              this->address_str().c_str(), char_count_status); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Now process characteristics | ||||||
|  |   uint16_t char_offset = 0; | ||||||
|  |   esp_gattc_char_elem_t char_result; | ||||||
|  |   while (true) {  // characteristics | ||||||
|  |     uint16_t char_count = 1; | ||||||
|  |     esp_gatt_status_t char_status = | ||||||
|  |         esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, | ||||||
|  |                                    service_result.end_handle, &char_result, &char_count, char_offset); | ||||||
|  |     if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |     if (char_status != ESP_GATT_OK) { | ||||||
|  |       ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->connection_index_, | ||||||
|  |                this->address_str().c_str(), char_status); | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |     if (char_count == 0) { | ||||||
|  |       break; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     service_resp.characteristics.emplace_back(); | ||||||
|  |     auto &characteristic_resp = service_resp.characteristics.back(); | ||||||
|  |     characteristic_resp.uuid = get_128bit_uuid_vec(char_result.uuid); | ||||||
|  |     characteristic_resp.handle = char_result.char_handle; | ||||||
|  |     characteristic_resp.properties = char_result.properties; | ||||||
|  |     char_offset++; | ||||||
|  |  | ||||||
|  |     // Get the number of descriptors directly with one call | ||||||
|  |     uint16_t total_desc_count = 0; | ||||||
|  |     esp_gatt_status_t desc_count_status = | ||||||
|  |         esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, char_result.char_handle, | ||||||
|  |                                      service_result.end_handle, 0, &total_desc_count); | ||||||
|  |  | ||||||
|  |     if (desc_count_status == ESP_GATT_OK && total_desc_count > 0) { | ||||||
|  |       // Only reserve if we successfully got a count | ||||||
|  |       characteristic_resp.descriptors.reserve(total_desc_count); | ||||||
|  |     } else if (desc_count_status != ESP_GATT_OK) { | ||||||
|  |       ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", this->connection_index_, | ||||||
|  |                this->address_str().c_str(), char_result.char_handle, desc_count_status); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Now process descriptors | ||||||
|  |     uint16_t desc_offset = 0; | ||||||
|  |     esp_gattc_descr_elem_t desc_result; | ||||||
|  |     while (true) {  // descriptors | ||||||
|  |       uint16_t desc_count = 1; | ||||||
|  |       esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( | ||||||
|  |           this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); | ||||||
|  |       if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       if (desc_status != ESP_GATT_OK) { | ||||||
|  |         ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", this->connection_index_, | ||||||
|  |                  this->address_str().c_str(), desc_status); | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       if (desc_count == 0) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       characteristic_resp.descriptors.emplace_back(); | ||||||
|  |       auto &descriptor_resp = characteristic_resp.descriptors.back(); | ||||||
|  |       descriptor_resp.uuid = get_128bit_uuid_vec(desc_result.uuid); | ||||||
|  |       descriptor_resp.handle = desc_result.handle; | ||||||
|  |       desc_offset++; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Send the message (we already checked api_conn is not null at the beginning) | ||||||
|  |   api_conn->send_message(resp, api::BluetoothGATTGetServicesResponse::MESSAGE_TYPE); | ||||||
|  | } | ||||||
|  |  | ||||||
| bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, | bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, | ||||||
|                                               esp_ble_gattc_cb_param_t *param) { |                                               esp_ble_gattc_cb_param_t *param) { | ||||||
|   if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) |   if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) | ||||||
| @@ -25,22 +193,16 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga | |||||||
|  |  | ||||||
|   switch (event) { |   switch (event) { | ||||||
|     case ESP_GATTC_DISCONNECT_EVT: { |     case ESP_GATTC_DISCONNECT_EVT: { | ||||||
|       this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); |       this->reset_connection_(param->disconnect.reason); | ||||||
|       this->set_address(0); |  | ||||||
|       this->proxy_->send_connections_free(); |  | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case ESP_GATTC_CLOSE_EVT: { |     case ESP_GATTC_CLOSE_EVT: { | ||||||
|       this->proxy_->send_device_connection(this->address_, false, 0, param->close.reason); |       this->reset_connection_(param->close.reason); | ||||||
|       this->set_address(0); |  | ||||||
|       this->proxy_->send_connections_free(); |  | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case ESP_GATTC_OPEN_EVT: { |     case ESP_GATTC_OPEN_EVT: { | ||||||
|       if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { |       if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { | ||||||
|         this->proxy_->send_device_connection(this->address_, false, 0, param->open.status); |         this->reset_connection_(param->open.status); | ||||||
|         this->set_address(0); |  | ||||||
|         this->proxy_->send_connections_free(); |  | ||||||
|       } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { |       } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { | ||||||
|         this->proxy_->send_device_connection(this->address_, true, this->mtu_); |         this->proxy_->send_device_connection(this->address_, true, this->mtu_); | ||||||
|         this->proxy_->send_connections_free(); |         this->proxy_->send_connections_free(); | ||||||
| @@ -75,7 +237,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga | |||||||
|       resp.data.reserve(param->read.value_len); |       resp.data.reserve(param->read.value_len); | ||||||
|       // Use bulk insert instead of individual push_backs |       // Use bulk insert instead of individual push_backs | ||||||
|       resp.data.insert(resp.data.end(), param->read.value, param->read.value + param->read.value_len); |       resp.data.insert(resp.data.end(), param->read.value, param->read.value + param->read.value_len); | ||||||
|       this->proxy_->get_api_connection()->send_message(resp); |       this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTReadResponse::MESSAGE_TYPE); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case ESP_GATTC_WRITE_CHAR_EVT: |     case ESP_GATTC_WRITE_CHAR_EVT: | ||||||
| @@ -89,7 +251,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga | |||||||
|       api::BluetoothGATTWriteResponse resp; |       api::BluetoothGATTWriteResponse resp; | ||||||
|       resp.address = this->address_; |       resp.address = this->address_; | ||||||
|       resp.handle = param->write.handle; |       resp.handle = param->write.handle; | ||||||
|       this->proxy_->get_api_connection()->send_message(resp); |       this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTWriteResponse::MESSAGE_TYPE); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { |     case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { | ||||||
| @@ -103,7 +265,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga | |||||||
|       api::BluetoothGATTNotifyResponse resp; |       api::BluetoothGATTNotifyResponse resp; | ||||||
|       resp.address = this->address_; |       resp.address = this->address_; | ||||||
|       resp.handle = param->unreg_for_notify.handle; |       resp.handle = param->unreg_for_notify.handle; | ||||||
|       this->proxy_->get_api_connection()->send_message(resp); |       this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case ESP_GATTC_REG_FOR_NOTIFY_EVT: { |     case ESP_GATTC_REG_FOR_NOTIFY_EVT: { | ||||||
| @@ -116,7 +278,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga | |||||||
|       api::BluetoothGATTNotifyResponse resp; |       api::BluetoothGATTNotifyResponse resp; | ||||||
|       resp.address = this->address_; |       resp.address = this->address_; | ||||||
|       resp.handle = param->reg_for_notify.handle; |       resp.handle = param->reg_for_notify.handle; | ||||||
|       this->proxy_->get_api_connection()->send_message(resp); |       this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     case ESP_GATTC_NOTIFY_EVT: { |     case ESP_GATTC_NOTIFY_EVT: { | ||||||
| @@ -128,7 +290,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga | |||||||
|       resp.data.reserve(param->notify.value_len); |       resp.data.reserve(param->notify.value_len); | ||||||
|       // Use bulk insert instead of individual push_backs |       // Use bulk insert instead of individual push_backs | ||||||
|       resp.data.insert(resp.data.end(), param->notify.value, param->notify.value + param->notify.value_len); |       resp.data.insert(resp.data.end(), param->notify.value, param->notify.value + param->notify.value_len); | ||||||
|       this->proxy_->get_api_connection()->send_message(resp); |       this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyDataResponse::MESSAGE_TYPE); | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
|     default: |     default: | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ class BluetoothProxy; | |||||||
| class BluetoothConnection : public esp32_ble_client::BLEClientBase { | class BluetoothConnection : public esp32_ble_client::BLEClientBase { | ||||||
|  public: |  public: | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|  |   void loop() override; | ||||||
|   bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, |   bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, | ||||||
|                            esp_ble_gattc_cb_param_t *param) override; |                            esp_ble_gattc_cb_param_t *param) override; | ||||||
|   void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; |   void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; | ||||||
| @@ -27,6 +28,9 @@ class BluetoothConnection : public esp32_ble_client::BLEClientBase { | |||||||
|  protected: |  protected: | ||||||
|   friend class BluetoothProxy; |   friend class BluetoothProxy; | ||||||
|  |  | ||||||
|  |   void send_service_for_discovery_(); | ||||||
|  |   void reset_connection_(esp_err_t reason); | ||||||
|  |  | ||||||
|   // Memory optimized layout for 32-bit systems |   // Memory optimized layout for 32-bit systems | ||||||
|   // Group 1: Pointers (4 bytes each, naturally aligned) |   // Group 1: Pointers (4 bytes each, naturally aligned) | ||||||
|   BluetoothProxy *proxy_; |   BluetoothProxy *proxy_; | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ | |||||||
| #include "esphome/core/log.h" | #include "esphome/core/log.h" | ||||||
| #include "esphome/core/macros.h" | #include "esphome/core/macros.h" | ||||||
| #include "esphome/core/application.h" | #include "esphome/core/application.h" | ||||||
|  | #include <cstring> | ||||||
|  |  | ||||||
| #ifdef USE_ESP32 | #ifdef USE_ESP32 | ||||||
|  |  | ||||||
| @@ -10,23 +11,31 @@ namespace esphome { | |||||||
| namespace bluetooth_proxy { | namespace bluetooth_proxy { | ||||||
|  |  | ||||||
| static const char *const TAG = "bluetooth_proxy"; | static const char *const TAG = "bluetooth_proxy"; | ||||||
| static const int DONE_SENDING_SERVICES = -2; |  | ||||||
|  |  | ||||||
| std::vector<uint64_t> get_128bit_uuid_vec(esp_bt_uuid_t uuid_source) { | // Batch size for BLE advertisements to maximize WiFi efficiency | ||||||
|   esp_bt_uuid_t uuid = espbt::ESPBTUUID::from_uuid(uuid_source).as_128bit().get_uuid(); | // Each advertisement is up to 80 bytes when packaged (including protocol overhead) | ||||||
|   return std::vector<uint64_t>{((uint64_t) uuid.uuid.uuid128[15] << 56) | ((uint64_t) uuid.uuid.uuid128[14] << 48) | | // Most advertisements are 20-30 bytes, allowing even more to fit per packet | ||||||
|                                    ((uint64_t) uuid.uuid.uuid128[13] << 40) | ((uint64_t) uuid.uuid.uuid128[12] << 32) | | // 16 advertisements × 80 bytes (worst case) = 1280 bytes out of ~1320 bytes usable payload | ||||||
|                                    ((uint64_t) uuid.uuid.uuid128[11] << 24) | ((uint64_t) uuid.uuid.uuid128[10] << 16) | | // This achieves ~97% WiFi MTU utilization while staying under the limit | ||||||
|                                    ((uint64_t) uuid.uuid.uuid128[9] << 8) | ((uint64_t) uuid.uuid.uuid128[8]), | static constexpr size_t FLUSH_BATCH_SIZE = 16; | ||||||
|                                ((uint64_t) uuid.uuid.uuid128[7] << 56) | ((uint64_t) uuid.uuid.uuid128[6] << 48) | |  | ||||||
|                                    ((uint64_t) uuid.uuid.uuid128[5] << 40) | ((uint64_t) uuid.uuid.uuid128[4] << 32) | | // Verify BLE advertisement data array size matches the BLE specification (31 bytes adv + 31 bytes scan response) | ||||||
|                                    ((uint64_t) uuid.uuid.uuid128[3] << 24) | ((uint64_t) uuid.uuid.uuid128[2] << 16) | | static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62, | ||||||
|                                    ((uint64_t) uuid.uuid.uuid128[1] << 8) | ((uint64_t) uuid.uuid.uuid128[0])}; |               "BLE advertisement data array size mismatch"); | ||||||
| } |  | ||||||
|  |  | ||||||
| BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } | BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } | ||||||
|  |  | ||||||
| void BluetoothProxy::setup() { | 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) { |   this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { | ||||||
|     if (this->api_connection_ != nullptr) { |     if (this->api_connection_ != nullptr) { | ||||||
|       this->send_bluetooth_scanner_state_(state); |       this->send_bluetooth_scanner_state_(state); | ||||||
| @@ -39,128 +48,91 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta | |||||||
|   resp.state = static_cast<api::enums::BluetoothScannerState>(state); |   resp.state = static_cast<api::enums::BluetoothScannerState>(state); | ||||||
|   resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE |   resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE | ||||||
|                                                : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; |                                                : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; | ||||||
|   this->api_connection_->send_message(resp); |   this->api_connection_->send_message(resp, api::BluetoothScannerStateResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
| bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { | bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { | ||||||
|   if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr || this->raw_advertisements_) |   // This method should never be called since bluetooth_proxy always uses raw advertisements | ||||||
|     return false; |   // but we need to provide an implementation to satisfy the virtual method requirement | ||||||
|  |   return false; | ||||||
|   ESP_LOGV(TAG, "Proxying packet from %s - %s. RSSI: %d dB", device.get_name().c_str(), device.address_str().c_str(), |  | ||||||
|            device.get_rssi()); |  | ||||||
|   this->send_api_packet_(device); |  | ||||||
|   return true; |  | ||||||
| } | } | ||||||
|  | #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) { | bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) { | ||||||
|   if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr || !this->raw_advertisements_) |   if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) | ||||||
|     return false; |     return false; | ||||||
|  |  | ||||||
|   // Get the batch buffer reference |   auto &advertisements = this->response_->advertisements; | ||||||
|   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++) { |   for (size_t i = 0; i < count; i++) { | ||||||
|     auto &result = scan_results[i]; |     auto &result = scan_results[i]; | ||||||
|     uint8_t length = result.adv_data_len + result.scan_rsp_len; |     uint8_t length = result.adv_data_len + result.scan_rsp_len; | ||||||
|  |  | ||||||
|     batch_buffer.emplace_back(); |     // Check if we need to expand the vector | ||||||
|     auto &adv = batch_buffer.back(); |     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_]; | ||||||
|     adv.address = esp32_ble::ble_addr_to_uint64(result.bda); |     adv.address = esp32_ble::ble_addr_to_uint64(result.bda); | ||||||
|     adv.rssi = result.rssi; |     adv.rssi = result.rssi; | ||||||
|     adv.address_type = result.ble_addr_type; |     adv.address_type = result.ble_addr_type; | ||||||
|     adv.data.assign(&result.ble_adv[0], &result.ble_adv[length]); |     adv.data_len = length; | ||||||
|  |     std::memcpy(adv.data, result.ble_adv, length); | ||||||
|  |  | ||||||
|  |     this->advertisement_count_++; | ||||||
|  |  | ||||||
|     ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], |     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); |              result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Only send if we've accumulated a good batch size to maximize batching efficiency |     // Flush if we have reached FLUSH_BATCH_SIZE | ||||||
|   // https://github.com/esphome/backlog/issues/21 |     if (this->advertisement_count_ >= FLUSH_BATCH_SIZE) { | ||||||
|   if (batch_buffer.size() >= FLUSH_BATCH_SIZE) { |       this->flush_pending_advertisements(); | ||||||
|     this->flush_pending_advertisements(); |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return true; |   return true; | ||||||
| } | } | ||||||
|  |  | ||||||
| void BluetoothProxy::flush_pending_advertisements() { | void BluetoothProxy::flush_pending_advertisements() { | ||||||
|   auto &batch_buffer = get_batch_buffer(); |   if (this->advertisement_count_ == 0 || !api::global_api_server->is_connected() || this->api_connection_ == nullptr) | ||||||
|   if (batch_buffer.empty() || !api::global_api_server->is_connected() || this->api_connection_ == nullptr) |  | ||||||
|     return; |     return; | ||||||
|  |  | ||||||
|   api::BluetoothLERawAdvertisementsResponse resp; |   auto &advertisements = this->response_->advertisements; | ||||||
|   resp.advertisements.swap(batch_buffer); |  | ||||||
|   this->api_connection_->send_message(resp); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| void BluetoothProxy::send_api_packet_(const esp32_ble_tracker::ESPBTDevice &device) { |   // Return any items beyond advertisement_count_ to the pool | ||||||
|   api::BluetoothLEAdvertisementResponse resp; |   if (advertisements.size() > this->advertisement_count_) { | ||||||
|   resp.address = device.address_uint64(); |     // Move unused items back to pool | ||||||
|   resp.address_type = device.get_address_type(); |     this->advertisement_pool_.insert(this->advertisement_pool_.end(), | ||||||
|   if (!device.get_name().empty()) |                                      std::make_move_iterator(advertisements.begin() + this->advertisement_count_), | ||||||
|     resp.name = device.get_name(); |                                      std::make_move_iterator(advertisements.end())); | ||||||
|   resp.rssi = device.get_rssi(); |  | ||||||
|  |  | ||||||
|   // Pre-allocate vectors based on known sizes |     // Resize to actual count | ||||||
|   auto service_uuids = device.get_service_uuids(); |     advertisements.resize(this->advertisement_count_); | ||||||
|   resp.service_uuids.reserve(service_uuids.size()); |  | ||||||
|   for (auto &uuid : service_uuids) { |  | ||||||
|     resp.service_uuids.emplace_back(uuid.to_string()); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Pre-allocate service data vector |   // Send the message | ||||||
|   auto service_datas = device.get_service_datas(); |   this->api_connection_->send_message(*this->response_, api::BluetoothLERawAdvertisementsResponse::MESSAGE_TYPE); | ||||||
|   resp.service_data.reserve(service_datas.size()); |  | ||||||
|   for (auto &data : service_datas) { |  | ||||||
|     resp.service_data.emplace_back(); |  | ||||||
|     auto &service_data = resp.service_data.back(); |  | ||||||
|     service_data.uuid = data.uuid.to_string(); |  | ||||||
|     service_data.data.assign(data.data.begin(), data.data.end()); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   // Pre-allocate manufacturer data vector |   // Reset count - existing items will be overwritten in next batch | ||||||
|   auto manufacturer_datas = device.get_manufacturer_datas(); |   this->advertisement_count_ = 0; | ||||||
|   resp.manufacturer_data.reserve(manufacturer_datas.size()); |  | ||||||
|   for (auto &data : manufacturer_datas) { |  | ||||||
|     resp.manufacturer_data.emplace_back(); |  | ||||||
|     auto &manufacturer_data = resp.manufacturer_data.back(); |  | ||||||
|     manufacturer_data.uuid = data.uuid.to_string(); |  | ||||||
|     manufacturer_data.data.assign(data.data.begin(), data.data.end()); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   this->api_connection_->send_message(resp); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| void BluetoothProxy::dump_config() { | void BluetoothProxy::dump_config() { | ||||||
|   ESP_LOGCONFIG(TAG, "Bluetooth Proxy:"); |   ESP_LOGCONFIG(TAG, "Bluetooth Proxy:"); | ||||||
|   ESP_LOGCONFIG(TAG, |   ESP_LOGCONFIG(TAG, | ||||||
|                 "  Active: %s\n" |                 "  Active: %s\n" | ||||||
|                 "  Connections: %d\n" |                 "  Connections: %d", | ||||||
|                 "  Raw advertisements: %s", |                 YESNO(this->active_), this->connections_.size()); | ||||||
|                 YESNO(this->active_), this->connections_.size(), YESNO(this->raw_advertisements_)); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| int BluetoothProxy::get_bluetooth_connections_free() { | int BluetoothProxy::get_bluetooth_connections_free() { | ||||||
| @@ -188,139 +160,17 @@ void BluetoothProxy::loop() { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Flush any pending BLE advertisements that have been accumulated but not yet sent |   // Flush any pending BLE advertisements that have been accumulated but not yet sent | ||||||
|   if (this->raw_advertisements_) { |   uint32_t now = App.get_loop_component_start_time(); | ||||||
|     static uint32_t last_flush_time = 0; |  | ||||||
|     uint32_t now = App.get_loop_component_start_time(); |  | ||||||
|  |  | ||||||
|     // Flush accumulated advertisements every 100ms |   // Flush accumulated advertisements every 100ms | ||||||
|     if (now - last_flush_time >= 100) { |   if (now - this->last_advertisement_flush_time_ >= 100) { | ||||||
|       this->flush_pending_advertisements(); |     this->flush_pending_advertisements(); | ||||||
|       last_flush_time = now; |     this->last_advertisement_flush_time_ = now; | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   for (auto *connection : this->connections_) { |  | ||||||
|     if (connection->send_service_ == connection->service_count_) { |  | ||||||
|       connection->send_service_ = DONE_SENDING_SERVICES; |  | ||||||
|       this->send_gatt_services_done(connection->get_address()); |  | ||||||
|       if (connection->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || |  | ||||||
|           connection->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { |  | ||||||
|         connection->release_services(); |  | ||||||
|       } |  | ||||||
|     } else if (connection->send_service_ >= 0) { |  | ||||||
|       esp_gattc_service_elem_t service_result; |  | ||||||
|       uint16_t service_count = 1; |  | ||||||
|       esp_gatt_status_t service_status = |  | ||||||
|           esp_ble_gattc_get_service(connection->get_gattc_if(), connection->get_conn_id(), nullptr, &service_result, |  | ||||||
|                                     &service_count, connection->send_service_); |  | ||||||
|       connection->send_service_++; |  | ||||||
|       if (service_status != ESP_GATT_OK) { |  | ||||||
|         ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service error at offset=%d, status=%d", |  | ||||||
|                  connection->get_connection_index(), connection->address_str().c_str(), connection->send_service_ - 1, |  | ||||||
|                  service_status); |  | ||||||
|         continue; |  | ||||||
|       } |  | ||||||
|       if (service_count == 0) { |  | ||||||
|         ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service missing, service_count=%d", |  | ||||||
|                  connection->get_connection_index(), connection->address_str().c_str(), service_count); |  | ||||||
|         continue; |  | ||||||
|       } |  | ||||||
|       api::BluetoothGATTGetServicesResponse resp; |  | ||||||
|       resp.address = connection->get_address(); |  | ||||||
|       resp.services.reserve(1);  // Always one service per response in this implementation |  | ||||||
|       api::BluetoothGATTService service_resp; |  | ||||||
|       service_resp.uuid = get_128bit_uuid_vec(service_result.uuid); |  | ||||||
|       service_resp.handle = service_result.start_handle; |  | ||||||
|       uint16_t char_offset = 0; |  | ||||||
|       esp_gattc_char_elem_t char_result; |  | ||||||
|       // Get the number of characteristics directly with one call |  | ||||||
|       uint16_t total_char_count = 0; |  | ||||||
|       esp_gatt_status_t char_count_status = esp_ble_gattc_get_attr_count( |  | ||||||
|           connection->get_gattc_if(), connection->get_conn_id(), ESP_GATT_DB_CHARACTERISTIC, |  | ||||||
|           service_result.start_handle, service_result.end_handle, 0, &total_char_count); |  | ||||||
|  |  | ||||||
|       if (char_count_status == ESP_GATT_OK && total_char_count > 0) { |  | ||||||
|         // Only reserve if we successfully got a count |  | ||||||
|         service_resp.characteristics.reserve(total_char_count); |  | ||||||
|       } else if (char_count_status != ESP_GATT_OK) { |  | ||||||
|         ESP_LOGW(TAG, "[%d] [%s] Error getting characteristic count, status=%d", connection->get_connection_index(), |  | ||||||
|                  connection->address_str().c_str(), char_count_status); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // Now process characteristics |  | ||||||
|       while (true) {  // characteristics |  | ||||||
|         uint16_t char_count = 1; |  | ||||||
|         esp_gatt_status_t char_status = esp_ble_gattc_get_all_char( |  | ||||||
|             connection->get_gattc_if(), connection->get_conn_id(), service_result.start_handle, |  | ||||||
|             service_result.end_handle, &char_result, &char_count, char_offset); |  | ||||||
|         if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|         if (char_status != ESP_GATT_OK) { |  | ||||||
|           ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", connection->get_connection_index(), |  | ||||||
|                    connection->address_str().c_str(), char_status); |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|         if (char_count == 0) { |  | ||||||
|           break; |  | ||||||
|         } |  | ||||||
|         api::BluetoothGATTCharacteristic characteristic_resp; |  | ||||||
|         characteristic_resp.uuid = get_128bit_uuid_vec(char_result.uuid); |  | ||||||
|         characteristic_resp.handle = char_result.char_handle; |  | ||||||
|         characteristic_resp.properties = char_result.properties; |  | ||||||
|         char_offset++; |  | ||||||
|  |  | ||||||
|         // Get the number of descriptors directly with one call |  | ||||||
|         uint16_t total_desc_count = 0; |  | ||||||
|         esp_gatt_status_t desc_count_status = |  | ||||||
|             esp_ble_gattc_get_attr_count(connection->get_gattc_if(), connection->get_conn_id(), ESP_GATT_DB_DESCRIPTOR, |  | ||||||
|                                          char_result.char_handle, service_result.end_handle, 0, &total_desc_count); |  | ||||||
|  |  | ||||||
|         if (desc_count_status == ESP_GATT_OK && total_desc_count > 0) { |  | ||||||
|           // Only reserve if we successfully got a count |  | ||||||
|           characteristic_resp.descriptors.reserve(total_desc_count); |  | ||||||
|         } else if (desc_count_status != ESP_GATT_OK) { |  | ||||||
|           ESP_LOGW(TAG, "[%d] [%s] Error getting descriptor count for char handle %d, status=%d", |  | ||||||
|                    connection->get_connection_index(), connection->address_str().c_str(), char_result.char_handle, |  | ||||||
|                    desc_count_status); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Now process descriptors |  | ||||||
|         uint16_t desc_offset = 0; |  | ||||||
|         esp_gattc_descr_elem_t desc_result; |  | ||||||
|         while (true) {  // descriptors |  | ||||||
|           uint16_t desc_count = 1; |  | ||||||
|           esp_gatt_status_t desc_status = |  | ||||||
|               esp_ble_gattc_get_all_descr(connection->get_gattc_if(), connection->get_conn_id(), |  | ||||||
|                                           char_result.char_handle, &desc_result, &desc_count, desc_offset); |  | ||||||
|           if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { |  | ||||||
|             break; |  | ||||||
|           } |  | ||||||
|           if (desc_status != ESP_GATT_OK) { |  | ||||||
|             ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", connection->get_connection_index(), |  | ||||||
|                      connection->address_str().c_str(), desc_status); |  | ||||||
|             break; |  | ||||||
|           } |  | ||||||
|           if (desc_count == 0) { |  | ||||||
|             break; |  | ||||||
|           } |  | ||||||
|           api::BluetoothGATTDescriptor descriptor_resp; |  | ||||||
|           descriptor_resp.uuid = get_128bit_uuid_vec(desc_result.uuid); |  | ||||||
|           descriptor_resp.handle = desc_result.handle; |  | ||||||
|           characteristic_resp.descriptors.push_back(std::move(descriptor_resp)); |  | ||||||
|           desc_offset++; |  | ||||||
|         } |  | ||||||
|         service_resp.characteristics.push_back(std::move(characteristic_resp)); |  | ||||||
|       } |  | ||||||
|       resp.services.push_back(std::move(service_resp)); |  | ||||||
|       this->api_connection_->send_message(resp); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { | esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { | ||||||
|   if (this->raw_advertisements_) |   return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; | ||||||
|     return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; |  | ||||||
|   return esp32_ble_tracker::AdvertisementParserType::PARSED_ADVERTISEMENTS; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { | BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { | ||||||
| @@ -465,7 +315,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest | |||||||
|       call.success = ret == ESP_OK; |       call.success = ret == ESP_OK; | ||||||
|       call.error = ret; |       call.error = ret; | ||||||
|  |  | ||||||
|       this->api_connection_->send_message(call); |       this->api_connection_->send_message(call, api::BluetoothDeviceClearCacheResponse::MESSAGE_TYPE); | ||||||
|  |  | ||||||
|       break; |       break; | ||||||
|     } |     } | ||||||
| @@ -565,7 +415,6 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection | |||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|   this->api_connection_ = api_connection; |   this->api_connection_ = api_connection; | ||||||
|   this->raw_advertisements_ = flags & BluetoothProxySubscriptionFlag::SUBSCRIPTION_RAW_ADVERTISEMENTS; |  | ||||||
|   this->parent_->recalculate_advertisement_parser_types(); |   this->parent_->recalculate_advertisement_parser_types(); | ||||||
|  |  | ||||||
|   this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); |   this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); | ||||||
| @@ -577,7 +426,6 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti | |||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|   this->api_connection_ = nullptr; |   this->api_connection_ = nullptr; | ||||||
|   this->raw_advertisements_ = false; |  | ||||||
|   this->parent_->recalculate_advertisement_parser_types(); |   this->parent_->recalculate_advertisement_parser_types(); | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -589,7 +437,7 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui | |||||||
|   call.connected = connected; |   call.connected = connected; | ||||||
|   call.mtu = mtu; |   call.mtu = mtu; | ||||||
|   call.error = error; |   call.error = error; | ||||||
|   this->api_connection_->send_message(call); |   this->api_connection_->send_message(call, api::BluetoothDeviceConnectionResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
| void BluetoothProxy::send_connections_free() { | void BluetoothProxy::send_connections_free() { | ||||||
|   if (this->api_connection_ == nullptr) |   if (this->api_connection_ == nullptr) | ||||||
| @@ -602,7 +450,7 @@ void BluetoothProxy::send_connections_free() { | |||||||
|       call.allocated.push_back(connection->address_); |       call.allocated.push_back(connection->address_); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   this->api_connection_->send_message(call); |   this->api_connection_->send_message(call, api::BluetoothConnectionsFreeResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
|  |  | ||||||
| void BluetoothProxy::send_gatt_services_done(uint64_t address) { | void BluetoothProxy::send_gatt_services_done(uint64_t address) { | ||||||
| @@ -610,7 +458,7 @@ void BluetoothProxy::send_gatt_services_done(uint64_t address) { | |||||||
|     return; |     return; | ||||||
|   api::BluetoothGATTGetServicesDoneResponse call; |   api::BluetoothGATTGetServicesDoneResponse call; | ||||||
|   call.address = address; |   call.address = address; | ||||||
|   this->api_connection_->send_message(call); |   this->api_connection_->send_message(call, api::BluetoothGATTGetServicesDoneResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
|  |  | ||||||
| void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) { | void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) { | ||||||
| @@ -620,7 +468,7 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_ | |||||||
|   call.address = address; |   call.address = address; | ||||||
|   call.handle = handle; |   call.handle = handle; | ||||||
|   call.error = error; |   call.error = error; | ||||||
|   this->api_connection_->send_message(call); |   this->api_connection_->send_message(call, api::BluetoothGATTWriteResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
|  |  | ||||||
| void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) { | void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) { | ||||||
| @@ -629,7 +477,7 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_ | |||||||
|   call.paired = paired; |   call.paired = paired; | ||||||
|   call.error = error; |   call.error = error; | ||||||
|  |  | ||||||
|   this->api_connection_->send_message(call); |   this->api_connection_->send_message(call, api::BluetoothDevicePairingResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
|  |  | ||||||
| void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) { | void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) { | ||||||
| @@ -638,7 +486,7 @@ void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_e | |||||||
|   call.success = success; |   call.success = success; | ||||||
|   call.error = error; |   call.error = error; | ||||||
|  |  | ||||||
|   this->api_connection_->send_message(call); |   this->api_connection_->send_message(call, api::BluetoothDeviceUnpairingResponse::MESSAGE_TYPE); | ||||||
| } | } | ||||||
|  |  | ||||||
| void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { | void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { | ||||||
|   | |||||||
| @@ -22,6 +22,7 @@ namespace esphome { | |||||||
| namespace bluetooth_proxy { | namespace bluetooth_proxy { | ||||||
|  |  | ||||||
| static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; | static const esp_err_t ESP_GATT_NOT_CONNECTED = -1; | ||||||
|  | static const int DONE_SENDING_SERVICES = -2; | ||||||
|  |  | ||||||
| using namespace esp32_ble_client; | using namespace esp32_ble_client; | ||||||
|  |  | ||||||
| @@ -51,7 +52,9 @@ enum BluetoothProxySubscriptionFlag : uint32_t { | |||||||
| class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { | class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Component { | ||||||
|  public: |  public: | ||||||
|   BluetoothProxy(); |   BluetoothProxy(); | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
|   bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; |   bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; | ||||||
|  | #endif | ||||||
|   bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; |   bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|   void setup() override; |   void setup() override; | ||||||
| @@ -129,7 +132,6 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com | |||||||
|   } |   } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   void send_api_packet_(const esp32_ble_tracker::ESPBTDevice &device); |  | ||||||
|   void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); |   void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); | ||||||
|  |  | ||||||
|   BluetoothConnection *get_connection_(uint64_t address, bool reserve); |   BluetoothConnection *get_connection_(uint64_t address, bool reserve); | ||||||
| @@ -141,9 +143,16 @@ class BluetoothProxy : public esp32_ble_tracker::ESPBTDeviceListener, public Com | |||||||
|   // Group 2: Container types (typically 12 bytes on 32-bit) |   // Group 2: Container types (typically 12 bytes on 32-bit) | ||||||
|   std::vector<BluetoothConnection *> connections_{}; |   std::vector<BluetoothConnection *> connections_{}; | ||||||
|  |  | ||||||
|   // Group 3: 1-byte types grouped together |   // BLE advertisement batching | ||||||
|  |   std::vector<api::BluetoothLERawAdvertisement> advertisement_pool_; | ||||||
|  |   std::unique_ptr<api::BluetoothLERawAdvertisementsResponse> response_; | ||||||
|  |  | ||||||
|  |   // Group 3: 4-byte types | ||||||
|  |   uint32_t last_advertisement_flush_time_{0}; | ||||||
|  |  | ||||||
|  |   // Group 4: 1-byte types grouped together | ||||||
|   bool active_; |   bool active_; | ||||||
|   bool raw_advertisements_{false}; |   uint8_t advertisement_count_{0}; | ||||||
|   // 2 bytes used, 2 bytes padding |   // 2 bytes used, 2 bytes padding | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ | |||||||
| CODEOWNERS = ["@esphome/core"] | CODEOWNERS = ["@esphome/core"] | ||||||
|  |  | ||||||
| CONF_BYTE_ORDER = "byte_order" | CONF_BYTE_ORDER = "byte_order" | ||||||
|  | CONF_COLOR_DEPTH = "color_depth" | ||||||
| CONF_DRAW_ROUNDING = "draw_rounding" | CONF_DRAW_ROUNDING = "draw_rounding" | ||||||
| CONF_ON_STATE_CHANGE = "on_state_change" | CONF_ON_STATE_CHANGE = "on_state_change" | ||||||
| CONF_REQUEST_HEADERS = "request_headers" | CONF_REQUEST_HEADERS = "request_headers" | ||||||
|   | |||||||
| @@ -53,6 +53,7 @@ void DebugComponent::on_shutdown() { | |||||||
|   auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); |   auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); | ||||||
|   if (component != nullptr) { |   if (component != nullptr) { | ||||||
|     strncpy(buffer, component->get_component_source(), REBOOT_MAX_LEN - 1); |     strncpy(buffer, component->get_component_source(), REBOOT_MAX_LEN - 1); | ||||||
|  |     buffer[REBOOT_MAX_LEN - 1] = '\0'; | ||||||
|   } |   } | ||||||
|   ESP_LOGD(TAG, "Storing reboot source: %s", buffer); |   ESP_LOGD(TAG, "Storing reboot source: %s", buffer); | ||||||
|   pref.save(&buffer); |   pref.save(&buffer); | ||||||
| @@ -68,6 +69,7 @@ std::string DebugComponent::get_reset_reason_() { | |||||||
|       auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); |       auto pref = global_preferences->make_preference(REBOOT_MAX_LEN, fnv1_hash(REBOOT_KEY + App.get_name())); | ||||||
|       char buffer[REBOOT_MAX_LEN]{}; |       char buffer[REBOOT_MAX_LEN]{}; | ||||||
|       if (pref.load(&buffer)) { |       if (pref.load(&buffer)) { | ||||||
|  |         buffer[REBOOT_MAX_LEN - 1] = '\0'; | ||||||
|         reset_reason = "Reboot request from " + std::string(buffer); |         reset_reason = "Reboot request from " + std::string(buffer); | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| from esphome import automation, pins | from esphome import automation, pins | ||||||
| import esphome.codegen as cg | import esphome.codegen as cg | ||||||
| from esphome.components import time | from esphome.components import esp32, time | ||||||
| from esphome.components.esp32 import get_esp32_variant | from esphome.components.esp32 import get_esp32_variant | ||||||
| from esphome.components.esp32.const import ( | from esphome.components.esp32.const import ( | ||||||
|     VARIANT_ESP32, |     VARIANT_ESP32, | ||||||
| @@ -116,12 +116,20 @@ def validate_pin_number(value): | |||||||
|     return value |     return value | ||||||
|  |  | ||||||
|  |  | ||||||
| def validate_config(config): | def _validate_ex1_wakeup_mode(value): | ||||||
|     if get_esp32_variant() == VARIANT_ESP32C3 and CONF_ESP32_EXT1_WAKEUP in config: |     if value == "ALL_LOW": | ||||||
|         raise cv.Invalid("ESP32-C3 does not support wakeup from touch.") |         esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) | ||||||
|     if get_esp32_variant() == VARIANT_ESP32C3 and CONF_TOUCH_WAKEUP in config: |     if value == "ANY_LOW": | ||||||
|         raise cv.Invalid("ESP32-C3 does not support wakeup from ext1") |         esp32.only_on_variant( | ||||||
|     return config |             supported=[ | ||||||
|  |                 VARIANT_ESP32S2, | ||||||
|  |                 VARIANT_ESP32S3, | ||||||
|  |                 VARIANT_ESP32C6, | ||||||
|  |                 VARIANT_ESP32H2, | ||||||
|  |             ], | ||||||
|  |             msg_prefix="ANY_LOW", | ||||||
|  |         )(value) | ||||||
|  |     return value | ||||||
|  |  | ||||||
|  |  | ||||||
| deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") | deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") | ||||||
| @@ -148,6 +156,7 @@ WAKEUP_PIN_MODES = { | |||||||
| esp_sleep_ext1_wakeup_mode_t = cg.global_ns.enum("esp_sleep_ext1_wakeup_mode_t") | esp_sleep_ext1_wakeup_mode_t = cg.global_ns.enum("esp_sleep_ext1_wakeup_mode_t") | ||||||
| Ext1Wakeup = deep_sleep_ns.struct("Ext1Wakeup") | Ext1Wakeup = deep_sleep_ns.struct("Ext1Wakeup") | ||||||
| EXT1_WAKEUP_MODES = { | EXT1_WAKEUP_MODES = { | ||||||
|  |     "ANY_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_LOW, | ||||||
|     "ALL_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ALL_LOW, |     "ALL_LOW": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ALL_LOW, | ||||||
|     "ANY_HIGH": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_HIGH, |     "ANY_HIGH": esp_sleep_ext1_wakeup_mode_t.ESP_EXT1_WAKEUP_ANY_HIGH, | ||||||
| } | } | ||||||
| @@ -187,16 +196,28 @@ CONFIG_SCHEMA = cv.All( | |||||||
|             ), |             ), | ||||||
|             cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( |             cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( | ||||||
|                 cv.only_on_esp32, |                 cv.only_on_esp32, | ||||||
|  |                 esp32.only_on_variant( | ||||||
|  |                     unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from ext1" | ||||||
|  |                 ), | ||||||
|                 cv.Schema( |                 cv.Schema( | ||||||
|                     { |                     { | ||||||
|                         cv.Required(CONF_PINS): cv.ensure_list( |                         cv.Required(CONF_PINS): cv.ensure_list( | ||||||
|                             pins.internal_gpio_input_pin_schema, validate_pin_number |                             pins.internal_gpio_input_pin_schema, validate_pin_number | ||||||
|                         ), |                         ), | ||||||
|                         cv.Required(CONF_MODE): cv.enum(EXT1_WAKEUP_MODES, upper=True), |                         cv.Required(CONF_MODE): cv.All( | ||||||
|  |                             cv.enum(EXT1_WAKEUP_MODES, upper=True), | ||||||
|  |                             _validate_ex1_wakeup_mode, | ||||||
|  |                         ), | ||||||
|                     } |                     } | ||||||
|                 ), |                 ), | ||||||
|             ), |             ), | ||||||
|             cv.Optional(CONF_TOUCH_WAKEUP): cv.All(cv.only_on_esp32, cv.boolean), |             cv.Optional(CONF_TOUCH_WAKEUP): cv.All( | ||||||
|  |                 cv.only_on_esp32, | ||||||
|  |                 esp32.only_on_variant( | ||||||
|  |                     unsupported=[VARIANT_ESP32C3], msg_prefix="Wakeup from touch" | ||||||
|  |                 ), | ||||||
|  |                 cv.boolean, | ||||||
|  |             ), | ||||||
|         } |         } | ||||||
|     ).extend(cv.COMPONENT_SCHEMA), |     ).extend(cv.COMPONENT_SCHEMA), | ||||||
|     cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), |     cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ from esphome.const import ( | |||||||
|     CONF_MODE, |     CONF_MODE, | ||||||
|     CONF_NUMBER, |     CONF_NUMBER, | ||||||
|     CONF_ON_VALUE, |     CONF_ON_VALUE, | ||||||
|  |     CONF_SWITCH, | ||||||
|     CONF_TEXT, |     CONF_TEXT, | ||||||
|     CONF_TRIGGER_ID, |     CONF_TRIGGER_ID, | ||||||
|     CONF_TYPE, |     CONF_TYPE, | ||||||
| @@ -33,7 +34,6 @@ CONF_LABEL = "label" | |||||||
| CONF_MENU = "menu" | CONF_MENU = "menu" | ||||||
| CONF_BACK = "back" | CONF_BACK = "back" | ||||||
| CONF_SELECT = "select" | CONF_SELECT = "select" | ||||||
| CONF_SWITCH = "switch" |  | ||||||
| CONF_ON_TEXT = "on_text" | CONF_ON_TEXT = "on_text" | ||||||
| CONF_OFF_TEXT = "off_text" | CONF_OFF_TEXT = "off_text" | ||||||
| CONF_VALUE_LAMBDA = "value_lambda" | CONF_VALUE_LAMBDA = "value_lambda" | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ | |||||||
| #include "esphome/components/network/ip_address.h" | #include "esphome/components/network/ip_address.h" | ||||||
| #include "esphome/core/log.h" | #include "esphome/core/log.h" | ||||||
| #include "esphome/core/util.h" | #include "esphome/core/util.h" | ||||||
|  | #include "esphome/core/helpers.h" | ||||||
|  |  | ||||||
| #include <lwip/igmp.h> | #include <lwip/igmp.h> | ||||||
| #include <lwip/init.h> | #include <lwip/init.h> | ||||||
| @@ -71,7 +72,11 @@ bool E131Component::join_igmp_groups_() { | |||||||
|     ip4_addr_t multicast_addr = |     ip4_addr_t multicast_addr = | ||||||
|         network::IPAddress(239, 255, ((universe.first >> 8) & 0xff), ((universe.first >> 0) & 0xff)); |         network::IPAddress(239, 255, ((universe.first >> 8) & 0xff), ((universe.first >> 0) & 0xff)); | ||||||
|  |  | ||||||
|     auto err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); |     err_t err; | ||||||
|  |     { | ||||||
|  |       LwIPLock lock; | ||||||
|  |       err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     if (err) { |     if (err) { | ||||||
|       ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe.first); |       ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe.first); | ||||||
| @@ -104,6 +109,7 @@ void E131Component::leave_(int universe) { | |||||||
|   if (listen_method_ == E131_MULTICAST) { |   if (listen_method_ == E131_MULTICAST) { | ||||||
|     ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff)); |     ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff)); | ||||||
|  |  | ||||||
|  |     LwIPLock lock; | ||||||
|     igmp_leavegroup(IP4_ADDR_ANY4, &multicast_addr); |     igmp_leavegroup(IP4_ADDR_ANY4, &multicast_addr); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -31,6 +31,7 @@ from esphome.const import ( | |||||||
|     KEY_TARGET_FRAMEWORK, |     KEY_TARGET_FRAMEWORK, | ||||||
|     KEY_TARGET_PLATFORM, |     KEY_TARGET_PLATFORM, | ||||||
|     PLATFORM_ESP32, |     PLATFORM_ESP32, | ||||||
|  |     CoreModel, | ||||||
|     __version__, |     __version__, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, HexInt, TimePeriod | from esphome.core import CORE, HexInt, TimePeriod | ||||||
| @@ -39,7 +40,7 @@ import esphome.final_validate as fv | |||||||
| from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed | from esphome.helpers import copy_file_if_changed, mkdir_p, write_file_if_changed | ||||||
| from esphome.types import ConfigType | from esphome.types import ConfigType | ||||||
|  |  | ||||||
| from .boards import BOARDS | from .boards import BOARDS, STANDARD_BOARDS | ||||||
| from .const import (  # noqa | from .const import (  # noqa | ||||||
|     KEY_BOARD, |     KEY_BOARD, | ||||||
|     KEY_COMPONENTS, |     KEY_COMPONENTS, | ||||||
| @@ -189,7 +190,7 @@ def get_download_types(storage_json): | |||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| def only_on_variant(*, supported=None, unsupported=None): | def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This feature"): | ||||||
|     """Config validator for features only available on some ESP32 variants.""" |     """Config validator for features only available on some ESP32 variants.""" | ||||||
|     if supported is not None and not isinstance(supported, list): |     if supported is not None and not isinstance(supported, list): | ||||||
|         supported = [supported] |         supported = [supported] | ||||||
| @@ -200,11 +201,11 @@ def only_on_variant(*, supported=None, unsupported=None): | |||||||
|         variant = get_esp32_variant() |         variant = get_esp32_variant() | ||||||
|         if supported is not None and variant not in supported: |         if supported is not None and variant not in supported: | ||||||
|             raise cv.Invalid( |             raise cv.Invalid( | ||||||
|                 f"This feature is only available on {', '.join(supported)}" |                 f"{msg_prefix} is only available on {', '.join(supported)}" | ||||||
|             ) |             ) | ||||||
|         if unsupported is not None and variant in unsupported: |         if unsupported is not None and variant in unsupported: | ||||||
|             raise cv.Invalid( |             raise cv.Invalid( | ||||||
|                 f"This feature is not available on {', '.join(unsupported)}" |                 f"{msg_prefix} is not available on {', '.join(unsupported)}" | ||||||
|             ) |             ) | ||||||
|         return obj |         return obj | ||||||
|  |  | ||||||
| @@ -487,25 +488,32 @@ def _platform_is_platformio(value): | |||||||
|  |  | ||||||
|  |  | ||||||
| def _detect_variant(value): | def _detect_variant(value): | ||||||
|     board = value[CONF_BOARD] |     board = value.get(CONF_BOARD) | ||||||
|     if board in BOARDS: |     variant = value.get(CONF_VARIANT) | ||||||
|         variant = BOARDS[board][KEY_VARIANT] |     if variant and board is None: | ||||||
|         if CONF_VARIANT in value and variant != value[CONF_VARIANT]: |         # 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]: | ||||||
|             raise cv.Invalid( |             raise cv.Invalid( | ||||||
|                 f"Option '{CONF_VARIANT}' does not match selected board.", |                 f"Option '{CONF_VARIANT}' does not match selected board.", | ||||||
|                 path=[CONF_VARIANT], |                 path=[CONF_VARIANT], | ||||||
|             ) |             ) | ||||||
|         value = value.copy() |         value = value.copy() | ||||||
|         value[CONF_VARIANT] = variant |         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: |     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( |         _LOGGER.warning( | ||||||
|             "This board is unknown. Make sure the chosen chip component is correct.", |             "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", | ||||||
|  |             variant, | ||||||
|         ) |         ) | ||||||
|     return value |     return value | ||||||
|  |  | ||||||
| @@ -676,7 +684,7 @@ CONF_PARTITIONS = "partitions" | |||||||
| CONFIG_SCHEMA = cv.All( | CONFIG_SCHEMA = cv.All( | ||||||
|     cv.Schema( |     cv.Schema( | ||||||
|         { |         { | ||||||
|             cv.Required(CONF_BOARD): cv.string_strict, |             cv.Optional(CONF_BOARD): cv.string_strict, | ||||||
|             cv.Optional(CONF_CPU_FREQUENCY): cv.one_of( |             cv.Optional(CONF_CPU_FREQUENCY): cv.one_of( | ||||||
|                 *FULL_CPU_FREQUENCIES, upper=True |                 *FULL_CPU_FREQUENCIES, upper=True | ||||||
|             ), |             ), | ||||||
| @@ -691,6 +699,7 @@ CONFIG_SCHEMA = cv.All( | |||||||
|     _detect_variant, |     _detect_variant, | ||||||
|     _set_default_framework, |     _set_default_framework, | ||||||
|     set_core_data, |     set_core_data, | ||||||
|  |     cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -705,8 +714,10 @@ async def to_code(config): | |||||||
|     cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) |     cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) | ||||||
|     cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{config[CONF_VARIANT]}") |     cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{config[CONF_VARIANT]}") | ||||||
|     cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[config[CONF_VARIANT]]) |     cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[config[CONF_VARIANT]]) | ||||||
|  |     cg.add_define(CoreModel.MULTI_ATOMICS) | ||||||
|  |  | ||||||
|     cg.add_platformio_option("lib_ldf_mode", "off") |     cg.add_platformio_option("lib_ldf_mode", "off") | ||||||
|  |     cg.add_platformio_option("lib_compat_mode", "strict") | ||||||
|  |  | ||||||
|     framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] |     framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,13 +2,30 @@ from .const import ( | |||||||
|     VARIANT_ESP32, |     VARIANT_ESP32, | ||||||
|     VARIANT_ESP32C2, |     VARIANT_ESP32C2, | ||||||
|     VARIANT_ESP32C3, |     VARIANT_ESP32C3, | ||||||
|  |     VARIANT_ESP32C5, | ||||||
|     VARIANT_ESP32C6, |     VARIANT_ESP32C6, | ||||||
|     VARIANT_ESP32H2, |     VARIANT_ESP32H2, | ||||||
|     VARIANT_ESP32P4, |     VARIANT_ESP32P4, | ||||||
|     VARIANT_ESP32S2, |     VARIANT_ESP32S2, | ||||||
|     VARIANT_ESP32S3, |     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 = { | ESP32_BASE_PINS = { | ||||||
|     "TX": 1, |     "TX": 1, | ||||||
|     "RX": 3, |     "RX": 3, | ||||||
|   | |||||||
| @@ -114,7 +114,6 @@ void ESP32InternalGPIOPin::setup() { | |||||||
|   if (flags_ & gpio::FLAG_OUTPUT) { |   if (flags_ & gpio::FLAG_OUTPUT) { | ||||||
|     gpio_set_drive_capability(pin_, drive_strength_); |     gpio_set_drive_capability(pin_, drive_strength_); | ||||||
|   } |   } | ||||||
|   ESP_LOGD(TAG, "rtc: %d", SOC_GPIO_SUPPORT_RTC_INDEPENDENT); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { | void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| #include "esphome/core/helpers.h" | #include "esphome/core/helpers.h" | ||||||
|  | #include "esphome/core/defines.h" | ||||||
|  |  | ||||||
| #ifdef USE_ESP32 | #ifdef USE_ESP32 | ||||||
|  |  | ||||||
| @@ -30,6 +31,45 @@ void Mutex::unlock() { xSemaphoreGive(this->handle_); } | |||||||
| IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } | IRAM_ATTR InterruptLock::InterruptLock() { portDISABLE_INTERRUPTS(); } | ||||||
| IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); } | IRAM_ATTR InterruptLock::~InterruptLock() { portENABLE_INTERRUPTS(); } | ||||||
|  |  | ||||||
|  | #ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING | ||||||
|  | #include "lwip/priv/tcpip_priv.h" | ||||||
|  | #endif | ||||||
|  |  | ||||||
|  | LwIPLock::LwIPLock() { | ||||||
|  | #ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING | ||||||
|  |   // When CONFIG_LWIP_TCPIP_CORE_LOCKING is enabled, lwIP uses a global mutex to protect | ||||||
|  |   // its internal state. Any thread can take this lock to safely access lwIP APIs. | ||||||
|  |   // | ||||||
|  |   // sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) returns true if the current thread | ||||||
|  |   // already holds the lwIP core lock. This prevents recursive locking attempts and | ||||||
|  |   // allows nested LwIPLock instances to work correctly. | ||||||
|  |   // | ||||||
|  |   // If we don't already hold the lock, acquire it. This will block until the lock | ||||||
|  |   // is available if another thread currently holds it. | ||||||
|  |   if (!sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) { | ||||||
|  |     LOCK_TCPIP_CORE(); | ||||||
|  |   } | ||||||
|  | #endif | ||||||
|  | } | ||||||
|  |  | ||||||
|  | LwIPLock::~LwIPLock() { | ||||||
|  | #ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING | ||||||
|  |   // Only release the lwIP core lock if this thread currently holds it. | ||||||
|  |   // | ||||||
|  |   // sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) queries lwIP's internal lock | ||||||
|  |   // ownership tracking. It returns true only if the current thread is registered | ||||||
|  |   // as the lock holder. | ||||||
|  |   // | ||||||
|  |   // This check is essential because: | ||||||
|  |   // 1. We may not have acquired the lock in the constructor (if we already held it) | ||||||
|  |   // 2. The lock might have been released by other means between constructor and destructor | ||||||
|  |   // 3. Calling UNLOCK_TCPIP_CORE() without holding the lock causes undefined behavior | ||||||
|  |   if (sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER)) { | ||||||
|  |     UNLOCK_TCPIP_CORE(); | ||||||
|  |   } | ||||||
|  | #endif | ||||||
|  | } | ||||||
|  |  | ||||||
| void get_mac_address_raw(uint8_t *mac) {  // NOLINT(readability-non-const-parameter) | void get_mac_address_raw(uint8_t *mac) {  // NOLINT(readability-non-const-parameter) | ||||||
| #if defined(CONFIG_SOC_IEEE802154_SUPPORTED) | #if defined(CONFIG_SOC_IEEE802154_SUPPORTED) | ||||||
|   // When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default |   // When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default | ||||||
|   | |||||||
| @@ -105,6 +105,7 @@ void BLEClientBase::dump_config() { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
| bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { | bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { | ||||||
|   if (!this->auto_connect_) |   if (!this->auto_connect_) | ||||||
|     return false; |     return false; | ||||||
| @@ -122,6 +123,7 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { | |||||||
|   this->remote_addr_type_ = device.get_address_type(); |   this->remote_addr_type_ = device.get_address_type(); | ||||||
|   return true; |   return true; | ||||||
| } | } | ||||||
|  | #endif | ||||||
|  |  | ||||||
| void BLEClientBase::connect() { | void BLEClientBase::connect() { | ||||||
|   ESP_LOGI(TAG, "[%d] [%s] 0x%02x Attempting BLE connection", this->connection_index_, this->address_str_.c_str(), |   ESP_LOGI(TAG, "[%d] [%s] 0x%02x Attempting BLE connection", this->connection_index_, this->address_str_.c_str(), | ||||||
|   | |||||||
| @@ -31,7 +31,9 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { | |||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|  |  | ||||||
|   void run_later(std::function<void()> &&f);  // NOLINT |   void run_later(std::function<void()> &&f);  // NOLINT | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
|   bool parse_device(const espbt::ESPBTDevice &device) override; |   bool parse_device(const espbt::ESPBTDevice &device) override; | ||||||
|  | #endif | ||||||
|   void on_scan_end() override {} |   void on_scan_end() override {} | ||||||
|   bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, |   bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, | ||||||
|                            esp_ble_gattc_cb_param_t *param) override; |                            esp_ble_gattc_cb_param_t *param) override; | ||||||
|   | |||||||
| @@ -31,6 +31,8 @@ from esphome.const import ( | |||||||
|     CONF_TRIGGER_ID, |     CONF_TRIGGER_ID, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE | from esphome.core import CORE | ||||||
|  | from esphome.enum import StrEnum | ||||||
|  | from esphome.types import ConfigType | ||||||
|  |  | ||||||
| AUTO_LOAD = ["esp32_ble"] | AUTO_LOAD = ["esp32_ble"] | ||||||
| DEPENDENCIES = ["esp32"] | DEPENDENCIES = ["esp32"] | ||||||
| @@ -50,6 +52,25 @@ IDF_MAX_CONNECTIONS = 9 | |||||||
|  |  | ||||||
| _LOGGER = logging.getLogger(__name__) | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Enum for BLE features | ||||||
|  | class BLEFeatures(StrEnum): | ||||||
|  |     ESP_BT_DEVICE = "ESP_BT_DEVICE" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Set to track which features are needed by components | ||||||
|  | _required_features: set[BLEFeatures] = set() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def register_ble_features(features: set[BLEFeatures]) -> None: | ||||||
|  |     """Register BLE features that a component needs. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         features: Set of BLEFeatures enum members | ||||||
|  |     """ | ||||||
|  |     _required_features.update(features) | ||||||
|  |  | ||||||
|  |  | ||||||
| esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") | esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") | ||||||
| ESP32BLETracker = esp32_ble_tracker_ns.class_( | ESP32BLETracker = esp32_ble_tracker_ns.class_( | ||||||
|     "ESP32BLETracker", |     "ESP32BLETracker", | ||||||
| @@ -277,6 +298,15 @@ async def to_code(config): | |||||||
|     cg.add(var.set_scan_window(int(params[CONF_WINDOW].total_milliseconds / 0.625))) |     cg.add(var.set_scan_window(int(params[CONF_WINDOW].total_milliseconds / 0.625))) | ||||||
|     cg.add(var.set_scan_active(params[CONF_ACTIVE])) |     cg.add(var.set_scan_active(params[CONF_ACTIVE])) | ||||||
|     cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS])) |     cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS])) | ||||||
|  |  | ||||||
|  |     # Register ESP_BT_DEVICE feature if any of the automation triggers are used | ||||||
|  |     if ( | ||||||
|  |         config.get(CONF_ON_BLE_ADVERTISE) | ||||||
|  |         or config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE) | ||||||
|  |         or config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE) | ||||||
|  |     ): | ||||||
|  |         register_ble_features({BLEFeatures.ESP_BT_DEVICE}) | ||||||
|  |  | ||||||
|     for conf in config.get(CONF_ON_BLE_ADVERTISE, []): |     for conf in config.get(CONF_ON_BLE_ADVERTISE, []): | ||||||
|         trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) |         trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) | ||||||
|         if CONF_MAC_ADDRESS in conf: |         if CONF_MAC_ADDRESS in conf: | ||||||
| @@ -334,6 +364,11 @@ async def to_code(config): | |||||||
|  |  | ||||||
|     cg.add_define("USE_OTA_STATE_CALLBACK")  # To be notified when an OTA update starts |     cg.add_define("USE_OTA_STATE_CALLBACK")  # To be notified when an OTA update starts | ||||||
|     cg.add_define("USE_ESP32_BLE_CLIENT") |     cg.add_define("USE_ESP32_BLE_CLIENT") | ||||||
|  |  | ||||||
|  |     # Add feature-specific defines based on what's needed | ||||||
|  |     if BLEFeatures.ESP_BT_DEVICE in _required_features: | ||||||
|  |         cg.add_define("USE_ESP32_BLE_DEVICE") | ||||||
|  |  | ||||||
|     if config.get(CONF_SOFTWARE_COEXISTENCE): |     if config.get(CONF_SOFTWARE_COEXISTENCE): | ||||||
|         cg.add_define("USE_ESP32_BLE_SOFTWARE_COEXISTENCE") |         cg.add_define("USE_ESP32_BLE_SOFTWARE_COEXISTENCE") | ||||||
|  |  | ||||||
| @@ -382,13 +417,43 @@ async def esp32_ble_tracker_stop_scan_action_to_code( | |||||||
|     return var |     return var | ||||||
|  |  | ||||||
|  |  | ||||||
| async def register_ble_device(var, config): | async def register_ble_device( | ||||||
|  |     var: cg.SafeExpType, config: ConfigType | ||||||
|  | ) -> cg.SafeExpType: | ||||||
|  |     register_ble_features({BLEFeatures.ESP_BT_DEVICE}) | ||||||
|     paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) |     paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) | ||||||
|     cg.add(paren.register_listener(var)) |     cg.add(paren.register_listener(var)) | ||||||
|     return var |     return var | ||||||
|  |  | ||||||
|  |  | ||||||
| async def register_client(var, config): | async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType: | ||||||
|  |     register_ble_features({BLEFeatures.ESP_BT_DEVICE}) | ||||||
|  |     paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) | ||||||
|  |     cg.add(paren.register_client(var)) | ||||||
|  |     return var | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def register_raw_ble_device( | ||||||
|  |     var: cg.SafeExpType, config: ConfigType | ||||||
|  | ) -> cg.SafeExpType: | ||||||
|  |     """Register a BLE device listener that only needs raw advertisement data. | ||||||
|  |  | ||||||
|  |     This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice | ||||||
|  |     will not be compiled in if this is the only registration method used. | ||||||
|  |     """ | ||||||
|  |     paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) | ||||||
|  |     cg.add(paren.register_listener(var)) | ||||||
|  |     return var | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def register_raw_client( | ||||||
|  |     var: cg.SafeExpType, config: ConfigType | ||||||
|  | ) -> cg.SafeExpType: | ||||||
|  |     """Register a BLE client that only needs raw advertisement data. | ||||||
|  |  | ||||||
|  |     This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice | ||||||
|  |     will not be compiled in if this is the only registration method used. | ||||||
|  |     """ | ||||||
|     paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) |     paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) | ||||||
|     cg.add(paren.register_client(var)) |     cg.add(paren.register_client(var)) | ||||||
|     return var |     return var | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ | |||||||
|  |  | ||||||
| namespace esphome { | namespace esphome { | ||||||
| namespace esp32_ble_tracker { | namespace esp32_ble_tracker { | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
| class ESPBTAdvertiseTrigger : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener { | class ESPBTAdvertiseTrigger : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener { | ||||||
|  public: |  public: | ||||||
|   explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } |   explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } | ||||||
| @@ -87,6 +88,7 @@ class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { | |||||||
|   bool parse_device(const ESPBTDevice &device) override { return false; } |   bool parse_device(const ESPBTDevice &device) override { return false; } | ||||||
|   void on_scan_end() override { this->trigger(); } |   void on_scan_end() override { this->trigger(); } | ||||||
| }; | }; | ||||||
|  | #endif  // USE_ESP32_BLE_DEVICE | ||||||
|  |  | ||||||
| template<typename... Ts> class ESP32BLEStartScanAction : public Action<Ts...> { | template<typename... Ts> class ESP32BLEStartScanAction : public Action<Ts...> { | ||||||
|  public: |  public: | ||||||
|   | |||||||
| @@ -128,44 +128,53 @@ void ESP32BLETracker::loop() { | |||||||
|     uint8_t write_idx = this->ring_write_index_.load(std::memory_order_acquire); |     uint8_t write_idx = this->ring_write_index_.load(std::memory_order_acquire); | ||||||
|  |  | ||||||
|     while (read_idx != write_idx) { |     while (read_idx != write_idx) { | ||||||
|       // Process one result at a time directly from ring buffer |       // Calculate how many contiguous results we can process in one batch | ||||||
|       BLEScanResult &scan_result = this->scan_ring_buffer_[read_idx]; |       // If write > read: process all results from read to write | ||||||
|  |       // If write <= read (wraparound): process from read to end of buffer first | ||||||
|  |       size_t batch_size = (write_idx > read_idx) ? (write_idx - read_idx) : (SCAN_RESULT_BUFFER_SIZE - read_idx); | ||||||
|  |  | ||||||
|  |       // Process the batch for raw advertisements | ||||||
|       if (this->raw_advertisements_) { |       if (this->raw_advertisements_) { | ||||||
|         for (auto *listener : this->listeners_) { |         for (auto *listener : this->listeners_) { | ||||||
|           listener->parse_devices(&scan_result, 1); |           listener->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); | ||||||
|         } |         } | ||||||
|         for (auto *client : this->clients_) { |         for (auto *client : this->clients_) { | ||||||
|           client->parse_devices(&scan_result, 1); |           client->parse_devices(&this->scan_ring_buffer_[read_idx], batch_size); | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  |       // Process individual results for parsed advertisements | ||||||
|       if (this->parse_advertisements_) { |       if (this->parse_advertisements_) { | ||||||
|         ESPBTDevice device; | #ifdef USE_ESP32_BLE_DEVICE | ||||||
|         device.parse_scan_rst(scan_result); |         for (size_t i = 0; i < batch_size; i++) { | ||||||
|  |           BLEScanResult &scan_result = this->scan_ring_buffer_[read_idx + i]; | ||||||
|  |           ESPBTDevice device; | ||||||
|  |           device.parse_scan_rst(scan_result); | ||||||
|  |  | ||||||
|         bool found = false; |           bool found = false; | ||||||
|         for (auto *listener : this->listeners_) { |           for (auto *listener : this->listeners_) { | ||||||
|           if (listener->parse_device(device)) |             if (listener->parse_device(device)) | ||||||
|             found = true; |               found = true; | ||||||
|         } |           } | ||||||
|  |  | ||||||
|         for (auto *client : this->clients_) { |           for (auto *client : this->clients_) { | ||||||
|           if (client->parse_device(device)) { |             if (client->parse_device(device)) { | ||||||
|             found = true; |               found = true; | ||||||
|             if (!connecting && client->state() == ClientState::DISCOVERED) { |               if (!connecting && client->state() == ClientState::DISCOVERED) { | ||||||
|               promote_to_connecting = true; |                 promote_to_connecting = true; | ||||||
|  |               } | ||||||
|             } |             } | ||||||
|           } |           } | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if (!found && !this->scan_continuous_) { |           if (!found && !this->scan_continuous_) { | ||||||
|           this->print_bt_device_info(device); |             this->print_bt_device_info(device); | ||||||
|  |           } | ||||||
|         } |         } | ||||||
|  | #endif  // USE_ESP32_BLE_DEVICE | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // Move to next entry in ring buffer |       // Update read index for entire batch | ||||||
|       read_idx = (read_idx + 1) % SCAN_RESULT_BUFFER_SIZE; |       read_idx = (read_idx + batch_size) % SCAN_RESULT_BUFFER_SIZE; | ||||||
|  |  | ||||||
|       // Store with release to ensure reads complete before index update |       // Store with release to ensure reads complete before index update | ||||||
|       this->ring_read_index_.store(read_idx, std::memory_order_release); |       this->ring_read_index_.store(read_idx, std::memory_order_release); | ||||||
| @@ -511,6 +520,7 @@ void ESP32BLETracker::set_scanner_state_(ScannerState state) { | |||||||
|   this->scanner_state_callbacks_.call(state); |   this->scanner_state_callbacks_.call(state); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
| ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(beacon_data_)); } | ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(beacon_data_)); } | ||||||
| optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { | optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { | ||||||
|   if (!data.uuid.contains(0x4C, 0x00)) |   if (!data.uuid.contains(0x4C, 0x00)) | ||||||
| @@ -751,13 +761,16 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| std::string ESPBTDevice::address_str() const { | std::string ESPBTDevice::address_str() const { | ||||||
|   char mac[24]; |   char mac[24]; | ||||||
|   snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X", this->address_[0], this->address_[1], this->address_[2], |   snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X", this->address_[0], this->address_[1], this->address_[2], | ||||||
|            this->address_[3], this->address_[4], this->address_[5]); |            this->address_[3], this->address_[4], this->address_[5]); | ||||||
|   return mac; |   return mac; | ||||||
| } | } | ||||||
|  |  | ||||||
| uint64_t ESPBTDevice::address_uint64() const { return esp32_ble::ble_addr_to_uint64(this->address_); } | uint64_t ESPBTDevice::address_uint64() const { return esp32_ble::ble_addr_to_uint64(this->address_); } | ||||||
|  | #endif  // USE_ESP32_BLE_DEVICE | ||||||
|  |  | ||||||
| void ESP32BLETracker::dump_config() { | void ESP32BLETracker::dump_config() { | ||||||
|   ESP_LOGCONFIG(TAG, "BLE Tracker:"); |   ESP_LOGCONFIG(TAG, "BLE Tracker:"); | ||||||
| @@ -796,6 +809,7 @@ void ESP32BLETracker::dump_config() { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
| void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { | void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { | ||||||
|   const uint64_t address = device.address_uint64(); |   const uint64_t address = device.address_uint64(); | ||||||
|   for (auto &disc : this->already_discovered_) { |   for (auto &disc : this->already_discovered_) { | ||||||
| @@ -866,8 +880,9 @@ bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { | |||||||
|   return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && |   return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && | ||||||
|          ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); |          ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); | ||||||
| } | } | ||||||
|  | #endif  // USE_ESP32_BLE_DEVICE | ||||||
|  |  | ||||||
| }  // namespace esp32_ble_tracker | }  // namespace esp32_ble_tracker | ||||||
| }  // namespace esphome | }  // namespace esphome | ||||||
|  |  | ||||||
| #endif | #endif  // USE_ESP32 | ||||||
|   | |||||||
| @@ -39,6 +39,7 @@ struct ServiceData { | |||||||
|   adv_data_t data; |   adv_data_t data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
| class ESPBLEiBeacon { | class ESPBLEiBeacon { | ||||||
|  public: |  public: | ||||||
|   ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } |   ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } | ||||||
| @@ -116,13 +117,16 @@ class ESPBTDevice { | |||||||
|   std::vector<ServiceData> service_datas_{}; |   std::vector<ServiceData> service_datas_{}; | ||||||
|   const BLEScanResult *scan_result_{nullptr}; |   const BLEScanResult *scan_result_{nullptr}; | ||||||
| }; | }; | ||||||
|  | #endif  // USE_ESP32_BLE_DEVICE | ||||||
|  |  | ||||||
| class ESP32BLETracker; | class ESP32BLETracker; | ||||||
|  |  | ||||||
| class ESPBTDeviceListener { | class ESPBTDeviceListener { | ||||||
|  public: |  public: | ||||||
|   virtual void on_scan_end() {} |   virtual void on_scan_end() {} | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
|   virtual bool parse_device(const ESPBTDevice &device) = 0; |   virtual bool parse_device(const ESPBTDevice &device) = 0; | ||||||
|  | #endif | ||||||
|   virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; |   virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; | ||||||
|   virtual AdvertisementParserType get_advertisement_parser_type() { |   virtual AdvertisementParserType get_advertisement_parser_type() { | ||||||
|     return AdvertisementParserType::PARSED_ADVERTISEMENTS; |     return AdvertisementParserType::PARSED_ADVERTISEMENTS; | ||||||
| @@ -237,7 +241,9 @@ class ESP32BLETracker : public Component, | |||||||
|   void register_client(ESPBTClient *client); |   void register_client(ESPBTClient *client); | ||||||
|   void recalculate_advertisement_parser_types(); |   void recalculate_advertisement_parser_types(); | ||||||
|  |  | ||||||
|  | #ifdef USE_ESP32_BLE_DEVICE | ||||||
|   void print_bt_device_info(const ESPBTDevice &device); |   void print_bt_device_info(const ESPBTDevice &device); | ||||||
|  | #endif | ||||||
|  |  | ||||||
|   void start_scan(); |   void start_scan(); | ||||||
|   void stop_scan(); |   void stop_scan(); | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | import logging | ||||||
|  |  | ||||||
| from esphome import automation, pins | from esphome import automation, pins | ||||||
| import esphome.codegen as cg | import esphome.codegen as cg | ||||||
| from esphome.components import i2c | from esphome.components import i2c | ||||||
| @@ -8,6 +10,7 @@ from esphome.const import ( | |||||||
|     CONF_CONTRAST, |     CONF_CONTRAST, | ||||||
|     CONF_DATA_PINS, |     CONF_DATA_PINS, | ||||||
|     CONF_FREQUENCY, |     CONF_FREQUENCY, | ||||||
|  |     CONF_I2C, | ||||||
|     CONF_I2C_ID, |     CONF_I2C_ID, | ||||||
|     CONF_ID, |     CONF_ID, | ||||||
|     CONF_PIN, |     CONF_PIN, | ||||||
| @@ -20,6 +23,9 @@ from esphome.const import ( | |||||||
| ) | ) | ||||||
| from esphome.core import CORE | from esphome.core import CORE | ||||||
| from esphome.core.entity_helpers import setup_entity | from esphome.core.entity_helpers import setup_entity | ||||||
|  | import esphome.final_validate as fv | ||||||
|  |  | ||||||
|  | _LOGGER = logging.getLogger(__name__) | ||||||
|  |  | ||||||
| DEPENDENCIES = ["esp32"] | DEPENDENCIES = ["esp32"] | ||||||
|  |  | ||||||
| @@ -113,6 +119,12 @@ ENUM_SPECIAL_EFFECT = { | |||||||
|     "SEPIA": ESP32SpecialEffect.ESP32_SPECIAL_EFFECT_SEPIA, |     "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 | # pin assignment | ||||||
| CONF_HREF_PIN = "href_pin" | CONF_HREF_PIN = "href_pin" | ||||||
| CONF_PIXEL_CLOCK_PIN = "pixel_clock_pin" | CONF_PIXEL_CLOCK_PIN = "pixel_clock_pin" | ||||||
| @@ -143,6 +155,7 @@ CONF_MAX_FRAMERATE = "max_framerate" | |||||||
| CONF_IDLE_FRAMERATE = "idle_framerate" | CONF_IDLE_FRAMERATE = "idle_framerate" | ||||||
| # frame buffer | # frame buffer | ||||||
| CONF_FRAME_BUFFER_COUNT = "frame_buffer_count" | CONF_FRAME_BUFFER_COUNT = "frame_buffer_count" | ||||||
|  | CONF_FRAME_BUFFER_LOCATION = "frame_buffer_location" | ||||||
|  |  | ||||||
| # stream trigger | # stream trigger | ||||||
| CONF_ON_STREAM_START = "on_stream_start" | CONF_ON_STREAM_START = "on_stream_start" | ||||||
| @@ -224,6 +237,9 @@ CONFIG_SCHEMA = cv.All( | |||||||
|                 cv.framerate, cv.Range(min=0, max=1) |                 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_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.Optional(CONF_ON_STREAM_START): automation.validate_automation( | ||||||
|                 { |                 { | ||||||
|                     cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( |                     cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( | ||||||
| @@ -250,6 +266,22 @@ CONFIG_SCHEMA = cv.All( | |||||||
|     cv.has_exactly_one_key(CONF_I2C_PINS, CONF_I2C_ID), |     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 = { | SETTERS = { | ||||||
|     # pin assignment |     # pin assignment | ||||||
|     CONF_DATA_PINS: "set_data_pins", |     CONF_DATA_PINS: "set_data_pins", | ||||||
| @@ -279,6 +311,7 @@ SETTERS = { | |||||||
|     CONF_WB_MODE: "set_wb_mode", |     CONF_WB_MODE: "set_wb_mode", | ||||||
|     # test pattern |     # test pattern | ||||||
|     CONF_TEST_PATTERN: "set_test_pattern", |     CONF_TEST_PATTERN: "set_test_pattern", | ||||||
|  |     CONF_FRAME_BUFFER_LOCATION: "set_frame_buffer_location", | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -306,9 +339,10 @@ async def to_code(config): | |||||||
|     else: |     else: | ||||||
|         cg.add(var.set_idle_update_interval(1000 / config[CONF_IDLE_FRAMERATE])) |         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_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(var.set_frame_size(config[CONF_RESOLUTION])) | ||||||
|  |  | ||||||
|     cg.add_define("USE_ESP32_CAMERA") |     cg.add_define("USE_CAMERA") | ||||||
|  |  | ||||||
|     if CORE.using_esp_idf: |     if CORE.using_esp_idf: | ||||||
|         add_idf_component(name="espressif/esp32-camera", ref="2.0.15") |         add_idf_component(name="espressif/esp32-camera", ref="2.0.15") | ||||||
|   | |||||||
| @@ -133,6 +133,7 @@ void ESP32Camera::dump_config() { | |||||||
|   ESP_LOGCONFIG(TAG, |   ESP_LOGCONFIG(TAG, | ||||||
|                 "  JPEG Quality: %u\n" |                 "  JPEG Quality: %u\n" | ||||||
|                 "  Framebuffer Count: %u\n" |                 "  Framebuffer Count: %u\n" | ||||||
|  |                 "  Framebuffer Location: %s\n" | ||||||
|                 "  Contrast: %d\n" |                 "  Contrast: %d\n" | ||||||
|                 "  Brightness: %d\n" |                 "  Brightness: %d\n" | ||||||
|                 "  Saturation: %d\n" |                 "  Saturation: %d\n" | ||||||
| @@ -140,8 +141,9 @@ void ESP32Camera::dump_config() { | |||||||
|                 "  Horizontal Mirror: %s\n" |                 "  Horizontal Mirror: %s\n" | ||||||
|                 "  Special Effect: %u\n" |                 "  Special Effect: %u\n" | ||||||
|                 "  White Balance Mode: %u", |                 "  White Balance Mode: %u", | ||||||
|                 st.quality, conf.fb_count, st.contrast, st.brightness, st.saturation, ONOFF(st.vflip), |                 st.quality, conf.fb_count, this->config_.fb_location == CAMERA_FB_IN_PSRAM ? "PSRAM" : "DRAM", | ||||||
|                 ONOFF(st.hmirror), st.special_effect, st.wb_mode); |                 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: %u", st.awb); | ||||||
|   // ESP_LOGCONFIG(TAG, "  Auto White Balance Gain: %u", st.awb_gain); |   // ESP_LOGCONFIG(TAG, "  Auto White Balance Gain: %u", st.awb_gain); | ||||||
|   ESP_LOGCONFIG(TAG, |   ESP_LOGCONFIG(TAG, | ||||||
| @@ -350,6 +352,9 @@ void ESP32Camera::set_frame_buffer_count(uint8_t fb_count) { | |||||||
|   this->config_.fb_count = fb_count; |   this->config_.fb_count = fb_count; | ||||||
|   this->set_frame_buffer_mode(fb_count > 1 ? CAMERA_GRAB_LATEST : CAMERA_GRAB_WHEN_EMPTY); |   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) ---------------- */ | /* ---------------- public API (specific) ---------------- */ | ||||||
| void ESP32Camera::add_image_callback(std::function<void(std::shared_ptr<camera::CameraImage>)> &&callback) { | void ESP32Camera::add_image_callback(std::function<void(std::shared_ptr<camera::CameraImage>)> &&callback) { | ||||||
|   | |||||||
| @@ -152,6 +152,7 @@ class ESP32Camera : public camera::Camera { | |||||||
|   /* -- frame buffer */ |   /* -- frame buffer */ | ||||||
|   void set_frame_buffer_mode(camera_grab_mode_t mode); |   void set_frame_buffer_mode(camera_grab_mode_t mode); | ||||||
|   void set_frame_buffer_count(uint8_t fb_count); |   void set_frame_buffer_count(uint8_t fb_count); | ||||||
|  |   void set_frame_buffer_location(camera_fb_location_t fb_location); | ||||||
|  |  | ||||||
|   /* public API (derivated) */ |   /* public API (derivated) */ | ||||||
|   void setup() override; |   void setup() override; | ||||||
|   | |||||||
| @@ -109,6 +109,7 @@ void ESP32TouchComponent::loop() { | |||||||
|  |  | ||||||
|       // Only publish if state changed - this filters out repeated events |       // Only publish if state changed - this filters out repeated events | ||||||
|       if (new_state != child->last_state_) { |       if (new_state != child->last_state_) { | ||||||
|  |         child->initial_state_published_ = true; | ||||||
|         child->last_state_ = new_state; |         child->last_state_ = new_state; | ||||||
|         child->publish_state(new_state); |         child->publish_state(new_state); | ||||||
|         // Original ESP32: ISR only fires when touched, release is detected by timeout |         // Original ESP32: ISR only fires when touched, release is detected by timeout | ||||||
| @@ -175,6 +176,9 @@ void ESP32TouchComponent::on_shutdown() { | |||||||
| void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { | void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { | ||||||
|   ESP32TouchComponent *component = static_cast<ESP32TouchComponent *>(arg); |   ESP32TouchComponent *component = static_cast<ESP32TouchComponent *>(arg); | ||||||
|  |  | ||||||
|  |   uint32_t mask = 0; | ||||||
|  |   touch_ll_read_trigger_status_mask(&mask); | ||||||
|  |   touch_ll_clear_trigger_status_mask(); | ||||||
|   touch_pad_clear_status(); |   touch_pad_clear_status(); | ||||||
|  |  | ||||||
|   // INTERRUPT BEHAVIOR: On ESP32 v1 hardware, the interrupt fires when ANY configured |   // INTERRUPT BEHAVIOR: On ESP32 v1 hardware, the interrupt fires when ANY configured | ||||||
| @@ -184,6 +188,11 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { | |||||||
|   // as any pad remains touched. This allows us to detect both new touches and |   // as any pad remains touched. This allows us to detect both new touches and | ||||||
|   // continued touches, but releases must be detected by timeout in the main loop. |   // continued touches, but releases must be detected by timeout in the main loop. | ||||||
|  |  | ||||||
|  |   // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2! | ||||||
|  |   // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE | ||||||
|  |   // Therefore: touched = (value < threshold) | ||||||
|  |   // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold) | ||||||
|  |  | ||||||
|   // Process all configured pads to check their current state |   // Process all configured pads to check their current state | ||||||
|   // Note: ESP32 v1 doesn't tell us which specific pad triggered the interrupt, |   // Note: ESP32 v1 doesn't tell us which specific pad triggered the interrupt, | ||||||
|   // so we must scan all configured pads to find which ones were touched |   // so we must scan all configured pads to find which ones were touched | ||||||
| @@ -201,19 +210,12 @@ void IRAM_ATTR ESP32TouchComponent::touch_isr_handler(void *arg) { | |||||||
|       value = touch_ll_read_raw_data(pad); |       value = touch_ll_read_raw_data(pad); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Skip pads with 0 value - they haven't been measured in this cycle |     // Skip pads that aren’t in the trigger mask | ||||||
|     // This is important: not all pads are measured every interrupt cycle, |     bool is_touched = (mask >> pad) & 1; | ||||||
|     // only those that the hardware has updated |     if (!is_touched) { | ||||||
|     if (value == 0) { |  | ||||||
|       continue; |       continue; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // IMPORTANT: ESP32 v1 touch detection logic - INVERTED compared to v2! |  | ||||||
|     // ESP32 v1: Touch is detected when capacitance INCREASES, causing the measured value to DECREASE |  | ||||||
|     // Therefore: touched = (value < threshold) |  | ||||||
|     // This is opposite to ESP32-S2/S3 v2 where touched = (value > threshold) |  | ||||||
|     bool is_touched = value < child->get_threshold(); |  | ||||||
|  |  | ||||||
|     // Always send the current state - the main loop will filter for changes |     // Always send the current state - the main loop will filter for changes | ||||||
|     // We send both touched and untouched states because the ISR doesn't |     // We send both touched and untouched states because the ISR doesn't | ||||||
|     // track previous state (to keep ISR fast and simple) |     // track previous state (to keep ISR fast and simple) | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ from esphome.const import ( | |||||||
|     KEY_TARGET_FRAMEWORK, |     KEY_TARGET_FRAMEWORK, | ||||||
|     KEY_TARGET_PLATFORM, |     KEY_TARGET_PLATFORM, | ||||||
|     PLATFORM_ESP8266, |     PLATFORM_ESP8266, | ||||||
|  |     CoreModel, | ||||||
| ) | ) | ||||||
| from esphome.core import CORE, coroutine_with_priority | from esphome.core import CORE, coroutine_with_priority | ||||||
| from esphome.helpers import copy_file_if_changed | from esphome.helpers import copy_file_if_changed | ||||||
| @@ -180,12 +181,14 @@ async def to_code(config): | |||||||
|     cg.add(esp8266_ns.setup_preferences()) |     cg.add(esp8266_ns.setup_preferences()) | ||||||
|  |  | ||||||
|     cg.add_platformio_option("lib_ldf_mode", "off") |     cg.add_platformio_option("lib_ldf_mode", "off") | ||||||
|  |     cg.add_platformio_option("lib_compat_mode", "strict") | ||||||
|  |  | ||||||
|     cg.add_platformio_option("board", config[CONF_BOARD]) |     cg.add_platformio_option("board", config[CONF_BOARD]) | ||||||
|     cg.add_build_flag("-DUSE_ESP8266") |     cg.add_build_flag("-DUSE_ESP8266") | ||||||
|     cg.set_cpp_standard("gnu++20") |     cg.set_cpp_standard("gnu++20") | ||||||
|     cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) |     cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) | ||||||
|     cg.add_define("ESPHOME_VARIANT", "ESP8266") |     cg.add_define("ESPHOME_VARIANT", "ESP8266") | ||||||
|  |     cg.add_define(CoreModel.SINGLE) | ||||||
|  |  | ||||||
|     cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) |     cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -22,6 +22,10 @@ void Mutex::unlock() {} | |||||||
| IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } | IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } | ||||||
| IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); } | IRAM_ATTR InterruptLock::~InterruptLock() { xt_wsr_ps(state_); } | ||||||
|  |  | ||||||
|  | // ESP8266 doesn't support lwIP core locking, so this is a no-op | ||||||
|  | LwIPLock::LwIPLock() {} | ||||||
|  | LwIPLock::~LwIPLock() {} | ||||||
|  |  | ||||||
| void get_mac_address_raw(uint8_t *mac) {  // NOLINT(readability-non-const-parameter) | void get_mac_address_raw(uint8_t *mac) {  // NOLINT(readability-non-const-parameter) | ||||||
|   wifi_get_macaddr(STATION_IF, mac); |   wifi_get_macaddr(STATION_IF, mac); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -20,14 +20,16 @@ adjusted_ids = set() | |||||||
|  |  | ||||||
| CONFIG_SCHEMA = cv.All( | CONFIG_SCHEMA = cv.All( | ||||||
|     cv.ensure_list( |     cv.ensure_list( | ||||||
|         { |         cv.COMPONENT_SCHEMA.extend( | ||||||
|             cv.GenerateID(): cv.declare_id(EspLdo), |             { | ||||||
|             cv.Required(CONF_VOLTAGE): cv.All( |                 cv.GenerateID(): cv.declare_id(EspLdo), | ||||||
|                 cv.voltage, cv.float_range(min=0.5, max=2.7) |                 cv.Required(CONF_VOLTAGE): cv.All( | ||||||
|             ), |                     cv.voltage, cv.float_range(min=0.5, max=2.7) | ||||||
|             cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True), |                 ), | ||||||
|             cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean, |                 cv.Required(CONF_CHANNEL): cv.one_of(*CHANNELS, int=True), | ||||||
|         } |                 cv.Optional(CONF_ADJUSTABLE, default=False): cv.boolean, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|     ), |     ), | ||||||
|     cv.only_with_esp_idf, |     cv.only_with_esp_idf, | ||||||
|     only_on_variant(supported=[VARIANT_ESP32P4]), |     only_on_variant(supported=[VARIANT_ESP32P4]), | ||||||
|   | |||||||
| @@ -17,6 +17,9 @@ class EspLdo : public Component { | |||||||
|   void set_adjustable(bool adjustable) { this->adjustable_ = adjustable; } |   void set_adjustable(bool adjustable) { this->adjustable_ = adjustable; } | ||||||
|   void set_voltage(float voltage) { this->voltage_ = voltage; } |   void set_voltage(float voltage) { this->voltage_ = voltage; } | ||||||
|   void adjust_voltage(float voltage); |   void adjust_voltage(float voltage); | ||||||
|  |   float get_setup_priority() const override { | ||||||
|  |     return setup_priority::BUS;  // LDO setup should be done early | ||||||
|  |   } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   int channel_; |   int channel_; | ||||||
|   | |||||||
| @@ -342,5 +342,11 @@ async def to_code(config): | |||||||
|  |  | ||||||
|     cg.add_define("USE_ETHERNET") |     cg.add_define("USE_ETHERNET") | ||||||
|  |  | ||||||
|  |     # Disable WiFi when using Ethernet to save memory | ||||||
|  |     if CORE.using_esp_idf: | ||||||
|  |         add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) | ||||||
|  |         # Also disable WiFi/BT coexistence since WiFi is disabled | ||||||
|  |         add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) | ||||||
|  |  | ||||||
|     if CORE.using_arduino: |     if CORE.using_arduino: | ||||||
|         cg.add_library("WiFi", None) |         cg.add_library("WiFi", None) | ||||||
|   | |||||||
| @@ -420,6 +420,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() { | |||||||
| } | } | ||||||
|  |  | ||||||
| network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { | network::IPAddress EthernetComponent::get_dns_address(uint8_t num) { | ||||||
|  |   LwIPLock lock; | ||||||
|   const ip_addr_t *dns_ip = dns_getserver(num); |   const ip_addr_t *dns_ip = dns_getserver(num); | ||||||
|   return dns_ip; |   return dns_ip; | ||||||
| } | } | ||||||
| @@ -527,6 +528,7 @@ void EthernetComponent::start_connect_() { | |||||||
|   ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); |   ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); | ||||||
|  |  | ||||||
|   if (this->manual_ip_.has_value()) { |   if (this->manual_ip_.has_value()) { | ||||||
|  |     LwIPLock lock; | ||||||
|     if (this->manual_ip_->dns1.is_set()) { |     if (this->manual_ip_->dns1.is_set()) { | ||||||
|       ip_addr_t d; |       ip_addr_t d; | ||||||
|       d = this->manual_ip_->dns1; |       d = this->manual_ip_->dns1; | ||||||
| @@ -559,8 +561,13 @@ bool EthernetComponent::is_connected() { return this->state_ == EthernetComponen | |||||||
| void EthernetComponent::dump_connect_params_() { | void EthernetComponent::dump_connect_params_() { | ||||||
|   esp_netif_ip_info_t ip; |   esp_netif_ip_info_t ip; | ||||||
|   esp_netif_get_ip_info(this->eth_netif_, &ip); |   esp_netif_get_ip_info(this->eth_netif_, &ip); | ||||||
|   const ip_addr_t *dns_ip1 = dns_getserver(0); |   const ip_addr_t *dns_ip1; | ||||||
|   const ip_addr_t *dns_ip2 = dns_getserver(1); |   const ip_addr_t *dns_ip2; | ||||||
|  |   { | ||||||
|  |     LwIPLock lock; | ||||||
|  |     dns_ip1 = dns_getserver(0); | ||||||
|  |     dns_ip2 = dns_getserver(1); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   ESP_LOGCONFIG(TAG, |   ESP_LOGCONFIG(TAG, | ||||||
|                 "  IP Address: %s\n" |                 "  IP Address: %s\n" | ||||||
|   | |||||||
| @@ -29,7 +29,6 @@ class IPAddressEthernetInfo : public PollingComponent, public text_sensor::TextS | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   float get_setup_priority() const override { return setup_priority::ETHERNET; } |   float get_setup_priority() const override { return setup_priority::ETHERNET; } | ||||||
|   std::string unique_id() override { return get_mac_address() + "-ethernetinfo"; } |  | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|   void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } |   void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } | ||||||
|  |  | ||||||
| @@ -52,7 +51,6 @@ class DNSAddressEthernetInfo : public PollingComponent, public text_sensor::Text | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   float get_setup_priority() const override { return setup_priority::ETHERNET; } |   float get_setup_priority() const override { return setup_priority::ETHERNET; } | ||||||
|   std::string unique_id() override { return get_mac_address() + "-ethernetinfo-dns"; } |  | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
| @@ -63,7 +61,6 @@ class MACAddressEthernetInfo : public Component, public text_sensor::TextSensor | |||||||
|  public: |  public: | ||||||
|   void setup() override { this->publish_state(ethernet::global_eth_component->get_eth_mac_address_pretty()); } |   void setup() override { this->publish_state(ethernet::global_eth_component->get_eth_mac_address_pretty()); } | ||||||
|   float get_setup_priority() const override { return setup_priority::ETHERNET; } |   float get_setup_priority() const override { return setup_priority::ETHERNET; } | ||||||
|   std::string unique_id() override { return get_mac_address() + "-ethernetinfo-mac"; } |  | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -177,6 +177,10 @@ optional<FanRestoreState> Fan::restore_state_() { | |||||||
|   return {}; |   return {}; | ||||||
| } | } | ||||||
| void Fan::save_state_() { | void Fan::save_state_() { | ||||||
|  |   if (this->restore_mode_ == FanRestoreMode::NO_RESTORE) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   FanRestoreState state{}; |   FanRestoreState state{}; | ||||||
|   state.state = this->state; |   state.state = this->state; | ||||||
|   state.oscillating = this->oscillating; |   state.oscillating = this->oscillating; | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								esphome/components/gl_r01_i2c/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								esphome/components/gl_r01_i2c/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										68
									
								
								esphome/components/gl_r01_i2c/gl_r01_i2c.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								esphome/components/gl_r01_i2c/gl_r01_i2c.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | |||||||
|  | #include "esphome/core/log.h" | ||||||
|  | #include "esphome/core/hal.h" | ||||||
|  | #include "gl_r01_i2c.h" | ||||||
|  |  | ||||||
|  | namespace esphome { | ||||||
|  | namespace gl_r01_i2c { | ||||||
|  |  | ||||||
|  | static const char *const TAG = "gl_r01_i2c"; | ||||||
|  |  | ||||||
|  | // Register definitions from datasheet | ||||||
|  | static const uint8_t REG_VERSION = 0x00; | ||||||
|  | static const uint8_t REG_DISTANCE = 0x02; | ||||||
|  | static const uint8_t REG_TRIGGER = 0x10; | ||||||
|  | static const uint8_t CMD_TRIGGER = 0xB0; | ||||||
|  | static const uint8_t RESTART_CMD1 = 0x5A; | ||||||
|  | static const uint8_t RESTART_CMD2 = 0xA5; | ||||||
|  | static const uint8_t READ_DELAY = 40;  // minimum milliseconds from datasheet to safely read measurement result | ||||||
|  |  | ||||||
|  | void GLR01I2CComponent::setup() { | ||||||
|  |   ESP_LOGCONFIG(TAG, "Setting up GL-R01 I2C..."); | ||||||
|  |   // Verify sensor presence | ||||||
|  |   if (!this->read_byte_16(REG_VERSION, &this->version_)) { | ||||||
|  |     ESP_LOGE(TAG, "Failed to communicate with GL-R01 I2C sensor!"); | ||||||
|  |     this->mark_failed(); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |   ESP_LOGD(TAG, "Found GL-R01 I2C with version 0x%04X", this->version_); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void GLR01I2CComponent::dump_config() { | ||||||
|  |   ESP_LOGCONFIG(TAG, "GL-R01 I2C:"); | ||||||
|  |   ESP_LOGCONFIG(TAG, " Firmware Version: 0x%04X", this->version_); | ||||||
|  |   LOG_I2C_DEVICE(this); | ||||||
|  |   LOG_SENSOR(" ", "Distance", this); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void GLR01I2CComponent::update() { | ||||||
|  |   // Trigger a new measurement | ||||||
|  |   if (!this->write_byte(REG_TRIGGER, CMD_TRIGGER)) { | ||||||
|  |     ESP_LOGE(TAG, "Failed to trigger measurement!"); | ||||||
|  |     this->status_set_warning(); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Schedule reading the result after the read delay | ||||||
|  |   this->set_timeout(READ_DELAY, [this]() { this->read_distance_(); }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | void GLR01I2CComponent::read_distance_() { | ||||||
|  |   uint16_t distance = 0; | ||||||
|  |   if (!this->read_byte_16(REG_DISTANCE, &distance)) { | ||||||
|  |     ESP_LOGE(TAG, "Failed to read distance value!"); | ||||||
|  |     this->status_set_warning(); | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (distance == 0xFFFF) { | ||||||
|  |     ESP_LOGW(TAG, "Invalid measurement received!"); | ||||||
|  |     this->status_set_warning(); | ||||||
|  |   } else { | ||||||
|  |     ESP_LOGV(TAG, "Distance: %umm", distance); | ||||||
|  |     this->publish_state(distance); | ||||||
|  |     this->status_clear_warning(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | }  // namespace gl_r01_i2c | ||||||
|  | }  // namespace esphome | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user