diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 4901c0ccac..2cd4319325 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -049d60eed541730efaa4c0dc5d337b4287bf29b6daa350b5dfc1f23915f1c52f +d7693a1e996cacd4a3d1c9a16336799c2a8cc3db02e4e74084151ce964581248 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a731a1b02..4682a05fea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,8 +114,7 @@ jobs: matrix: python-version: - "3.11" - - "3.12" - - "3.13" + - "3.14" os: - ubuntu-latest - macOS-latest @@ -124,13 +123,9 @@ jobs: # Minimize CI resource usage # by only running the Python version # version used for docker images on Windows and macOS - - python-version: "3.13" + - python-version: "3.14" os: windows-latest - - python-version: "3.12" - os: windows-latest - - python-version: "3.13" - os: macOS-latest - - python-version: "3.12" + - python-version: "3.14" os: macOS-latest runs-on: ${{ matrix.os }} needs: @@ -178,6 +173,7 @@ jobs: python-linters: ${{ steps.determine.outputs.python-linters }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} + directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} component-test-count: ${{ steps.determine.outputs.component-test-count }} memory_impact: ${{ steps.determine.outputs.memory-impact }} steps: @@ -207,6 +203,7 @@ jobs: echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT + echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT @@ -360,48 +357,13 @@ jobs: # yamllint disable-line rule:line-length if: always() - test-build-components: - name: Component test ${{ matrix.file }} - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - 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: - fail-fast: false - max-parallel: 2 - matrix: - file: ${{ fromJson(needs.determine-jobs.outputs.changed-components-with-tests) }} - steps: - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 - with: - packages: libsdl2-dev - version: 1.0 - - - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Validate config for ${{ matrix.file }} - run: | - . venv/bin/activate - python3 script/test_build_components.py -e config -c ${{ matrix.file }} - - name: Compile config for ${{ matrix.file }} - run: | - . venv/bin/activate - python3 script/test_build_components.py -e compile -c ${{ matrix.file }} - test-build-components-splitter: name: Split components for intelligent grouping (40 weighted per batch) runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 + if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 outputs: matrix: ${{ steps.split.outputs.components }} steps: @@ -420,8 +382,18 @@ jobs: # Use intelligent splitter that groups components with same bus configs components='${{ needs.determine-jobs.outputs.changed-components-with-tests }}' + # Only isolate directly changed components when targeting dev branch + # For beta/release branches, group everything for faster CI + if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then + directly_changed='[]' + echo "Target branch: ${{ github.base_ref }} - grouping all components" + else + directly_changed='${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}' + echo "Target branch: ${{ github.base_ref }} - isolating directly changed components" + fi + echo "Splitting components intelligently..." - output=$(python3 script/split_components_for_ci.py --components "$components" --batch-size 40 --output github) + output=$(python3 script/split_components_for_ci.py --components "$components" --directly-changed "$directly_changed" --batch-size 40 --output github) echo "$output" >> $GITHUB_OUTPUT @@ -432,10 +404,10 @@ jobs: - common - determine-jobs - test-build-components-splitter - if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) >= 100 + if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 strategy: fail-fast: false - max-parallel: ${{ (github.base_ref == 'beta' || github.base_ref == 'release') && 8 || 4 }} + max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} matrix: components: ${{ fromJson(needs.test-build-components-splitter.outputs.matrix) }} steps: @@ -463,41 +435,80 @@ jobs: - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate - # Use /mnt for build files (70GB available vs ~29GB on /) - # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) - sudo mkdir -p /mnt/platformio - sudo chown $USER:$USER /mnt/platformio - mkdir -p ~/.platformio - sudo mount --bind /mnt/platformio ~/.platformio - # Bind mount test build directory to /mnt - sudo mkdir -p /mnt/test_build_components_build - sudo chown $USER:$USER /mnt/test_build_components_build - mkdir -p tests/test_build_components/build - sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build + # Check if /mnt has more free space than / before bind mounting + # Extract available space in KB for comparison + root_avail=$(df -k / | awk 'NR==2 {print $4}') + mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}') + + echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB" + + # Only use /mnt if it has more space than / + if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then + echo "Using /mnt for build files (more space available)" + # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) + sudo mkdir -p /mnt/platformio + sudo chown $USER:$USER /mnt/platformio + mkdir -p ~/.platformio + sudo mount --bind /mnt/platformio ~/.platformio + + # Bind mount test build directory to /mnt + sudo mkdir -p /mnt/test_build_components_build + sudo chown $USER:$USER /mnt/test_build_components_build + mkdir -p tests/test_build_components/build + sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build + else + echo "Using / for build files (more space available than /mnt or /mnt unavailable)" + fi # Convert space-separated components to comma-separated for Python script components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') - echo "Testing components: $components_csv" + # Only isolate directly changed components when targeting dev branch + # For beta/release branches, group everything for faster CI + # + # WHY ISOLATE DIRECTLY CHANGED COMPONENTS? + # - Isolated tests run WITHOUT --testing-mode, enabling full validation + # - This catches pin conflicts and other issues in directly changed code + # - Grouped tests use --testing-mode to allow config merging (disables some checks) + # - Dependencies are safe to group since they weren't modified in this PR + if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then + directly_changed_csv="" + echo "Testing components: $components_csv" + echo "Target branch: ${{ github.base_ref }} - grouping all components" + else + directly_changed_csv=$(echo '${{ needs.determine-jobs.outputs.directly-changed-components-with-tests }}' | jq -r 'join(",")') + echo "Testing components: $components_csv" + echo "Target branch: ${{ github.base_ref }} - isolating directly changed components: $directly_changed_csv" + fi echo "" - # Run config validation with grouping - python3 script/test_build_components.py -e config -c "$components_csv" -f + # Show disk space before validation (after bind mounts setup) + echo "Disk space before config validation:" + df -h + echo "" + + # Run config validation with grouping and isolation + python3 script/test_build_components.py -e config -c "$components_csv" -f --isolate "$directly_changed_csv" echo "" echo "Config validation passed! Starting compilation..." echo "" - # Run compilation with grouping - python3 script/test_build_components.py -e compile -c "$components_csv" -f + # Show disk space before compilation + echo "Disk space before compilation:" + df -h + echo "" + + # Run compilation with grouping and isolation + python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv" 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' + if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') steps: - name: Check out code from GitHub uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -635,7 +646,6 @@ jobs: - integration-tests - clang-tidy - determine-jobs - - test-build-components - test-build-components-splitter - test-build-components-split - pre-commit-ci-lite diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9aa7856116..c24900d378 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 + uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8 + uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 with: category: "/language:${{matrix.language}}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 521aaf9cc8..9e0e71d388 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.0 + rev: v0.14.1 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index 03ea5d0e47..b5cefa1e0c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -62,6 +62,7 @@ esphome/components/bedjet/fan/* @jhansche esphome/components/bedjet/sensor/* @javawizard @jhansche esphome/components/beken_spi_led_strip/* @Mat931 esphome/components/bh1750/* @OttoWinter +esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core esphome/components/bk72xx/* @kuba2k2 esphome/components/bl0906/* @athom-tech @jesserockz @tarontop diff --git a/esphome/__main__.py b/esphome/__main__.py index 8e0c475525..d9bdfb175b 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -117,6 +117,17 @@ class Purpose(StrEnum): LOGGING = "logging" +class PortType(StrEnum): + SERIAL = "SERIAL" + NETWORK = "NETWORK" + MQTT = "MQTT" + MQTTIP = "MQTTIP" + + +# Magic MQTT port types that require special handling +_MQTT_PORT_TYPES = frozenset({PortType.MQTT, PortType.MQTTIP}) + + def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]: """Resolve an address using cache if available, otherwise return the address itself.""" if CORE.address_cache and (cached := CORE.address_cache.get_addresses(address)): @@ -280,16 +291,67 @@ def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str return mqtt.get_esphome_device_ip(config, username, password, client_id) -_PORT_TO_PORT_TYPE = { - "MQTT": "MQTT", - "MQTTIP": "MQTTIP", -} +def _resolve_network_devices( + devices: list[str], config: ConfigType, args: ArgsProtocol +) -> list[str]: + """Resolve device list, converting MQTT magic strings to actual IP addresses. + + This function filters the devices list to: + - Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup + - Deduplicate addresses while preserving order + - Only resolve MQTT once even if multiple MQTT strings are present + - If MQTT resolution fails, log a warning and continue with other devices + + Args: + devices: List of device identifiers (IPs, hostnames, or magic strings) + config: ESPHome configuration + args: Command-line arguments containing MQTT credentials + + Returns: + List of network addresses suitable for connection attempts + """ + network_devices: list[str] = [] + mqtt_resolved: bool = False + + for device in devices: + port_type = get_port_type(device) + if port_type in _MQTT_PORT_TYPES: + # Only resolve MQTT once, even if multiple MQTT entries + if not mqtt_resolved: + try: + mqtt_ips = mqtt_get_ip( + config, args.username, args.password, args.client_id + ) + network_devices.extend(mqtt_ips) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + mqtt_resolved = True + elif device not in network_devices: + # Regular network address or IP - add if not already present + network_devices.append(device) + + return network_devices -def get_port_type(port: str) -> str: +def get_port_type(port: str) -> PortType: + """Determine the type of port/device identifier. + + Returns: + PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.) + PortType.MQTT for MQTT logging + PortType.MQTTIP for MQTT IP lookup + PortType.NETWORK for IP addresses, hostnames, or mDNS names + """ if port.startswith("/") or port.startswith("COM"): - return "SERIAL" - return _PORT_TO_PORT_TYPE.get(port, "NETWORK") + return PortType.SERIAL + if port == "MQTT": + return PortType.MQTT + if port == "MQTTIP": + return PortType.MQTTIP + return PortType.NETWORK def run_miniterm(config: ConfigType, port: str, args) -> int: @@ -489,7 +551,7 @@ def upload_using_platformio(config: ConfigType, port: str): def check_permissions(port: str): - if os.name == "posix" and get_port_type(port) == "SERIAL": + if os.name == "posix" and get_port_type(port) == PortType.SERIAL: # Check if we can open selected serial port if not os.access(port, os.F_OK): raise EsphomeError( @@ -517,7 +579,7 @@ def upload_program( except AttributeError: pass - if get_port_type(host) == "SERIAL": + if get_port_type(host) == PortType.SERIAL: check_permissions(host) exit_code = 1 @@ -544,17 +606,16 @@ def upload_program( from esphome import espota2 remote_port = int(ota_conf[CONF_PORT]) - password = ota_conf.get(CONF_PASSWORD, "") + password = ota_conf.get(CONF_PASSWORD) if getattr(args, "file", None) is not None: binary = Path(args.file) else: binary = CORE.firmware_bin - # MQTT address resolution - if get_port_type(host) in ("MQTT", "MQTTIP"): - devices = mqtt_get_ip(config, args.username, args.password, args.client_id) + # Resolve MQTT magic strings to actual IP addresses + network_devices = _resolve_network_devices(devices, config, args) - return espota2.run_ota(devices, remote_port, password, binary) + return espota2.run_ota(network_devices, remote_port, password, binary) def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: @@ -569,33 +630,22 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int raise EsphomeError("Logger is not configured!") port = devices[0] + port_type = get_port_type(port) - if get_port_type(port) == "SERIAL": + if port_type == PortType.SERIAL: check_permissions(port) return run_miniterm(config, port, args) - port_type = get_port_type(port) - # Check if we should use API for logging - if has_api(): - addresses_to_use: list[str] | None = None + # Resolve MQTT magic strings to actual IP addresses + if has_api() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + from esphome.components.api.client import run_logs - if port_type == "NETWORK": - # Network addresses (IPs, mDNS names, or regular DNS hostnames) can be used - # The resolve_ip_address() function in helpers.py handles all types - addresses_to_use = devices - elif port_type in ("MQTT", "MQTTIP") and has_mqtt_ip_lookup(): - # Use MQTT IP lookup for MQTT/MQTTIP types - addresses_to_use = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) + return run_logs(config, network_devices) - if addresses_to_use is not None: - from esphome.components.api.client import run_logs - - return run_logs(config, addresses_to_use) - - if port_type in ("NETWORK", "MQTT") and has_mqtt_logging(): + if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt return mqtt.show_logs( diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 58828c131d..e8dacf51bc 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -380,12 +380,19 @@ async def homeassistant_service_to_code( var = cg.new_Pvariable(action_id, template_arg, serv, False) templ = await cg.templatable(config[CONF_ACTION], args, None) cg.add(var.set_service(templ)) + + # Initialize FixedVectors with exact sizes from config + cg.add(var.init_data(len(config[CONF_DATA]))) for key, value in config[CONF_DATA].items(): templ = await cg.templatable(value, args, None) cg.add(var.add_data(key, templ)) + + cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) for key, value in config[CONF_DATA_TEMPLATE].items(): templ = await cg.templatable(value, args, None) cg.add(var.add_data_template(key, templ)) + + cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) cg.add(var.add_variable(key, templ)) @@ -458,15 +465,23 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg, serv, True) templ = await cg.templatable(config[CONF_EVENT], args, None) cg.add(var.set_service(templ)) + + # Initialize FixedVectors with exact sizes from config + cg.add(var.init_data(len(config[CONF_DATA]))) for key, value in config[CONF_DATA].items(): templ = await cg.templatable(value, args, None) cg.add(var.add_data(key, templ)) + + cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) for key, value in config[CONF_DATA_TEMPLATE].items(): templ = await cg.templatable(value, args, None) cg.add(var.add_data_template(key, templ)) + + cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) cg.add(var.add_variable(key, templ)) + return var @@ -489,6 +504,8 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) cg.add(var.set_service("esphome.tag_scanned")) + # Initialize FixedVector with exact size (1 data field) + cg.add(var.init_data(1)) templ = await cg.templatable(config[CONF_TAG], args, cg.std_string) cg.add(var.add_data("tag_id", templ)) return var diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 9b714d00f1..753adc3592 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -776,9 +776,9 @@ message HomeassistantActionRequest { option (ifdef) = "USE_API_HOMEASSISTANT_SERVICES"; string service = 1; - repeated HomeassistantServiceMap data = 2; - repeated HomeassistantServiceMap data_template = 3; - repeated HomeassistantServiceMap variables = 4; + repeated HomeassistantServiceMap data = 2 [(fixed_vector) = true]; + repeated HomeassistantServiceMap data_template = 3 [(fixed_vector) = true]; + repeated HomeassistantServiceMap variables = 4 [(fixed_vector) = true]; bool is_event = 5; uint32 call_id = 6 [(field_ifdef) = "USE_API_HOMEASSISTANT_ACTION_RESPONSES"]; bool wants_response = 7 [(field_ifdef) = "USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON"]; @@ -866,7 +866,7 @@ message ListEntitiesServicesResponse { string name = 1; fixed32 key = 2; - repeated ListEntitiesServicesArgument args = 3; + repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; } message ExecuteServiceArgument { option (ifdef) = "USE_API_SERVICES"; @@ -876,10 +876,10 @@ message ExecuteServiceArgument { string string_ = 4; // ESPHome 1.14 (api v1.3) make int a signed value sint32 int_ = 5; - repeated bool bool_array = 6 [packed=false]; - repeated sint32 int_array = 7 [packed=false]; - repeated float float_array = 8 [packed=false]; - repeated string string_array = 9; + repeated bool bool_array = 6 [packed=false, (fixed_vector) = true]; + repeated sint32 int_array = 7 [packed=false, (fixed_vector) = true]; + repeated float float_array = 8 [packed=false, (fixed_vector) = true]; + repeated string string_array = 9 [(fixed_vector) = true]; } message ExecuteServiceRequest { option (id) = 42; @@ -888,7 +888,7 @@ message ExecuteServiceRequest { option (ifdef) = "USE_API_SERVICES"; fixed32 key = 1; - repeated ExecuteServiceArgument args = 2; + repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true]; } // ==================== CAMERA ==================== @@ -987,8 +987,8 @@ message ListEntitiesClimateResponse { string name = 3; reserved 4; // Deprecated: was string unique_id - bool supports_current_temperature = 5; - bool supports_two_point_target_temperature = 6; + bool supports_current_temperature = 5; // Deprecated: use feature_flags + bool supports_two_point_target_temperature = 6; // Deprecated: use feature_flags repeated ClimateMode supported_modes = 7 [(container_pointer) = "std::set"]; float visual_min_temperature = 8; float visual_max_temperature = 9; @@ -997,7 +997,7 @@ message ListEntitiesClimateResponse { // is if CLIMATE_PRESET_AWAY exists is supported_presets // Deprecated in API version 1.5 bool legacy_supports_away = 11 [deprecated=true]; - bool supports_action = 12; + bool supports_action = 12; // Deprecated: use feature_flags repeated ClimateFanMode supported_fan_modes = 13 [(container_pointer) = "std::set"]; repeated ClimateSwingMode supported_swing_modes = 14 [(container_pointer) = "std::set"]; repeated string supported_custom_fan_modes = 15 [(container_pointer) = "std::set"]; @@ -1007,11 +1007,12 @@ message ListEntitiesClimateResponse { string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 20; float visual_current_temperature_step = 21; - bool supports_current_humidity = 22; - bool supports_target_humidity = 23; + bool supports_current_humidity = 22; // Deprecated: use feature_flags + bool supports_target_humidity = 23; // Deprecated: use feature_flags float visual_min_humidity = 24; float visual_max_humidity = 25; uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"]; + uint32 feature_flags = 27; } message ClimateStateResponse { option (id) = 47; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ae03dfbb33..1f3456a205 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -27,6 +27,9 @@ #ifdef USE_BLUETOOTH_PROXY #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" #endif +#ifdef USE_CLIMATE +#include "esphome/components/climate/climate_mode.h" +#endif #ifdef USE_VOICE_ASSISTANT #include "esphome/components/voice_assistant/voice_assistant.h" #endif @@ -623,9 +626,10 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection auto traits = climate->get_traits(); resp.mode = static_cast(climate->mode); resp.action = static_cast(climate->action); - if (traits.get_supports_current_temperature()) + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) resp.current_temperature = climate->current_temperature; - if (traits.get_supports_two_point_target_temperature()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { resp.target_temperature_low = climate->target_temperature_low; resp.target_temperature_high = climate->target_temperature_high; } else { @@ -644,9 +648,9 @@ uint16_t APIConnection::try_send_climate_state(EntityBase *entity, APIConnection } if (traits.get_supports_swing_modes()) resp.swing_mode = static_cast(climate->swing_mode); - if (traits.get_supports_current_humidity()) + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) resp.current_humidity = climate->current_humidity; - if (traits.get_supports_target_humidity()) + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) resp.target_humidity = climate->target_humidity; return fill_and_encode_entity_state(climate, resp, ClimateStateResponse::MESSAGE_TYPE, conn, remaining_size, is_single); @@ -656,10 +660,14 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection auto *climate = static_cast(entity); ListEntitiesClimateResponse msg; auto traits = climate->get_traits(); + // Flags set for backward compatibility, deprecated in 2025.11.0 msg.supports_current_temperature = traits.get_supports_current_temperature(); msg.supports_current_humidity = traits.get_supports_current_humidity(); msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature(); msg.supports_target_humidity = traits.get_supports_target_humidity(); + msg.supports_action = traits.get_supports_action(); + // Current feature flags and other supported parameters + msg.feature_flags = traits.get_feature_flags(); msg.supported_modes = &traits.get_supported_modes_for_api_(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); @@ -667,7 +675,6 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.visual_current_temperature_step = traits.get_visual_current_temperature_step(); msg.visual_min_humidity = traits.get_visual_min_humidity(); msg.visual_max_humidity = traits.get_visual_max_humidity(); - msg.supports_action = traits.get_supports_action(); msg.supported_fan_modes = &traits.get_supported_fan_modes_for_api_(); msg.supported_custom_fan_modes = &traits.get_supported_custom_fan_modes_for_api_(); msg.supported_presets = &traits.get_supported_presets_for_api_(); @@ -1406,7 +1413,7 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 12; + resp.api_version_minor = 13; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.set_server_info(ESPHOME_VERSION_REF); resp.set_name(StringRef(App.get_name())); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 1213e65948..e952ea670b 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -242,7 +242,6 @@ APIError APINoiseFrameHelper::state_action_() { const std::string &name = App.get_name(); const std::string &mac = get_mac_address(); - std::vector msg; // Calculate positions and sizes size_t name_len = name.size() + 1; // including null terminator size_t mac_len = mac.size() + 1; // including null terminator @@ -250,17 +249,17 @@ APIError APINoiseFrameHelper::state_action_() { size_t mac_offset = name_offset + name_len; size_t total_size = 1 + name_len + mac_len; - msg.resize(total_size); + auto msg = std::make_unique(total_size); // chosen proto msg[0] = 0x01; // node name, terminated by null byte - std::memcpy(msg.data() + name_offset, name.c_str(), name_len); + std::memcpy(msg.get() + name_offset, name.c_str(), name_len); // node mac, terminated by null byte - std::memcpy(msg.data() + mac_offset, mac.c_str(), mac_len); + std::memcpy(msg.get() + mac_offset, mac.c_str(), mac_len); - aerr = write_frame_(msg.data(), msg.size()); + aerr = write_frame_(msg.get(), total_size); if (aerr != APIError::OK) return aerr; @@ -339,32 +338,32 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reaso #ifdef USE_STORE_LOG_STR_IN_FLASH // On ESP8266 with flash strings, we need to use PROGMEM-aware functions size_t reason_len = strlen_P(reinterpret_cast(reason)); - std::vector data; - data.resize(reason_len + 1); + size_t data_size = reason_len + 1; + auto data = std::make_unique(data_size); data[0] = 0x01; // failure // Copy error message from PROGMEM if (reason_len > 0) { - memcpy_P(data.data() + 1, reinterpret_cast(reason), reason_len); + memcpy_P(data.get() + 1, reinterpret_cast(reason), reason_len); } #else // Normal memory access const char *reason_str = LOG_STR_ARG(reason); size_t reason_len = strlen(reason_str); - std::vector data; - data.resize(reason_len + 1); + size_t data_size = reason_len + 1; + auto data = std::make_unique(data_size); data[0] = 0x01; // failure // Copy error message in bulk if (reason_len > 0) { - std::memcpy(data.data() + 1, reason_str, reason_len); + std::memcpy(data.get() + 1, reason_str, reason_len); } #endif // temporarily remove failed state auto orig_state = state_; state_ = State::EXPLICIT_REJECT; - write_frame_(data.data(), data.size()); + write_frame_(data.get(), data_size); state_ = orig_state; } APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 70bcf082a6..37bcf5d8a0 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1064,6 +1064,17 @@ bool ExecuteServiceArgument::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } +void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { + uint32_t count_bool_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 6); + this->bool_array.init(count_bool_array); + uint32_t count_int_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 7); + this->int_array.init(count_int_array); + uint32_t count_float_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 8); + this->float_array.init(count_float_array); + uint32_t count_string_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 9); + this->string_array.init(count_string_array); + ProtoDecodableMessage::decode(buffer, length); +} bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: @@ -1085,6 +1096,11 @@ bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } +void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { + uint32_t count_args = ProtoDecodableMessage::count_repeated_field(buffer, length, 2); + this->args.init(count_args); + ProtoDecodableMessage::decode(buffer, length); +} #endif #ifdef USE_CAMERA void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { @@ -1185,6 +1201,7 @@ void ListEntitiesClimateResponse::encode(ProtoWriteBuffer buffer) const { #ifdef USE_DEVICES buffer.encode_uint32(26, this->device_id); #endif + buffer.encode_uint32(27, this->feature_flags); } void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->object_id_ref_.size()); @@ -1239,6 +1256,7 @@ void ListEntitiesClimateResponse::calculate_size(ProtoSize &size) const { #ifdef USE_DEVICES size.add_uint32(2, this->device_id); #endif + size.add_uint32(2, this->feature_flags); } void ClimateStateResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_fixed32(1, this->key); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 1798458393..5603204801 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1110,9 +1110,9 @@ class HomeassistantActionRequest final : public ProtoMessage { #endif StringRef service_ref_{}; void set_service(const StringRef &ref) { this->service_ref_ = ref; } - std::vector data{}; - std::vector data_template{}; - std::vector variables{}; + FixedVector data{}; + FixedVector data_template{}; + FixedVector variables{}; bool is_event{false}; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES uint32_t call_id{0}; @@ -1263,7 +1263,7 @@ class ListEntitiesServicesResponse final : public ProtoMessage { StringRef name_ref_{}; void set_name(const StringRef &ref) { this->name_ref_ = ref; } uint32_t key{0}; - std::vector args{}; + FixedVector args{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1279,10 +1279,11 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { float float_{0.0f}; std::string string_{}; int32_t int_{0}; - std::vector bool_array{}; - std::vector int_array{}; - std::vector float_array{}; - std::vector string_array{}; + FixedVector bool_array{}; + FixedVector int_array{}; + FixedVector float_array{}; + FixedVector string_array{}; + void decode(const uint8_t *buffer, size_t length) override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1300,7 +1301,8 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { const char *message_name() const override { return "execute_service_request"; } #endif uint32_t key{0}; - std::vector args{}; + FixedVector args{}; + void decode(const uint8_t *buffer, size_t length) override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1369,7 +1371,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 46; - static constexpr uint8_t ESTIMATED_SIZE = 145; + static constexpr uint8_t ESTIMATED_SIZE = 150; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_climate_response"; } #endif @@ -1390,6 +1392,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { bool supports_target_humidity{false}; float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; + uint32_t feature_flags{0}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index cf732e451b..e803125f53 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1292,6 +1292,7 @@ void ListEntitiesClimateResponse::dump_to(std::string &out) const { #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif + dump_field(out, "feature_flags", this->feature_flags); } void ClimateStateResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ClimateStateResponse"); diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 0c6e49d6ca..711eba2444 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -201,9 +201,9 @@ class CustomAPIDevice { void call_homeassistant_service(const std::string &service_name, const std::map &data) { HomeassistantActionRequest resp; resp.set_service(StringRef(service_name)); + resp.data.init(data.size()); for (auto &it : data) { - resp.data.emplace_back(); - auto &kv = resp.data.back(); + auto &kv = resp.data.emplace_back(); kv.set_key(StringRef(it.first)); kv.value = it.second; } @@ -244,9 +244,9 @@ class CustomAPIDevice { HomeassistantActionRequest resp; resp.set_service(StringRef(service_name)); resp.is_event = true; + resp.data.init(data.size()); for (auto &it : data) { - resp.data.emplace_back(); - auto &kv = resp.data.back(); + auto &kv = resp.data.emplace_back(); kv.set_key(StringRef(it.first)); kv.value = it.second; } diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 730024f7b7..4343fcd0bb 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -41,10 +41,14 @@ template class TemplatableStringValue : public TemplatableValue class TemplatableKeyValuePair { public: + // Default constructor needed for FixedVector::emplace_back() + TemplatableKeyValuePair() = default; + // Keys are always string literals from YAML dictionary keys (e.g., "code", "event") // and never templatable values or lambdas. Only the value parameter can be a lambda/template. // Using pass-by-value with std::move allows optimal performance for both lvalues and rvalues. template TemplatableKeyValuePair(std::string key, T value) : key(std::move(key)), value(value) {} + std::string key; TemplatableStringValue value; }; @@ -93,15 +97,22 @@ template class HomeAssistantServiceCallAction : public Action void set_service(T service) { this->service_ = service; } + // Initialize FixedVector members - called from Python codegen with compile-time known sizes. + // Must be called before any add_* methods; capacity must match the number of subsequent add_* calls. + void init_data(size_t count) { this->data_.init(count); } + void init_data_template(size_t count) { this->data_template_.init(count); } + void init_variables(size_t count) { this->variables_.init(count); } + // Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))). // The value parameter can be a lambda/template, but keys are never templatable. - // Using pass-by-value allows the compiler to optimize for both lvalues and rvalues. - template void add_data(std::string key, T value) { this->data_.emplace_back(std::move(key), value); } - template void add_data_template(std::string key, T value) { - this->data_template_.emplace_back(std::move(key), value); + template void add_data(K &&key, V &&value) { + this->add_kv_(this->data_, std::forward(key), std::forward(value)); } - template void add_variable(std::string key, T value) { - this->variables_.emplace_back(std::move(key), value); + template void add_data_template(K &&key, V &&value) { + this->add_kv_(this->data_template_, std::forward(key), std::forward(value)); + } + template void add_variable(K &&key, V &&value) { + this->add_kv_(this->variables_, std::forward(key), std::forward(value)); } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -127,24 +138,9 @@ template class HomeAssistantServiceCallAction : public Actionservice_.value(x...); resp.set_service(StringRef(service_value)); resp.is_event = this->flags_.is_event; - for (auto &it : this->data_) { - resp.data.emplace_back(); - auto &kv = resp.data.back(); - kv.set_key(StringRef(it.key)); - kv.value = it.value.value(x...); - } - for (auto &it : this->data_template_) { - resp.data_template.emplace_back(); - auto &kv = resp.data_template.back(); - kv.set_key(StringRef(it.key)); - kv.value = it.value.value(x...); - } - for (auto &it : this->variables_) { - resp.variables.emplace_back(); - auto &kv = resp.variables.back(); - kv.set_key(StringRef(it.key)); - kv.value = it.value.value(x...); - } + this->populate_service_map(resp.data, this->data_, x...); + this->populate_service_map(resp.data_template, this->data_template_, x...); + this->populate_service_map(resp.variables, this->variables_, x...); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES if (this->flags_.wants_status) { @@ -189,11 +185,28 @@ template class HomeAssistantServiceCallAction : public Action void add_kv_(FixedVector> &vec, K &&key, V &&value) { + auto &kv = vec.emplace_back(); + kv.key = std::forward(key); + kv.value = std::forward(value); + } + + template + static void populate_service_map(VectorType &dest, SourceType &source, Ts... x) { + dest.init(source.size()); + for (auto &it : source) { + auto &kv = dest.emplace_back(); + kv.set_key(StringRef(it.key)); + kv.value = it.value.value(x...); + } + } + APIServer *parent_; TemplatableStringValue service_{}; - std::vector> data_; - std::vector> data_template_; - std::vector> variables_; + FixedVector> data_; + FixedVector> data_template_; + FixedVector> variables_; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON TemplatableStringValue response_template_{""}; diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index afda5d32ba..4f0d0846d7 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -7,6 +7,69 @@ namespace esphome::api { static const char *const TAG = "api.proto"; +uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id) { + uint32_t count = 0; + const uint8_t *ptr = buffer; + const uint8_t *end = buffer + length; + + while (ptr < end) { + uint32_t consumed; + + // Parse field header (tag) + auto res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + if (!res.has_value()) { + break; // Invalid data, stop counting + } + + uint32_t tag = res->as_uint32(); + uint32_t field_type = tag & WIRE_TYPE_MASK; + uint32_t field_id = tag >> 3; + ptr += consumed; + + // Count if this is the target field + if (field_id == target_field_id) { + count++; + } + + // Skip field data based on wire type + switch (field_type) { + case WIRE_TYPE_VARINT: { // VarInt - parse and skip + res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + if (!res.has_value()) { + return count; // Invalid data, return what we have + } + ptr += consumed; + break; + } + case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited - parse length and skip data + res = ProtoVarInt::parse(ptr, end - ptr, &consumed); + if (!res.has_value()) { + return count; + } + uint32_t field_length = res->as_uint32(); + ptr += consumed; + if (ptr + field_length > end) { + return count; // Out of bounds + } + ptr += field_length; + break; + } + case WIRE_TYPE_FIXED32: { // 32-bit - skip 4 bytes + if (ptr + 4 > end) { + return count; + } + ptr += 4; + break; + } + default: + // Unknown wire type, can't continue + return count; + } + } + + return count; +} + void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { const uint8_t *ptr = buffer; const uint8_t *end = buffer + length; @@ -22,12 +85,12 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { } uint32_t tag = res->as_uint32(); - uint32_t field_type = tag & 0b111; + uint32_t field_type = tag & WIRE_TYPE_MASK; uint32_t field_id = tag >> 3; ptr += consumed; switch (field_type) { - case 0: { // VarInt + case WIRE_TYPE_VARINT: { // VarInt res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid VarInt at offset %ld", (long) (ptr - buffer)); @@ -39,7 +102,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { ptr += consumed; break; } - case 2: { // Length-delimited + case WIRE_TYPE_LENGTH_DELIMITED: { // Length-delimited res = ProtoVarInt::parse(ptr, end - ptr, &consumed); if (!res.has_value()) { ESP_LOGV(TAG, "Invalid Length Delimited at offset %ld", (long) (ptr - buffer)); @@ -57,7 +120,7 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { ptr += field_length; break; } - case 5: { // 32-bit + case WIRE_TYPE_FIXED32: { // 32-bit if (ptr + 4 > end) { ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); return; diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index a6a09bf7c5..e7585924a5 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -15,6 +15,13 @@ namespace esphome::api { +// Protocol Buffer wire type constants +// See https://protobuf.dev/programming-guides/encoding/#structure +constexpr uint8_t WIRE_TYPE_VARINT = 0; // int32, int64, uint32, uint64, sint32, sint64, bool, enum +constexpr uint8_t WIRE_TYPE_LENGTH_DELIMITED = 2; // string, bytes, embedded messages, packed repeated fields +constexpr uint8_t WIRE_TYPE_FIXED32 = 5; // fixed32, sfixed32, float +constexpr uint8_t WIRE_TYPE_MASK = 0b111; // Mask to extract wire type from tag + // Helper functions for ZigZag encoding/decoding inline constexpr uint32_t encode_zigzag32(int32_t value) { return (static_cast(value) << 1) ^ (static_cast(value >> 31)); @@ -241,7 +248,7 @@ class ProtoWriteBuffer { * Following https://protobuf.dev/programming-guides/encoding/#structure */ void encode_field_raw(uint32_t field_id, uint32_t type) { - uint32_t val = (field_id << 3) | (type & 0b111); + uint32_t val = (field_id << 3) | (type & WIRE_TYPE_MASK); this->encode_varint_raw(val); } void encode_string(uint32_t field_id, const char *string, size_t len, bool force = false) { @@ -354,7 +361,18 @@ class ProtoMessage { // Base class for messages that support decoding class ProtoDecodableMessage : public ProtoMessage { public: - void decode(const uint8_t *buffer, size_t length); + virtual void decode(const uint8_t *buffer, size_t length); + + /** + * Count occurrences of a repeated field in a protobuf buffer. + * This is a lightweight scan that only parses tags and skips field data. + * + * @param buffer Pointer to the protobuf buffer + * @param length Length of the buffer in bytes + * @param target_field_id The field ID to count + * @return Number of times the field appears in the buffer + */ + static uint32_t count_repeated_field(const uint8_t *buffer, size_t length, uint32_t target_field_id); protected: virtual bool decode_varint(uint32_t field_id, ProtoVarInt value) { return false; } @@ -482,7 +500,7 @@ class ProtoSize { * @return The number of bytes needed to encode the field ID and wire type */ static constexpr uint32_t field(uint32_t field_id, uint32_t type) { - uint32_t tag = (field_id << 3) | (type & 0b111); + uint32_t tag = (field_id << 3) | (type & WIRE_TYPE_MASK); return varint(tag); } diff --git a/esphome/components/api/user_services.cpp b/esphome/components/api/user_services.cpp index 27b30eb332..3cbf2ab5f9 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -12,16 +12,16 @@ template<> int32_t get_execute_arg_value(const ExecuteServiceArgument & template<> float get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.float_; } template<> std::string get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.string_; } template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return arg.bool_array; + return std::vector(arg.bool_array.begin(), arg.bool_array.end()); } template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return arg.int_array; + return std::vector(arg.int_array.begin(), arg.int_array.end()); } template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return arg.float_array; + return std::vector(arg.float_array.begin(), arg.float_array.end()); } template<> std::vector get_execute_arg_value>(const ExecuteServiceArgument &arg) { - return arg.string_array; + return std::vector(arg.string_array.begin(), arg.string_array.end()); } template<> enums::ServiceArgType to_service_arg_type() { return enums::SERVICE_ARG_TYPE_BOOL; } diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 3996c921a9..9ca5e1093e 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -35,9 +35,9 @@ template class UserServiceBase : public UserServiceDescriptor { msg.set_name(StringRef(this->name_)); msg.key = this->key_; std::array arg_types = {to_service_arg_type()...}; + msg.args.init(sizeof...(Ts)); for (size_t i = 0; i < sizeof...(Ts); i++) { - msg.args.emplace_back(); - auto &arg = msg.args.back(); + auto &arg = msg.args.emplace_back(); arg.type = arg_types[i]; arg.set_name(StringRef(this->arg_names_[i])); } @@ -55,7 +55,7 @@ template class UserServiceBase : public UserServiceDescriptor { protected: virtual void execute(Ts... x) = 0; - template void execute_(const std::vector &args, seq type) { + template void execute_(const ArgsContainer &args, seq type) { this->execute((get_execute_arg_value(args[S]))...); } diff --git a/esphome/components/bh1900nux/__init__.py b/esphome/components/bh1900nux/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp new file mode 100644 index 0000000000..96a06adaa0 --- /dev/null +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -0,0 +1,54 @@ +#include "esphome/core/log.h" +#include "bh1900nux.h" + +namespace esphome { +namespace bh1900nux { + +static const char *const TAG = "bh1900nux.sensor"; + +// I2C Registers +static const uint8_t TEMPERATURE_REG = 0x00; +static const uint8_t CONFIG_REG = 0x01; // Not used and supported yet +static const uint8_t TEMPERATURE_LOW_REG = 0x02; // Not used and supported yet +static const uint8_t TEMPERATURE_HIGH_REG = 0x03; // Not used and supported yet +static const uint8_t SOFT_RESET_REG = 0x04; + +// I2C Command payloads +static const uint8_t SOFT_RESET_PAYLOAD = 0x01; // Soft Reset value + +static const float SENSOR_RESOLUTION = 0.0625f; // Sensor resolution per bit in degrees celsius + +void BH1900NUXSensor::setup() { + // Initialize I2C device + i2c::ErrorCode result_code = + this->write_register(SOFT_RESET_REG, &SOFT_RESET_PAYLOAD, 1); // Software Reset to check communication + if (result_code != i2c::ERROR_OK) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +void BH1900NUXSensor::update() { + uint8_t temperature_raw[2]; + if (this->read_register(TEMPERATURE_REG, temperature_raw, 2) != i2c::ERROR_OK) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + + // Combined raw value, unsigned and unaligned 16 bit + // Temperature is represented in just 12 bits, shift needed + int16_t raw_temperature_register_value = encode_uint16(temperature_raw[0], temperature_raw[1]); + raw_temperature_register_value >>= 4; + float temperature_value = raw_temperature_register_value * SENSOR_RESOLUTION; // Apply sensor resolution + + this->publish_state(temperature_value); +} + +void BH1900NUXSensor::dump_config() { + LOG_SENSOR("", "BH1900NUX", this); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace bh1900nux +} // namespace esphome diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h new file mode 100644 index 0000000000..fd7f8848d6 --- /dev/null +++ b/esphome/components/bh1900nux/bh1900nux.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome { +namespace bh1900nux { + +class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; +}; + +} // namespace bh1900nux +} // namespace esphome diff --git a/esphome/components/bh1900nux/sensor.py b/esphome/components/bh1900nux/sensor.py new file mode 100644 index 0000000000..5e1c0395af --- /dev/null +++ b/esphome/components/bh1900nux/sensor.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) + +DEPENDENCIES = ["i2c"] +CODEOWNERS = ["@B48D81EFCC"] + +sensor_ns = cg.esphome_ns.namespace("bh1900nux") +BH1900NUXSensor = sensor_ns.class_( + "BH1900NUXSensor", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + BH1900NUXSensor, + accuracy_decimals=1, + unit_of_measurement=UNIT_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 1ce2321bee..a5f0fbe32f 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -16,7 +16,9 @@ #include "bluetooth_connection.h" +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include namespace esphome::bluetooth_proxy { diff --git a/esphome/components/cap1188/cap1188.cpp b/esphome/components/cap1188/cap1188.cpp index 584ff896c5..683e5cf487 100644 --- a/esphome/components/cap1188/cap1188.cpp +++ b/esphome/components/cap1188/cap1188.cpp @@ -8,17 +8,30 @@ namespace cap1188 { static const char *const TAG = "cap1188"; void CAP1188Component::setup() { - // Reset device using the reset pin - if (this->reset_pin_ != nullptr) { - this->reset_pin_->setup(); - this->reset_pin_->digital_write(false); - delay(100); // NOLINT - this->reset_pin_->digital_write(true); - delay(100); // NOLINT - this->reset_pin_->digital_write(false); - delay(100); // NOLINT + this->disable_loop(); + + // no reset pin + if (this->reset_pin_ == nullptr) { + this->finish_setup_(); + return; } + // reset pin configured so reset before finishing setup + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + // delay after reset pin write + this->set_timeout(100, [this]() { + this->reset_pin_->digital_write(true); + // delay after reset pin write + this->set_timeout(100, [this]() { + this->reset_pin_->digital_write(false); + // delay after reset pin write + this->set_timeout(100, [this]() { this->finish_setup_(); }); + }); + }); +} + +void CAP1188Component::finish_setup_() { // Check if CAP1188 is actually connected this->read_byte(CAP1188_PRODUCT_ID, &this->cap1188_product_id_); this->read_byte(CAP1188_MANUFACTURE_ID, &this->cap1188_manufacture_id_); @@ -44,6 +57,9 @@ void CAP1188Component::setup() { // Speed up a bit this->write_byte(CAP1188_STAND_BY_CONFIGURATION, 0x30); + + // Setup successful, so enable loop + this->enable_loop(); } void CAP1188Component::dump_config() { diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index baefd1c48f..297c601b05 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -49,6 +49,8 @@ class CAP1188Component : public Component, public i2c::I2CDevice { void loop() override; protected: + void finish_setup_(); + std::vector channels_{}; uint8_t touch_threshold_{0x20}; uint8_t allow_multiple_touches_{0x80}; diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index e7a454d459..f3c93ed44e 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -96,7 +96,8 @@ void ClimateCall::validate_() { } if (this->target_temperature_.has_value()) { auto target = *this->target_temperature_; - if (traits.get_supports_two_point_target_temperature()) { + if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { ESP_LOGW(TAG, " Cannot set target temperature for climate device " "with two-point target temperature!"); this->target_temperature_.reset(); @@ -106,7 +107,8 @@ void ClimateCall::validate_() { } } if (this->target_temperature_low_.has_value() || this->target_temperature_high_.has_value()) { - if (!traits.get_supports_two_point_target_temperature()) { + if (!traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { ESP_LOGW(TAG, " Cannot set low/high target temperature for this device!"); this->target_temperature_low_.reset(); this->target_temperature_high_.reset(); @@ -350,13 +352,14 @@ void Climate::save_state_() { state.mode = this->mode; auto traits = this->get_traits(); - if (traits.get_supports_two_point_target_temperature()) { + if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { state.target_temperature_low = this->target_temperature_low; state.target_temperature_high = this->target_temperature_high; } else { state.target_temperature = this->target_temperature; } - if (traits.get_supports_target_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { state.target_humidity = this->target_humidity; } if (traits.get_supports_fan_modes() && fan_mode.has_value()) { @@ -400,7 +403,7 @@ void Climate::publish_state() { auto traits = this->get_traits(); ESP_LOGD(TAG, " Mode: %s", LOG_STR_ARG(climate_mode_to_string(this->mode))); - if (traits.get_supports_action()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { ESP_LOGD(TAG, " Action: %s", LOG_STR_ARG(climate_action_to_string(this->action))); } if (traits.get_supports_fan_modes() && this->fan_mode.has_value()) { @@ -418,19 +421,20 @@ void Climate::publish_state() { if (traits.get_supports_swing_modes()) { ESP_LOGD(TAG, " Swing Mode: %s", LOG_STR_ARG(climate_swing_mode_to_string(this->swing_mode))); } - if (traits.get_supports_current_temperature()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { ESP_LOGD(TAG, " Current Temperature: %.2f°C", this->current_temperature); } - if (traits.get_supports_two_point_target_temperature()) { + if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { ESP_LOGD(TAG, " Target Temperature: Low: %.2f°C High: %.2f°C", this->target_temperature_low, this->target_temperature_high); } else { ESP_LOGD(TAG, " Target Temperature: %.2f°C", this->target_temperature); } - if (traits.get_supports_current_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { ESP_LOGD(TAG, " Current Humidity: %.0f%%", this->current_humidity); } - if (traits.get_supports_target_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { ESP_LOGD(TAG, " Target Humidity: %.0f%%", this->target_humidity); } @@ -485,13 +489,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { auto call = climate->make_call(); auto traits = climate->get_traits(); call.set_mode(this->mode); - if (traits.get_supports_two_point_target_temperature()) { + if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { call.set_target_temperature_low(this->target_temperature_low); call.set_target_temperature_high(this->target_temperature_high); } else { call.set_target_temperature(this->target_temperature); } - if (traits.get_supports_target_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { call.set_target_humidity(this->target_humidity); } if (traits.get_supports_fan_modes() || !traits.get_supported_custom_fan_modes().empty()) { @@ -508,13 +513,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { void ClimateDeviceRestoreState::apply(Climate *climate) { auto traits = climate->get_traits(); climate->mode = this->mode; - if (traits.get_supports_two_point_target_temperature()) { + if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { climate->target_temperature_low = this->target_temperature_low; climate->target_temperature_high = this->target_temperature_high; } else { climate->target_temperature = this->target_temperature; } - if (traits.get_supports_target_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { climate->target_humidity = this->target_humidity; } if (traits.get_supports_fan_modes() && !this->uses_custom_fan_mode) { @@ -580,28 +586,30 @@ void Climate::dump_traits_(const char *tag) { " Target: %.1f", traits.get_visual_min_temperature(), traits.get_visual_max_temperature(), traits.get_visual_target_temperature_step()); - if (traits.get_supports_current_temperature()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { ESP_LOGCONFIG(tag, " Current: %.1f", traits.get_visual_current_temperature_step()); } - if (traits.get_supports_target_humidity() || traits.get_supports_current_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY | + climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { ESP_LOGCONFIG(tag, " - Min humidity: %.0f\n" " - Max humidity: %.0f", traits.get_visual_min_humidity(), traits.get_visual_max_humidity()); } - if (traits.get_supports_two_point_target_temperature()) { + if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { ESP_LOGCONFIG(tag, " [x] Supports two-point target temperature"); } - if (traits.get_supports_current_temperature()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { ESP_LOGCONFIG(tag, " [x] Supports current temperature"); } - if (traits.get_supports_target_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { ESP_LOGCONFIG(tag, " [x] Supports target humidity"); } - if (traits.get_supports_current_humidity()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { ESP_LOGCONFIG(tag, " [x] Supports current humidity"); } - if (traits.get_supports_action()) { + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { ESP_LOGCONFIG(tag, " [x] Supports action"); } if (!traits.get_supported_modes().empty()) { diff --git a/esphome/components/climate/climate_mode.h b/esphome/components/climate/climate_mode.h index 80efb4c048..faec5d2537 100644 --- a/esphome/components/climate/climate_mode.h +++ b/esphome/components/climate/climate_mode.h @@ -98,6 +98,21 @@ enum ClimatePreset : uint8_t { CLIMATE_PRESET_ACTIVITY = 7, }; +enum ClimateFeature : uint32_t { + // Reporting current temperature is supported + CLIMATE_SUPPORTS_CURRENT_TEMPERATURE = 1 << 0, + // Setting two target temperatures is supported (used in conjunction with CLIMATE_MODE_HEAT_COOL) + CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE = 1 << 1, + // Single-point mode is NOT supported (UI always displays two handles, setting 'target_temperature' is not supported) + CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE = 1 << 2, + // Reporting current humidity is supported + CLIMATE_SUPPORTS_CURRENT_HUMIDITY = 1 << 3, + // Setting a target humidity is supported + CLIMATE_SUPPORTS_TARGET_HUMIDITY = 1 << 4, + // Reporting current climate action is supported + CLIMATE_SUPPORTS_ACTION = 1 << 5, +}; + /// Convert the given ClimateMode to a human-readable string. const LogString *climate_mode_to_string(ClimateMode mode); diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 8bd4714753..50c1e79ad2 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -21,48 +21,92 @@ namespace climate { * - Target Temperature * * All other properties and modes are optional and the integration must mark - * each of them as supported by setting the appropriate flag here. + * each of them as supported by setting the appropriate flag(s) here. * - * - supports current temperature - if the climate device supports reporting a current temperature - * - supports two point target temperature - if the climate device's target temperature should be - * split in target_temperature_low and target_temperature_high instead of just the single target_temperature + * - feature flags: see ClimateFeatures enum in climate_mode.h * - supports modes: * - auto mode (automatic control) * - cool mode (lowers current temperature) * - heat mode (increases current temperature) * - dry mode (removes humidity from air) * - fan mode (only turns on fan) - * - supports action - if the climate device supports reporting the active - * current action of the device with the action property. * - supports fan modes - optionally, if it has a fan which can be configured in different ways: * - on, off, auto, high, medium, low, middle, focus, diffuse, quiet * - supports swing modes - optionally, if it has a swing which can be configured in different ways: * - off, both, vertical, horizontal * * This class also contains static data for the climate device display: - * - visual min/max temperature - tells the frontend what range of temperatures the climate device - * should display (gauge min/max values) + * - visual min/max temperature/humidity - tells the frontend what range of temperature/humidity the + * climate device should display (gauge min/max values) * - temperature step - the step with which to increase/decrease target temperature. * This also affects with how many decimal places the temperature is shown */ class ClimateTraits { public: - bool get_supports_current_temperature() const { return this->supports_current_temperature_; } + /// Get/set feature flags (see ClimateFeatures enum in climate_mode.h) + uint32_t get_feature_flags() const { return this->feature_flags_; } + void add_feature_flags(uint32_t feature_flags) { this->feature_flags_ |= feature_flags; } + void clear_feature_flags(uint32_t feature_flags) { this->feature_flags_ &= ~feature_flags; } + bool has_feature_flags(uint32_t feature_flags) const { return this->feature_flags_ & feature_flags; } + void set_feature_flags(uint32_t feature_flags) { this->feature_flags_ = feature_flags; } + + ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") + bool get_supports_current_temperature() const { + return this->has_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + } + ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") void set_supports_current_temperature(bool supports_current_temperature) { - this->supports_current_temperature_ = supports_current_temperature; + if (supports_current_temperature) { + this->add_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + } else { + this->clear_feature_flags(CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + } } - bool get_supports_current_humidity() const { return this->supports_current_humidity_; } + ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") + bool get_supports_current_humidity() const { return this->has_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY); } + ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") void set_supports_current_humidity(bool supports_current_humidity) { - this->supports_current_humidity_ = supports_current_humidity; + if (supports_current_humidity) { + this->add_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY); + } else { + this->clear_feature_flags(CLIMATE_SUPPORTS_CURRENT_HUMIDITY); + } } - bool get_supports_two_point_target_temperature() const { return this->supports_two_point_target_temperature_; } + ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") + bool get_supports_two_point_target_temperature() const { + return this->has_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE); + } + ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") void set_supports_two_point_target_temperature(bool supports_two_point_target_temperature) { - this->supports_two_point_target_temperature_ = supports_two_point_target_temperature; + if (supports_two_point_target_temperature) + // Use CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE to mimic previous behavior + { + this->add_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE); + } else { + this->clear_feature_flags(CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE); + } } - bool get_supports_target_humidity() const { return this->supports_target_humidity_; } + ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") + bool get_supports_target_humidity() const { return this->has_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY); } + ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") void set_supports_target_humidity(bool supports_target_humidity) { - this->supports_target_humidity_ = supports_target_humidity; + if (supports_target_humidity) { + this->add_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY); + } else { + this->clear_feature_flags(CLIMATE_SUPPORTS_TARGET_HUMIDITY); + } } + ESPDEPRECATED("This method is deprecated, use get_feature_flags() instead", "2025.11.0") + bool get_supports_action() const { return this->has_feature_flags(CLIMATE_SUPPORTS_ACTION); } + ESPDEPRECATED("This method is deprecated, use add_feature_flags() instead", "2025.11.0") + void set_supports_action(bool supports_action) { + if (supports_action) { + this->add_feature_flags(CLIMATE_SUPPORTS_ACTION); + } else { + this->clear_feature_flags(CLIMATE_SUPPORTS_ACTION); + } + } + void set_supported_modes(std::set modes) { this->supported_modes_ = std::move(modes); } void add_supported_mode(ClimateMode mode) { this->supported_modes_.insert(mode); } ESPDEPRECATED("This method is deprecated, use set_supported_modes() instead", "v1.20") @@ -82,9 +126,6 @@ class ClimateTraits { bool supports_mode(ClimateMode mode) const { return this->supported_modes_.count(mode); } const std::set &get_supported_modes() const { return this->supported_modes_; } - void set_supports_action(bool supports_action) { this->supports_action_ = supports_action; } - bool get_supports_action() const { return this->supports_action_; } - void set_supported_fan_modes(std::set modes) { this->supported_fan_modes_ = std::move(modes); } void add_supported_fan_mode(ClimateFanMode mode) { this->supported_fan_modes_.insert(mode); } void add_supported_custom_fan_mode(const std::string &mode) { this->supported_custom_fan_modes_.insert(mode); } @@ -219,24 +260,20 @@ class ClimateTraits { } } - bool supports_current_temperature_{false}; - bool supports_current_humidity_{false}; - bool supports_two_point_target_temperature_{false}; - bool supports_target_humidity_{false}; - std::set supported_modes_ = {climate::CLIMATE_MODE_OFF}; - bool supports_action_{false}; - std::set supported_fan_modes_; - std::set supported_swing_modes_; - std::set supported_presets_; - std::set supported_custom_fan_modes_; - std::set supported_custom_presets_; - + uint32_t feature_flags_{0}; float visual_min_temperature_{10}; float visual_max_temperature_{30}; float visual_target_temperature_step_{0.1}; float visual_current_temperature_step_{0.1}; float visual_min_humidity_{30}; float visual_max_humidity_{99}; + + std::set supported_modes_ = {climate::CLIMATE_MODE_OFF}; + std::set supported_fan_modes_; + std::set supported_swing_modes_; + std::set supported_presets_; + std::set supported_custom_fan_modes_; + std::set supported_custom_presets_; }; } // namespace climate diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index b7645f5539..b5f54ac96f 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -30,14 +30,12 @@ class DateTimeBase : public EntityBase { #endif }; -#ifdef USE_TIME class DateTimeStateTrigger : public Trigger { public: explicit DateTimeStateTrigger(DateTimeBase *parent) { parent->add_on_state_callback([this, parent]() { this->trigger(parent->state_as_esptime()); }); } }; -#endif } // namespace datetime } // namespace esphome diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index b1dfe1bc9a..1c3dc3699b 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -11,8 +11,6 @@ #include #include -#include - #ifdef USE_ARDUINO #include #endif @@ -125,7 +123,12 @@ void DebugComponent::log_partition_info_() { uint32_t DebugComponent::get_free_heap_() { return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); } -static const std::map CHIP_FEATURES = { +struct ChipFeature { + int bit; + const char *name; +}; + +static constexpr ChipFeature CHIP_FEATURES[] = { {CHIP_FEATURE_BLE, "BLE"}, {CHIP_FEATURE_BT, "BT"}, {CHIP_FEATURE_EMB_FLASH, "EMB Flash"}, @@ -170,11 +173,13 @@ void DebugComponent::get_device_info_(std::string &device_info) { esp_chip_info(&info); const char *model = ESPHOME_VARIANT; std::string features; - for (auto feature : CHIP_FEATURES) { - if (info.features & feature.first) { - features += feature.second; + + // Check each known feature bit + for (const auto &feature : CHIP_FEATURES) { + if (info.features & feature.bit) { + features += feature.name; features += ", "; - info.features &= ~feature.first; + info.features &= ~feature.bit; } } if (info.features != 0) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index 9a361b158f..231b39a711 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -25,10 +25,37 @@ static void show_reset_reason(std::string &reset_reason, bool set, const char *r reset_reason += reason; } -inline uint32_t read_mem_u32(uintptr_t addr) { +static inline uint32_t read_mem_u32(uintptr_t addr) { return *reinterpret_cast(addr); // NOLINT(performance-no-int-to-ptr) } +static inline uint8_t read_mem_u8(uintptr_t addr) { + return *reinterpret_cast(addr); // NOLINT(performance-no-int-to-ptr) +} + +// defines from https://github.com/adafruit/Adafruit_nRF52_Bootloader which prints those information +constexpr uint32_t SD_MAGIC_NUMBER = 0x51B1E5DB; +constexpr uintptr_t MBR_SIZE = 0x1000; +constexpr uintptr_t SOFTDEVICE_INFO_STRUCT_OFFSET = 0x2000; +constexpr uintptr_t SD_ID_OFFSET = SOFTDEVICE_INFO_STRUCT_OFFSET + 0x10; +constexpr uintptr_t SD_VERSION_OFFSET = SOFTDEVICE_INFO_STRUCT_OFFSET + 0x14; + +static inline bool is_sd_present() { + return read_mem_u32(SOFTDEVICE_INFO_STRUCT_OFFSET + MBR_SIZE + 4) == SD_MAGIC_NUMBER; +} +static inline uint32_t sd_id_get() { + if (read_mem_u8(MBR_SIZE + SOFTDEVICE_INFO_STRUCT_OFFSET) > (SD_ID_OFFSET - SOFTDEVICE_INFO_STRUCT_OFFSET)) { + return read_mem_u32(MBR_SIZE + SD_ID_OFFSET); + } + return 0; +} +static inline uint32_t sd_version_get() { + if (read_mem_u8(MBR_SIZE + SOFTDEVICE_INFO_STRUCT_OFFSET) > (SD_VERSION_OFFSET - SOFTDEVICE_INFO_STRUCT_OFFSET)) { + return read_mem_u32(MBR_SIZE + SD_VERSION_OFFSET); + } + return 0; +} + std::string DebugComponent::get_reset_reason_() { uint32_t cause; auto ret = hwinfo_get_reset_cause(&cause); @@ -271,6 +298,29 @@ void DebugComponent::get_device_info_(std::string &device_info) { NRF_UICR->NRFFW[0]); ESP_LOGD(TAG, "MBR param page addr 0x%08x, UICR param page addr 0x%08x", read_mem_u32(MBR_PARAM_PAGE_ADDR), NRF_UICR->NRFFW[1]); + if (is_sd_present()) { + uint32_t const sd_id = sd_id_get(); + uint32_t const sd_version = sd_version_get(); + + uint32_t ver[3]; + ver[0] = sd_version / 1000000; + ver[1] = (sd_version - ver[0] * 1000000) / 1000; + ver[2] = (sd_version - ver[0] * 1000000 - ver[1] * 1000); + + ESP_LOGD(TAG, "SoftDevice: S%u %u.%u.%u", sd_id, ver[0], ver[1], ver[2]); +#ifdef USE_SOFTDEVICE_ID +#ifdef USE_SOFTDEVICE_VERSION + if (USE_SOFTDEVICE_ID != sd_id || USE_SOFTDEVICE_VERSION != ver[0]) { + ESP_LOGE(TAG, "Built for SoftDevice S%u %u.x.y. It may crash due to mismatch of bootloader version.", + USE_SOFTDEVICE_ID, USE_SOFTDEVICE_VERSION); + } +#else + if (USE_SOFTDEVICE_ID != sd_id) { + ESP_LOGE(TAG, "Built for SoftDevice S%u. It may crash due to mismatch of bootloader version.", USE_SOFTDEVICE_ID); + } +#endif +#endif + } #endif } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index c666eee298..1451d14e2e 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -775,7 +775,7 @@ void Display::test_card() { int shift_y = (h - image_h) / 2; int line_w = (image_w - 6) / 6; int image_c = image_w / 2; - for (auto i = 0; i <= image_h; i++) { + for (auto i = 0; i != image_h; i++) { int c = esp_scale(i, image_h); this->horizontal_line(shift_x + 0, shift_y + i, line_w, r.fade_to_white(c)); this->horizontal_line(shift_x + line_w, shift_y + i, line_w, r.fade_to_black(c)); // @@ -809,8 +809,11 @@ void Display::test_card() { } } } - this->rectangle(0, 0, w, h, Color(127, 0, 127)); this->filled_rectangle(0, 0, 10, 10, Color(255, 0, 255)); + this->filled_rectangle(w - 10, 0, 10, 10, Color(255, 0, 255)); + this->filled_rectangle(0, h - 10, 10, 10, Color(255, 0, 255)); + this->filled_rectangle(w - 10, h - 10, 10, 10, Color(255, 0, 255)); + this->rectangle(0, 0, w, h, Color(255, 255, 255)); this->stop_poller(); } diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 56ab2eda88..b7dd25e0d8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -324,7 +324,7 @@ def _is_framework_url(source: str) -> str: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 2, 1), + "recommended": cv.Version(3, 3, 2), "latest": cv.Version(3, 3, 2), "dev": cv.Version(3, 3, 2), } @@ -343,7 +343,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 4, 2), + "recommended": cv.Version(5, 5, 1), "latest": cv.Version(5, 5, 1), "dev": cv.Version(5, 5, 1), } @@ -363,7 +363,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(54, 3, 21, "2"), + "recommended": cv.Version(55, 3, 31, "1"), "latest": cv.Version(55, 3, 31, "1"), "dev": cv.Version(55, 3, 31, "1"), } @@ -544,6 +544,7 @@ CONF_ENABLE_LWIP_MDNS_QUERIES = "enable_lwip_mdns_queries" CONF_ENABLE_LWIP_BRIDGE_INTERFACE = "enable_lwip_bridge_interface" CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING = "enable_lwip_tcpip_core_locking" CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY = "enable_lwip_check_thread_safety" +CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram" def _validate_idf_component(config: ConfigType) -> ConfigType: @@ -606,6 +607,9 @@ FRAMEWORK_SCHEMA = cv.All( cv.Optional( CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True ): cv.boolean, + cv.Optional( + CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True + ): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, } ), @@ -824,6 +828,9 @@ async def to_code(config): # Disable dynamic log level control to save memory add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", False) + # Reduce PHY TX power in the event of a brownout + add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) + # Set default CPU frequency add_idf_sdkconfig_option( f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True @@ -864,6 +871,12 @@ async def to_code(config): if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) + # Disable placing libc locks in IRAM to save RAM + # This is safe for ESPHome since no IRAM ISRs (interrupts that run while cache is disabled) + # use libc lock APIs. Saves approximately 1.3KB (1,356 bytes) of IRAM. + if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True): + add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) + cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: add_extra_build_file( diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 5f039492c8..cbb314650a 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1564,6 +1564,10 @@ BOARDS = { "name": "DFRobot Beetle ESP32-C3", "variant": VARIANT_ESP32C3, }, + "dfrobot_firebeetle2_esp32c6": { + "name": "DFRobot FireBeetle 2 ESP32-C6", + "variant": VARIANT_ESP32C6, + }, "dfrobot_firebeetle2_esp32e": { "name": "DFRobot Firebeetle 2 ESP32-E", "variant": VARIANT_ESP32, @@ -1604,6 +1608,22 @@ BOARDS = { "name": "Ai-Thinker ESP-C3-M1-I-Kit", "variant": VARIANT_ESP32C3, }, + "esp32-c5-devkitc-1": { + "name": "Espressif ESP32-C5-DevKitC-1 4MB no PSRAM", + "variant": VARIANT_ESP32C5, + }, + "esp32-c5-devkitc1-n16r4": { + "name": "Espressif ESP32-C5-DevKitC-1 N16R4 (16 MB Flash Quad, 4 MB PSRAM Quad)", + "variant": VARIANT_ESP32C5, + }, + "esp32-c5-devkitc1-n4": { + "name": "Espressif ESP32-C5-DevKitC-1 N4 (4MB no PSRAM)", + "variant": VARIANT_ESP32C5, + }, + "esp32-c5-devkitc1-n8r4": { + "name": "Espressif ESP32-C5-DevKitC-1 N8R4 (8 MB Flash Quad, 4 MB PSRAM Quad)", + "variant": VARIANT_ESP32C5, + }, "esp32-c6-devkitc-1": { "name": "Espressif ESP32-C6-DevKitC-1", "variant": VARIANT_ESP32C6, @@ -2048,6 +2068,10 @@ BOARDS = { "name": "M5Stack Station", "variant": VARIANT_ESP32, }, + "m5stack-tab5-p4": { + "name": "M5STACK Tab5 esp32-p4 Board", + "variant": VARIANT_ESP32P4, + }, "m5stack-timer-cam": { "name": "M5Stack Timer CAM", "variant": VARIANT_ESP32, @@ -2476,6 +2500,10 @@ BOARDS = { "name": "YelloByte YB-ESP32-S3-AMP (Rev.3)", "variant": VARIANT_ESP32S3, }, + "yb_esp32s3_drv": { + "name": "YelloByte YB-ESP32-S3-DRV", + "variant": VARIANT_ESP32S3, + }, "yb_esp32s3_eth": { "name": "YelloByte YB-ESP32-S3-ETH", "variant": VARIANT_ESP32S3, diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index fc8c65a2f4..411c2add71 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -108,8 +108,13 @@ class BTLoggers(Enum): """ESP32 WiFi provisioning over Bluetooth""" -# Set to track which loggers are needed by components -_required_loggers: set[BTLoggers] = set() +# Key for storing required loggers in CORE.data +ESP32_BLE_REQUIRED_LOGGERS_KEY = "esp32_ble_required_loggers" + + +def _get_required_loggers() -> set[BTLoggers]: + """Get the set of required Bluetooth loggers from CORE.data.""" + return CORE.data.setdefault(ESP32_BLE_REQUIRED_LOGGERS_KEY, set()) # Dataclass for handler registration counts @@ -170,12 +175,13 @@ def register_bt_logger(*loggers: BTLoggers) -> None: Args: *loggers: One or more BTLoggers enum members """ + required_loggers = _get_required_loggers() for logger in loggers: if not isinstance(logger, BTLoggers): raise TypeError( f"Logger must be a BTLoggers enum member, got {type(logger)}" ) - _required_loggers.add(logger) + required_loggers.add(logger) CONF_BLE_ID = "ble_id" @@ -387,6 +393,15 @@ def final_validation(config): max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) validate_connection_slots(max_connections) + # Check if hosted bluetooth is being used + if "esp32_hosted" in full_config: + add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False) + add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True) + # Check if BLE Server is needed has_ble_server = "esp32_ble_server" in full_config @@ -479,8 +494,9 @@ async def to_code(config): # Apply logger settings if log disabling is enabled if config.get(CONF_DISABLE_BT_LOGS, False): # Disable all Bluetooth loggers that are not required + required_loggers = _get_required_loggers() for logger in BTLoggers: - if logger not in _required_loggers: + if logger not in required_loggers: add_idf_sdkconfig_option(f"{logger.value}_NONE", True) # Set BLE connection establishment timeout to match aioesphomeapi/bleak-retry-connector diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 9f8ca1d149..5bbd5fe9ed 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -6,7 +6,15 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#else +extern "C" { +#include +#include +#include +} +#endif #include #include #include @@ -136,6 +144,7 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #ifdef USE_ARDUINO if (!btStart()) { ESP_LOGE(TAG, "btStart failed: %d", esp_bt_controller_get_status()); @@ -169,6 +178,28 @@ bool ESP32BLE::ble_setup_() { #endif esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); +#else + esp_hosted_connect_to_slave(); // NOLINT + + if (esp_hosted_bt_controller_init() != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed"); + return false; + } + + if (esp_hosted_bt_controller_enable() != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed"); + return false; + } + + hosted_hci_bluedroid_open(); + + esp_bluedroid_hci_driver_operations_t operations = { + .send = hosted_hci_bluedroid_send, + .check_send_available = hosted_hci_bluedroid_check_send_available, + .register_host_callback = hosted_hci_bluedroid_register_host_callback, + }; + esp_bluedroid_attach_hci_driver(&operations); +#endif err = esp_bluedroid_init(); if (err != ESP_OK) { @@ -257,6 +288,7 @@ bool ESP32BLE::ble_dismantle_() { return false; } +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #ifdef USE_ARDUINO if (!btStop()) { ESP_LOGE(TAG, "btStop failed: %d", esp_bt_controller_get_status()); @@ -286,6 +318,19 @@ bool ESP32BLE::ble_dismantle_() { return false; } } +#endif +#else + if (esp_hosted_bt_controller_disable() != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed"); + return false; + } + + if (esp_hosted_bt_controller_deinit(false) != ESP_OK) { + ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed"); + return false; + } + + hosted_hci_bluedroid_close(); #endif return true; } diff --git a/esphome/components/esp32_ble/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index 7a31d926f6..d7f1eeac9d 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -10,7 +10,9 @@ #ifdef USE_ESP32 #ifdef USE_ESP32_BLE_ADVERTISING +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index af28804013..f2aa7e762e 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -3,7 +3,9 @@ #ifdef USE_ESP32 +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include #include diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index e37edf6cde..05afdc7379 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -5,7 +5,9 @@ #ifdef USE_ESP32 +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include namespace esphome { diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 25cc97eeaf..0e58224a5a 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -10,7 +10,9 @@ #include #include #include +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 5910be67af..4e25434aad 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -60,11 +60,21 @@ class RegistrationCounts: clients: int = 0 -# Set to track which features are needed by components -_required_features: set[BLEFeatures] = set() +# CORE.data keys for state management +ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY = "esp32_ble_tracker_required_features" +ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY = "esp32_ble_tracker_registration_counts" -# Track registration counts for StaticVector sizing -_registration_counts = RegistrationCounts() + +def _get_required_features() -> set[BLEFeatures]: + """Get the set of required BLE features from CORE.data.""" + return CORE.data.setdefault(ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY, set()) + + +def _get_registration_counts() -> RegistrationCounts: + """Get the registration counts from CORE.data.""" + return CORE.data.setdefault( + ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY, RegistrationCounts() + ) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -73,7 +83,7 @@ def register_ble_features(features: set[BLEFeatures]) -> None: Args: features: Set of BLEFeatures enum members """ - _required_features.update(features) + _get_required_features().update(features) esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") @@ -267,15 +277,17 @@ async def to_code(config): ): register_ble_features({BLEFeatures.ESP_BT_DEVICE}) + registration_counts = _get_registration_counts() + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): - _registration_counts.listeners += 1 + registration_counts.listeners += 1 trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if CONF_MAC_ADDRESS in conf: addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] cg.add(trigger.set_addresses(addr_list)) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): - _registration_counts.listeners += 1 + registration_counts.listeners += 1 trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_SERVICE_UUID]) == len(bt_uuid16_format): cg.add(trigger.set_service_uuid16(as_hex(conf[CONF_SERVICE_UUID]))) @@ -288,7 +300,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, []): - _registration_counts.listeners += 1 + registration_counts.listeners += 1 trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_MANUFACTURER_ID]) == len(bt_uuid16_format): cg.add(trigger.set_manufacturer_uuid16(as_hex(conf[CONF_MANUFACTURER_ID]))) @@ -301,7 +313,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_SCAN_END, []): - _registration_counts.listeners += 1 + registration_counts.listeners += 1 trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) @@ -331,19 +343,21 @@ async def to_code(config): @coroutine_with_priority(CoroPriority.FINAL) async def _add_ble_features(): # Add feature-specific defines based on what's needed - if BLEFeatures.ESP_BT_DEVICE in _required_features: + required_features = _get_required_features() + if BLEFeatures.ESP_BT_DEVICE in required_features: cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") # Add defines for StaticVector sizing based on registration counts # Only define if count > 0 to avoid allocating unnecessary memory - if _registration_counts.listeners > 0: + registration_counts = _get_registration_counts() + if registration_counts.listeners > 0: cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", _registration_counts.listeners + "ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", registration_counts.listeners ) - if _registration_counts.clients > 0: + if registration_counts.clients > 0: cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", _registration_counts.clients + "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", registration_counts.clients ) @@ -395,7 +409,7 @@ async def register_ble_device( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _registration_counts.listeners += 1 + _get_registration_counts().listeners += 1 paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -403,7 +417,7 @@ async def register_ble_device( async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _registration_counts.clients += 1 + _get_registration_counts().clients += 1 paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var @@ -417,7 +431,7 @@ async def register_raw_ble_device( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _registration_counts.listeners += 1 + _get_registration_counts().listeners += 1 paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -431,7 +445,7 @@ async def register_raw_client( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _registration_counts.clients += 1 + _get_registration_counts().clients += 1 paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index d07e67825b..8577f12a92 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -7,7 +7,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include +#endif #include #include #include @@ -845,6 +847,7 @@ void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE void ESP32BLETracker::update_coex_preference_(bool force_ble) { +#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID if (force_ble && !this->coex_prefer_ble_) { ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection."); this->coex_prefer_ble_ = true; @@ -854,6 +857,7 @@ void ESP32BLETracker::update_coex_preference_(bool force_ble) { this->coex_prefer_ble_ = false; esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default } +#endif // CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID } #endif diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 9cea02c322..7e9f1b05b5 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -92,9 +92,14 @@ async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.10.2") - esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") + if framework_ver >= cv.Version(5, 5, 0): + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.5.11") + else: + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") + esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index fa43aa5950..2c7963b366 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -42,6 +42,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size symbols[i] = params->bit0; } } +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) + if ((index + 1) >= size && params->reset.duration0 == 0 && params->reset.duration1 == 0) { + *done = true; + } +#endif return RMT_SYMBOLS_PER_BYTE; } diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 8a7fbbcb0a..9d8e6b7d1e 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -190,7 +190,7 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option("extra_scripts", ["post:post_build.py"]) + cg.add_platformio_option("extra_scripts", ["pre:iram_fix.py", "post:post_build.py"]) conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") @@ -230,6 +230,12 @@ async def to_code(config): # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` cg.add_build_flag("-DNEW_OOM_ABORT") + # In testing mode, fake a larger IRAM to allow linking grouped component tests + # Real ESP8266 hardware only has 32KB IRAM, but for CI testing we pretend it has 2MB + # This is done via a pre-build script that generates a custom linker script + if CORE.testing_mode: + cg.add_build_flag("-DESPHOME_TESTING_MODE") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] @@ -265,3 +271,8 @@ def copy_files(): post_build_file, CORE.relative_build_path("post_build.py"), ) + iram_fix_file = dir / "iram_fix.py.script" + copy_file_if_changed( + iram_fix_file, + CORE.relative_build_path("iram_fix.py"), + ) diff --git a/esphome/components/esp8266/iram_fix.py.script b/esphome/components/esp8266/iram_fix.py.script new file mode 100644 index 0000000000..96bddc2ced --- /dev/null +++ b/esphome/components/esp8266/iram_fix.py.script @@ -0,0 +1,44 @@ +import os +import re + +# pylint: disable=E0602 +Import("env") # noqa + + +def patch_linker_script_after_preprocess(source, target, env): + """Patch the local linker script after PlatformIO preprocesses it.""" + # Check if we're in testing mode by looking for the define + build_flags = env.get("BUILD_FLAGS", []) + testing_mode = any("-DESPHOME_TESTING_MODE" in flag for flag in build_flags) + + if not testing_mode: + return + + # Get the local linker script path + build_dir = env.subst("$BUILD_DIR") + local_ld = os.path.join(build_dir, "ld", "local.eagle.app.v6.common.ld") + + if not os.path.exists(local_ld): + return + + # Read the linker script + with open(local_ld, "r") as f: + content = f.read() + + # Replace IRAM size from 0x8000 (32KB) to 0x200000 (2MB) + # The line looks like: iram1_0_seg : org = 0x40100000, len = 0x8000 + updated = re.sub( + r"(iram1_0_seg\s*:\s*org\s*=\s*0x40100000\s*,\s*len\s*=\s*)0x8000", + r"\g<1>0x200000", + content, + ) + + if updated != content: + with open(local_ld, "w") as f: + f.write(updated) + print("ESPHome: Patched IRAM size to 2MB for testing mode") + + +# Hook into the build process right before linking +# This runs after PlatformIO has already preprocessed the linker scripts +env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", patch_linker_script_after_preprocess) diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index e6f249e021..69a50a2de9 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -136,11 +137,12 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_port(config[CONF_PORT])) - if CONF_PASSWORD in config: + # Password could be set to an empty string and we can assume that means no password + if config.get(CONF_PASSWORD): cg.add(var.set_auth_password(config[CONF_PASSWORD])) cg.add_define("USE_OTA_PASSWORD") # Only include hash algorithms when password is configured diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index c9fb006568..9963f3431d 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -90,13 +90,12 @@ void HomeassistantNumber::control(float value) { api::HomeassistantActionRequest resp; resp.set_service(SERVICE_NAME); - resp.data.emplace_back(); - auto &entity_id = resp.data.back(); + resp.data.init(2); + auto &entity_id = resp.data.emplace_back(); entity_id.set_key(ENTITY_ID_KEY); entity_id.value = this->entity_id_; - resp.data.emplace_back(); - auto &entity_value = resp.data.back(); + auto &entity_value = resp.data.emplace_back(); entity_value.set_key(VALUE_KEY); entity_value.value = to_string(value); diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 8feec26fe6..27d3705fc2 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -51,8 +51,8 @@ void HomeassistantSwitch::write_state(bool state) { resp.set_service(SERVICE_OFF); } - resp.data.emplace_back(); - auto &entity_id_kv = resp.data.back(); + resp.data.init(1); + auto &entity_id_kv = resp.data.emplace_back(); entity_id_kv.set_key(ENTITY_ID_KEY); entity_id_kv.value = this->entity_id_; diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index a7aae16f17..c5d91d3dd0 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -9,8 +9,8 @@ static const char *const TAG = "htu21d"; static const uint8_t HTU21D_ADDRESS = 0x40; static const uint8_t HTU21D_REGISTER_RESET = 0xFE; -static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xE3; -static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xE5; +static const uint8_t HTU21D_REGISTER_TEMPERATURE = 0xF3; +static const uint8_t HTU21D_REGISTER_HUMIDITY = 0xF5; static const uint8_t HTU21D_WRITERHT_REG_CMD = 0xE6; /**< Write RH/T User Register 1 */ static const uint8_t HTU21D_REGISTER_STATUS = 0xE7; static const uint8_t HTU21D_WRITEHEATER_REG_CMD = 0x51; /**< Write Heater Control Register */ diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 8ceff26d84..907429ee0e 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -143,7 +143,18 @@ def validate_mclk_divisible_by_3(config): return config -_use_legacy_driver = None +# Key for storing legacy driver setting in CORE.data +I2S_USE_LEGACY_DRIVER_KEY = "i2s_use_legacy_driver" + + +def _get_use_legacy_driver(): + """Get the legacy driver setting from CORE.data.""" + return CORE.data.get(I2S_USE_LEGACY_DRIVER_KEY) + + +def _set_use_legacy_driver(value: bool) -> None: + """Set the legacy driver setting in CORE.data.""" + CORE.data[I2S_USE_LEGACY_DRIVER_KEY] = value def i2s_audio_component_schema( @@ -209,17 +220,15 @@ async def register_i2s_audio_component(var, config): def validate_use_legacy(value): - global _use_legacy_driver # noqa: PLW0603 if CONF_USE_LEGACY in value: - if (_use_legacy_driver is not None) and ( - _use_legacy_driver != value[CONF_USE_LEGACY] - ): + existing_value = _get_use_legacy_driver() + if (existing_value is not None) and (existing_value != value[CONF_USE_LEGACY]): raise cv.Invalid( f"All i2s_audio components must set {CONF_USE_LEGACY} to the same value." ) if (not value[CONF_USE_LEGACY]) and (CORE.using_arduino): raise cv.Invalid("Arduino supports only the legacy i2s driver") - _use_legacy_driver = value[CONF_USE_LEGACY] + _set_use_legacy_driver(value[CONF_USE_LEGACY]) return value @@ -249,7 +258,8 @@ def _final_validate(_): def use_legacy(): - return not (CORE.using_esp_idf and not _use_legacy_driver) + legacy_driver = _get_use_legacy_driver() + return not (CORE.using_esp_idf and not legacy_driver) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 528a155a7f..28245dcfdf 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -218,7 +218,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command } case improv::GET_WIFI_NETWORKS: { std::vector networks; - auto results = wifi::global_wifi_component->get_scan_result(); + const auto &results = wifi::global_wifi_component->get_scan_result(); for (auto &scan : results) { if (scan.get_is_hidden()) continue; diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index fef88e72e9..ce68ad2726 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -35,6 +35,7 @@ CONF_CHARGE = "charge" CONF_CHARGE_COULOMBS = "charge_coulombs" CONF_ENERGY_JOULES = "energy_joules" CONF_TEMPERATURE_COEFFICIENT = "temperature_coefficient" +CONF_RESET_ON_BOOT = "reset_on_boot" UNIT_AMPERE_HOURS = "Ah" UNIT_COULOMB = "C" UNIT_JOULE = "J" @@ -113,6 +114,7 @@ INA2XX_SCHEMA = cv.Schema( cv.Optional(CONF_TEMPERATURE_COEFFICIENT, default=0): cv.int_range( min=0, max=16383 ), + cv.Optional(CONF_RESET_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_SHUNT_VOLTAGE): cv.maybe_simple_value( sensor.sensor_schema( unit_of_measurement=UNIT_MILLIVOLT, @@ -206,6 +208,7 @@ async def setup_ina2xx(var, config): cg.add(var.set_adc_range(config[CONF_ADC_RANGE])) cg.add(var.set_adc_avg_samples(config[CONF_ADC_AVERAGING])) cg.add(var.set_shunt_tempco(config[CONF_TEMPERATURE_COEFFICIENT])) + cg.add(var.set_reset_on_boot(config[CONF_RESET_ON_BOOT])) adc_time_config = config[CONF_ADC_TIME] if isinstance(adc_time_config, dict): diff --git a/esphome/components/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index 35a94e3989..4ab02703e8 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -257,7 +257,12 @@ bool INA2XX::reset_energy_counters() { bool INA2XX::reset_config_() { ESP_LOGV(TAG, "Reset"); ConfigurationRegister cfg{0}; - cfg.RST = true; + if (!this->reset_on_boot_) { + ESP_LOGI(TAG, "Skipping on-boot device reset"); + cfg.RST = false; + } else { + cfg.RST = true; + } return this->write_unsigned_16_(RegisterMap::REG_CONFIG, cfg.raw_u16); } diff --git a/esphome/components/ina2xx_base/ina2xx_base.h b/esphome/components/ina2xx_base/ina2xx_base.h index 261c5321bf..ba0999b28e 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.h +++ b/esphome/components/ina2xx_base/ina2xx_base.h @@ -127,6 +127,7 @@ class INA2XX : public PollingComponent { void set_adc_time_die_temperature(AdcTime time) { this->adc_time_die_temperature_ = time; } void set_adc_avg_samples(AdcAvgSamples samples) { this->adc_avg_samples_ = samples; } void set_shunt_tempco(uint16_t coeff) { this->shunt_tempco_ppm_c_ = coeff; } + void set_reset_on_boot(bool reset) { this->reset_on_boot_ = reset; } void set_shunt_voltage_sensor(sensor::Sensor *sensor) { this->shunt_voltage_sensor_ = sensor; } void set_bus_voltage_sensor(sensor::Sensor *sensor) { this->bus_voltage_sensor_ = sensor; } @@ -172,6 +173,7 @@ class INA2XX : public PollingComponent { AdcTime adc_time_die_temperature_{AdcTime::ADC_TIME_4120US}; AdcAvgSamples adc_avg_samples_{AdcAvgSamples::ADC_AVG_SAMPLES_128}; uint16_t shunt_tempco_ppm_c_{0}; + bool reset_on_boot_{true}; // // Calculated coefficients diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index f18d5ba1de..1d139e49e7 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -177,9 +177,10 @@ void LightState::set_gamma_correct(float gamma_correct) { this->gamma_correct_ = void LightState::set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } void LightState::set_initial_state(const LightStateRTCState &initial_state) { this->initial_state_ = initial_state; } bool LightState::supports_effects() { return !this->effects_.empty(); } -const std::vector &LightState::get_effects() const { return this->effects_; } +const FixedVector &LightState::get_effects() const { return this->effects_; } void LightState::add_effects(const std::vector &effects) { - this->effects_.reserve(this->effects_.size() + effects.size()); + // Called once from Python codegen during setup with all effects from YAML config + this->effects_.init(effects.size()); for (auto *effect : effects) { this->effects_.push_back(effect); } diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 1427c02c35..a07aeb6ae5 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -11,8 +11,9 @@ #include "light_traits.h" #include "light_transformer.h" -#include +#include "esphome/core/helpers.h" #include +#include namespace esphome { namespace light { @@ -159,7 +160,7 @@ class LightState : public EntityBase, public Component { bool supports_effects(); /// Get all effects for this light state. - const std::vector &get_effects() const; + const FixedVector &get_effects() const; /// Add effects for this light state. void add_effects(const std::vector &effects); @@ -260,7 +261,7 @@ class LightState : public EntityBase, public Component { /// The currently active transformer for this light (transition/flash). std::unique_ptr transformer_{nullptr}; /// List of effects for this light. - std::vector effects_; + FixedVector effects_; /// Object used to store the persisted values of the light. ESPPreferenceObject rtc_; /// Value for storing the index of the currently active effect. 0 if no effect is active diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index baee403b57..6464824c64 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -486,7 +486,6 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" -CONF_ROWS = "rows" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SELECTED_INDEX = "selected_index" diff --git a/esphome/components/lvgl/widgets/buttonmatrix.py b/esphome/components/lvgl/widgets/buttonmatrix.py index c6b6d2440f..baeb1c8e3e 100644 --- a/esphome/components/lvgl/widgets/buttonmatrix.py +++ b/esphome/components/lvgl/widgets/buttonmatrix.py @@ -2,7 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ITEMS, CONF_TEXT, CONF_WIDTH +from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROWS, CONF_TEXT, CONF_WIDTH from esphome.cpp_generator import MockObj from ..automation import action_to_code @@ -15,7 +15,6 @@ from ..defines import ( CONF_ONE_CHECKED, CONF_PAD_COLUMN, CONF_PAD_ROW, - CONF_ROWS, CONF_SELECTED, ) from ..helpers import lvgl_components_required diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index f7a1d622a1..2e123323a0 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -2,7 +2,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import key_provider import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_ROWS, CONF_TRIGGER_ID CODEOWNERS = ["@ssieb"] @@ -19,7 +19,6 @@ MatrixKeyTrigger = matrix_keypad_ns.class_( ) CONF_KEYPAD_ID = "keypad_id" -CONF_ROWS = "rows" CONF_COLUMNS = "columns" CONF_KEYS = "keys" CONF_DEBOUNCE_TIME = "debounce_time" diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index fea3ced99f..d476136554 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -83,7 +83,7 @@ void MDNSComponent::compile_records_(StaticVectorservices_ = services; + fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; #endif } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 62476e9504..35371fd739 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -38,7 +38,7 @@ struct MDNSService { // as defined in RFC6763 Section 7, like "_tcp" or "_udp" const MDNSString *proto; TemplatableValue port; - std::vector txt_records; + FixedVector txt_records; }; class MDNSComponent : public Component { diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index da47be7dbc..f2cb2d3ef5 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,8 +12,13 @@ namespace mdns { static const char *const TAG = "mdns"; void MDNSComponent::setup() { +#ifdef USE_MDNS_STORE_SERVICES + this->compile_records_(this->services_); + const auto &services = this->services_; +#else StaticVector services; this->compile_records_(services); +#endif esp_err_t err = mdns_init(); if (err != ESP_OK) { diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 06503742db..25a3defa7b 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -12,8 +12,13 @@ namespace esphome { namespace mdns { void MDNSComponent::setup() { +#ifdef USE_MDNS_STORE_SERVICES + this->compile_records_(this->services_); + const auto &services = this->services_; +#else StaticVector services; this->compile_records_(services); +#endif MDNS.begin(this->hostname_.c_str()); diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index a959482ff6..a3e317a2bf 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -12,8 +12,13 @@ namespace esphome { namespace mdns { void MDNSComponent::setup() { +#ifdef USE_MDNS_STORE_SERVICES + this->compile_records_(this->services_); + const auto &services = this->services_; +#else StaticVector services; this->compile_records_(services); +#endif MDNS.begin(this->hostname_.c_str()); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 9dfb05bda9..791fa3934d 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -12,8 +12,13 @@ namespace esphome { namespace mdns { void MDNSComponent::setup() { +#ifdef USE_MDNS_STORE_SERVICES + this->compile_records_(this->services_); + const auto &services = this->services_; +#else StaticVector services; this->compile_records_(services); +#endif MDNS.begin(this->hostname_.c_str()); diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 7e687cabaa..4dff1af62a 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_BRIGHTNESS, CONF_COLOR_ORDER, CONF_DIMENSIONS, + CONF_DISABLED, CONF_HEIGHT, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, @@ -301,6 +302,8 @@ class DriverChip: Check if a rotation can be implemented in hardware using the MADCTL register. A rotation of 180 is always possible if x and y mirroring are supported, 90 and 270 are possible if the model supports swapping X and Y. """ + if config.get(CONF_TRANSFORM) == CONF_DISABLED: + return False transforms = self.transforms rotation = config.get(CONF_ROTATION, 0) if rotation == 0 or not transforms: @@ -358,26 +361,26 @@ class DriverChip: CONF_SWAP_XY: self.get_default(CONF_SWAP_XY), }, ) - # fill in defaults if not provided - mirror_x = transform.get(CONF_MIRROR_X, self.get_default(CONF_MIRROR_X)) - mirror_y = transform.get(CONF_MIRROR_Y, self.get_default(CONF_MIRROR_Y)) - swap_xy = transform.get(CONF_SWAP_XY, self.get_default(CONF_SWAP_XY)) - transform[CONF_MIRROR_X] = mirror_x - transform[CONF_MIRROR_Y] = mirror_y - transform[CONF_SWAP_XY] = swap_xy - + if not isinstance(transform, dict): + # Presumably disabled + return { + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + CONF_SWAP_XY: False, + CONF_TRANSFORM: False, + } # Can we use the MADCTL register to set the rotation? if can_transform and CONF_TRANSFORM not in config: rotation = config[CONF_ROTATION] if rotation == 180: - transform[CONF_MIRROR_X] = not mirror_x - transform[CONF_MIRROR_Y] = not mirror_y + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] elif rotation == 90: - transform[CONF_SWAP_XY] = not swap_xy - transform[CONF_MIRROR_X] = not mirror_x + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] else: - transform[CONF_SWAP_XY] = not swap_xy - transform[CONF_MIRROR_Y] = not mirror_y + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] transform[CONF_TRANSFORM] = True return transform diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 52b5b86fba..891c8b42ff 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -37,6 +37,7 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, + CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, @@ -146,12 +147,15 @@ def swap_xy_schema(model): def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] - transform = cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **swap_xy_schema(model), - } + transform = cv.Any( + cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + **swap_xy_schema(model), + } + ), + cv.one_of(CONF_DISABLED, lower=True), ) # CUSTOM model will need to provide a custom init sequence iseqconf = ( @@ -160,7 +164,11 @@ def model_schema(config): else cv.Optional(CONF_INIT_SEQUENCE) ) # Dimensions are optional if the model has a default width and the x-y transform is not overridden - is_swapped = config.get(CONF_TRANSFORM, {}).get(CONF_SWAP_XY) is True + transform_config = config.get(CONF_TRANSFORM, {}) + is_swapped = ( + isinstance(transform_config, dict) + and transform_config.get(CONF_SWAP_XY, False) is True + ) cv_dimensions = ( cv.Optional if model.get_default(CONF_WIDTH) and not is_swapped else cv.Required ) @@ -192,9 +200,7 @@ def model_schema(config): .extend( { cv.GenerateID(): cv.declare_id(MipiSpi), - cv_dimensions(CONF_DIMENSIONS): dimension_schema( - model.get_default(CONF_DRAW_ROUNDING, 1) - ), + cv_dimensions(CONF_DIMENSIONS): dimension_schema(1), model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list( pins.gpio_output_pin_schema ), @@ -400,6 +406,7 @@ def get_instance(config): offset_height, DISPLAY_ROTATIONS[rotation], frac, + config[CONF_DRAW_ROUNDING], ] ) return MipiSpiBuffer, templateargs @@ -431,7 +438,6 @@ async def to_code(config): else: config[CONF_ROTATION] = 0 cg.add(var.set_model(config[CONF_MODEL])) - cg.add(var.set_draw_rounding(config[CONF_DRAW_ROUNDING])) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] cg.add(var.set_enable_pins(enable)) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 248d5b7104..7e597d1c61 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -38,7 +38,7 @@ static constexpr uint8_t MADCTL_BGR = 0x08; // Bit 3 Blue-Green-Red pixel ord static constexpr uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally static constexpr uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -static const uint8_t DELAY_FLAG = 0xFF; +static constexpr uint8_t DELAY_FLAG = 0xFF; // store a 16 bit value in a buffer, big endian. static inline void put16_be(uint8_t *buf, uint16_t value) { buf[0] = value >> 8; @@ -79,7 +79,7 @@ class MipiSpi : public display::Display, public spi::SPIDevice { public: - MipiSpi() {} + MipiSpi() = default; void update() override { this->stop_poller(); } void draw_pixel_at(int x, int y, Color color) override {} void set_model(const char *model) { this->model_ = model; } @@ -99,7 +99,6 @@ class MipiSpi : public display::Display, int get_width_internal() override { return WIDTH; } int get_height_internal() override { return HEIGHT; } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } - void set_draw_rounding(unsigned rounding) { this->draw_rounding_ = rounding; } // reset the display, and write the init sequence void setup() override { @@ -326,6 +325,7 @@ class MipiSpi : public display::Display, /** * Writes a buffer to the display. + * @param ptr The pointer to the pixel data * @param w Width of each line in bytes * @param h Height of the buffer in rows * @param pad Padding in bytes after each line @@ -424,7 +424,6 @@ class MipiSpi : public display::Display, // other properties set by configuration bool invert_colors_{}; - unsigned draw_rounding_{2}; optional brightness_{}; const char *model_{"Unknown"}; std::vector init_sequence_{}; @@ -444,12 +443,20 @@ class MipiSpi : public display::Display, * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). + * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, display::DisplayRotation ROTATION, + int FRACTION, unsigned ROUNDING> class MipiSpiBuffer : public MipiSpi { public: + // these values define the buffer size needed to write in accordance with the chip pixel alignment + // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and + // ignore the extra columns and rows when drawing, but use them to write to the display. + static constexpr unsigned BUFFER_WIDTH = (WIDTH + ROUNDING - 1) / ROUNDING * ROUNDING; + static constexpr unsigned BUFFER_HEIGHT = (HEIGHT + ROUNDING - 1) / ROUNDING * ROUNDING; + MipiSpiBuffer() { this->rotation_ = ROTATION; } void dump_config() override { @@ -461,15 +468,15 @@ class MipiSpiBuffer : public MipiSpirotation_, BUFFERPIXEL * 8, FRACTION, sizeof(BUFFERTYPE) * WIDTH * HEIGHT / FRACTION, - this->draw_rounding_); + this->rotation_, BUFFERPIXEL * 8, FRACTION, + sizeof(BUFFERTYPE) * BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION, ROUNDING); } void setup() override { MipiSpi::setup(); RAMAllocator allocator{}; - this->buffer_ = allocator.allocate(WIDTH * HEIGHT / FRACTION); + this->buffer_ = allocator.allocate(BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION); if (this->buffer_ == nullptr) { this->mark_failed("Buffer allocation failed"); } @@ -508,15 +515,14 @@ class MipiSpiBuffer : public MipiSpix_low_, this->y_low_, this->x_high_, this->y_high_); // Some chips require that the drawing window be aligned on certain boundaries - auto dr = this->draw_rounding_; - this->x_low_ = this->x_low_ / dr * dr; - this->y_low_ = this->y_low_ / dr * dr; - this->x_high_ = (this->x_high_ + dr) / dr * dr - 1; - this->y_high_ = (this->y_high_ + dr) / dr * dr - 1; + this->x_low_ = this->x_low_ / ROUNDING * ROUNDING; + this->y_low_ = this->y_low_ / ROUNDING * ROUNDING; + this->x_high_ = (this->x_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; + this->y_high_ = (this->y_high_ + ROUNDING) / ROUNDING * ROUNDING - 1; int w = this->x_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, WIDTH - w); + this->y_low_ - this->start_line_, BUFFER_WIDTH - w); // invalidate watermarks this->x_low_ = WIDTH; this->y_low_ = HEIGHT; @@ -536,10 +542,10 @@ class MipiSpiBuffer : public MipiSpiget_clipping().inside(x, y)) return; - rotate_coordinates_(x, y); + rotate_coordinates(x, y); if (x < 0 || x >= WIDTH || y < this->start_line_ || y >= this->end_line_) return; - this->buffer_[(y - this->start_line_) * WIDTH + x] = convert_color_(color); + this->buffer_[(y - this->start_line_) * BUFFER_WIDTH + x] = convert_color(color); if (x < this->x_low_) { this->x_low_ = x; } @@ -560,7 +566,7 @@ class MipiSpiBuffer : public MipiSpiy_low_ = this->start_line_; this->x_high_ = WIDTH - 1; this->y_high_ = this->end_line_ - 1; - std::fill_n(this->buffer_, HEIGHT * WIDTH / FRACTION, convert_color_(color)); + std::fill_n(this->buffer_, HEIGHT * BUFFER_WIDTH / FRACTION, convert_color(color)); } int get_width() override { @@ -577,7 +583,7 @@ class MipiSpiBuffer : public MipiSpi> 3 | color.b >> 6; } else if constexpr (BUFFERPIXEL == PIXEL_MODE_16) { diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 7a55027e58..e4e090da2e 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -3,6 +3,7 @@ import esphome.config_validation as cv from .amoled import CO5300 from .ili import ILI9488_A +from .jc import AXS15231 DriverChip( "WAVESHARE-4-TFT", @@ -152,3 +153,12 @@ CO5300.extend( cs_pin=12, reset_pin=39, ) + +AXS15231.extend( + "WAVESHARE-ESP32-S3-TOUCH-LCD-3.49", + width=172, + height=640, + data_rate="80MHz", + cs_pin=9, + reset_pin=21, +) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 84e505a90a..727607933d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from esphome import pins @@ -48,6 +49,7 @@ from .gpio import nrf52_pin_to_code # noqa CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["zephyr"] IS_TARGET_PLATFORM = True +_LOGGER = logging.getLogger(__name__) def set_core_data(config: ConfigType) -> ConfigType: @@ -127,6 +129,10 @@ def _validate_mcumgr(config): def _final_validate(config): if CONF_DFU in config: _validate_mcumgr(config) + if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: + _LOGGER.warning( + "Selected generic Adafruit bootloader. The board might crash. Consider settings `bootloader:`" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -157,6 +163,13 @@ async def to_code(config: ConfigType) -> None: if config[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT: cg.add_define("USE_BOOTLOADER_MCUBOOT") else: + if "_sd" in config[KEY_BOOTLOADER]: + bootloader = config[KEY_BOOTLOADER].split("_") + sd_id = bootloader[2][2:] + cg.add_define("USE_SOFTDEVICE_ID", int(sd_id)) + if (len(bootloader)) > 3: + sd_version = bootloader[3][1:] + cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version)) # make sure that firmware.zip is created # for Adafruit_nRF52_Bootloader cg.add_platformio_option("board_upload.protocol", "nrfutil") diff --git a/esphome/components/nrf52/boards.py b/esphome/components/nrf52/boards.py index 8e5fb2a23d..6064fe844a 100644 --- a/esphome/components/nrf52/boards.py +++ b/esphome/components/nrf52/boards.py @@ -11,10 +11,18 @@ from .const import ( BOARDS_ZEPHYR = { "adafruit_itsybitsy_nrf52840": { KEY_BOOTLOADER: [ + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ] + }, + "xiao_ble": { + KEY_BOOTLOADER: [ + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ] }, } diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 76a7b05ea1..230c3aa0c1 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -66,6 +66,7 @@ from esphome.const import ( DEVICE_CLASS_SPEED, DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, @@ -130,6 +131,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_SPEED, DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, DEVICE_CLASS_VOLTAGE, diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2b99f68ac0..bf13217787 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -89,6 +89,7 @@ from esphome.const import ( DEVICE_CLASS_SPEED, DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, @@ -157,6 +158,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_SPEED, DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TEMPERATURE_DELTA, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index e056696bcf..c7cca62027 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -145,7 +145,7 @@ class BSDSocketImpl : public Socket { } ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *to, socklen_t tolen) override { - return ::sendto(fd_, buf, len, flags, to, tolen); + return ::sendto(fd_, buf, len, flags, to, tolen); // NOLINT(readability-suspicious-call-argument) } int setblocking(bool blocking) override { diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 1a1736aed1..e6bcdc063a 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -1,7 +1,7 @@ import logging from esphome import core -from esphome.config_helpers import Extend, Remove, merge_config +from esphome.config_helpers import Extend, Remove, merge_config, merge_dicts_ordered import esphome.config_validation as cv from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, make_data_base @@ -170,10 +170,10 @@ def do_substitution_pass(config, command_line_substitutions, ignore_missing=Fals return # Merge substitutions in config, overriding with substitutions coming from command line: - substitutions = { - **config.get(CONF_SUBSTITUTIONS, {}), - **(command_line_substitutions or {}), - } + # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() + substitutions = merge_dicts_ordered( + config.get(CONF_SUBSTITUTIONS, {}), command_line_substitutions or {} + ) with cv.prepend_path("substitutions"): if not isinstance(substitutions, dict): raise cv.Invalid( diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index e2db3ca5e1..784b3cfb50 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -241,9 +241,14 @@ void ThermostatClimate::control(const climate::ClimateCall &call) { climate::ClimateTraits ThermostatClimate::traits() { auto traits = climate::ClimateTraits(); - traits.set_supports_current_temperature(true); + + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_ACTION | climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + + if (this->supports_two_points_) + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE); + if (this->humidity_sensor_ != nullptr) - traits.set_supports_current_humidity(true); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); if (this->supports_auto_) traits.add_supported_mode(climate::CLIMATE_MODE_AUTO); @@ -294,9 +299,6 @@ climate::ClimateTraits ThermostatClimate::traits() { for (auto &it : this->custom_preset_config_) { traits.add_supported_custom_preset(it.first); } - - traits.set_supports_two_point_target_temperature(this->supports_two_points_); - traits.set_supports_action(true); return traits; } diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index b8e390dd66..bdb17923fa 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -14,6 +14,7 @@ MODELS = { "GENERIC": Model.MODEL_GENERIC, "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, + "RAS-2819T": Model.MODEL_RAS_2819T, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index ff4241a81f..36e5a21ffa 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1,4 +1,5 @@ #include "toshiba.h" +#include "esphome/components/remote_base/toshiba_ac_protocol.h" #include @@ -97,6 +98,282 @@ const std::vector RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; +// RAS-2819T protocol constants +const uint16_t RAS_2819T_HEADER1 = 0xC23D; +const uint8_t RAS_2819T_HEADER2 = 0xD5; +const uint8_t RAS_2819T_MESSAGE_LENGTH = 6; + +// RAS-2819T fan speed codes for rc_code_1 (bytes 2-3) +const uint16_t RAS_2819T_FAN_AUTO = 0xBF40; +const uint16_t RAS_2819T_FAN_QUIET = 0xFF00; +const uint16_t RAS_2819T_FAN_LOW = 0x9F60; +const uint16_t RAS_2819T_FAN_MEDIUM = 0x5FA0; +const uint16_t RAS_2819T_FAN_HIGH = 0x3FC0; + +// RAS-2819T fan speed codes for rc_code_2 (byte 1) +const uint8_t RAS_2819T_FAN2_AUTO = 0x66; +const uint8_t RAS_2819T_FAN2_QUIET = 0x01; +const uint8_t RAS_2819T_FAN2_LOW = 0x28; +const uint8_t RAS_2819T_FAN2_MEDIUM = 0x3C; +const uint8_t RAS_2819T_FAN2_HIGH = 0x50; + +// RAS-2819T second packet suffix bytes for rc_code_2 (bytes 3-5) +// These are fixed patterns, not actual checksums +struct Ras2819tPacketSuffix { + uint8_t byte3; + uint8_t byte4; + uint8_t byte5; +}; +const Ras2819tPacketSuffix RAS_2819T_SUFFIX_AUTO{0x00, 0x02, 0x3D}; +const Ras2819tPacketSuffix RAS_2819T_SUFFIX_QUIET{0x00, 0x02, 0xD8}; +const Ras2819tPacketSuffix RAS_2819T_SUFFIX_LOW{0x00, 0x02, 0xFF}; +const Ras2819tPacketSuffix RAS_2819T_SUFFIX_MEDIUM{0x00, 0x02, 0x13}; +const Ras2819tPacketSuffix RAS_2819T_SUFFIX_HIGH{0x00, 0x02, 0x27}; + +// RAS-2819T swing toggle command +const uint64_t RAS_2819T_SWING_TOGGLE = 0xC23D6B94E01F; + +// RAS-2819T single-packet commands +const uint64_t RAS_2819T_POWER_OFF_COMMAND = 0xC23D7B84E01F; + +// RAS-2819T known valid command patterns for validation +const std::array RAS_2819T_VALID_SINGLE_COMMANDS = { + RAS_2819T_POWER_OFF_COMMAND, // Power off + RAS_2819T_SWING_TOGGLE, // Swing toggle +}; + +const uint16_t RAS_2819T_VALID_HEADER1 = 0xC23D; +const uint8_t RAS_2819T_VALID_HEADER2 = 0xD5; + +const uint8_t RAS_2819T_DRY_BYTE2 = 0x1F; +const uint8_t RAS_2819T_DRY_BYTE3 = 0xE0; +const uint8_t RAS_2819T_DRY_TEMP_OFFSET = 0x24; + +const uint8_t RAS_2819T_AUTO_BYTE2 = 0x1F; +const uint8_t RAS_2819T_AUTO_BYTE3 = 0xE0; +const uint8_t RAS_2819T_AUTO_TEMP_OFFSET = 0x08; + +const uint8_t RAS_2819T_FAN_ONLY_TEMP = 0xE4; +const uint8_t RAS_2819T_FAN_ONLY_TEMP_INV = 0x1B; + +const uint8_t RAS_2819T_HEAT_TEMP_OFFSET = 0x0C; + +// RAS-2819T second packet fixed values +const uint8_t RAS_2819T_AUTO_DRY_FAN_BYTE = 0x65; +const uint8_t RAS_2819T_AUTO_DRY_SUFFIX = 0x3A; +const uint8_t RAS_2819T_HEAT_SUFFIX = 0x3B; + +// RAS-2819T temperature codes for 18-30°C +static const uint8_t RAS_2819T_TEMP_CODES[] = { + 0x10, // 18°C + 0x30, // 19°C + 0x20, // 20°C + 0x60, // 21°C + 0x70, // 22°C + 0x50, // 23°C + 0x40, // 24°C + 0xC0, // 25°C + 0xD0, // 26°C + 0x90, // 27°C + 0x80, // 28°C + 0xA0, // 29°C + 0xB0 // 30°C +}; + +// Helper functions for RAS-2819T protocol +// +// ===== RAS-2819T PROTOCOL DOCUMENTATION ===== +// +// The RAS-2819T uses a two-packet IR protocol with some exceptions for simple commands. +// +// PACKET STRUCTURE: +// All packets are 6 bytes (48 bits) transmitted with standard Toshiba timing. +// +// TWO-PACKET COMMANDS (Mode/Temperature/Fan changes): +// +// First Packet (rc_code_1): [C2 3D] [FAN_HI FAN_LO] [TEMP] [~TEMP] +// Byte 0-1: Header (always 0xC23D) +// Byte 2-3: Fan speed encoding (varies by mode, see fan tables below) +// Byte 4: Temperature + mode encoding +// Byte 5: Bitwise complement of temperature byte +// +// Second Packet (rc_code_2): [D5] [FAN2] [00] [SUF1] [SUF2] [SUF3] +// Byte 0: Header (always 0xD5) +// Byte 1: Fan speed secondary encoding +// Byte 2: Always 0x00 +// Byte 3-5: Fixed suffix pattern (depends on fan speed and mode) +// +// TEMPERATURE ENCODING: +// Base temp codes: 18°C=0x10, 19°C=0x30, 20°C=0x20, 21°C=0x60, 22°C=0x70, +// 23°C=0x50, 24°C=0x40, 25°C=0xC0, 26°C=0xD0, 27°C=0x90, +// 28°C=0x80, 29°C=0xA0, 30°C=0xB0 +// Mode offsets added to base temp: +// COOL: No offset +// HEAT: +0x0C (e.g., 24°C heat = 0x40 | 0x0C = 0x4C) +// AUTO: +0x08 (e.g., 24°C auto = 0x40 | 0x08 = 0x48) +// DRY: +0x24 (e.g., 24°C dry = 0x40 | 0x24 = 0x64) +// +// FAN SPEED ENCODING (First packet bytes 2-3): +// AUTO: 0xBF40, QUIET: 0xFF00, LOW: 0x9F60, MEDIUM: 0x5FA0, HIGH: 0x3FC0 +// Special cases: AUTO/DRY modes use 0x1FE0 instead +// +// SINGLE-PACKET COMMANDS: +// Power Off: 0xC23D7B84E01F (6 bytes, no second packet) +// Swing Toggle: 0xC23D6B94E01F (6 bytes, no second packet) +// +// MODE DETECTION (from first packet): +// - Check bytes 2-3: if 0x7B84 → OFF mode +// - Check bytes 2-3: if 0x1FE0 → AUTO/DRY/low-temp-COOL (distinguish by temp code) +// - Otherwise: COOL/HEAT/FAN_ONLY (distinguish by temp code and byte 5) + +/** + * Get fan speed encoding for RAS-2819T first packet (rc_code_1, bytes 2-3) + */ +static uint16_t get_ras_2819t_fan_code(climate::ClimateFanMode fan_mode) { + switch (fan_mode) { + case climate::CLIMATE_FAN_QUIET: + return RAS_2819T_FAN_QUIET; + case climate::CLIMATE_FAN_LOW: + return RAS_2819T_FAN_LOW; + case climate::CLIMATE_FAN_MEDIUM: + return RAS_2819T_FAN_MEDIUM; + case climate::CLIMATE_FAN_HIGH: + return RAS_2819T_FAN_HIGH; + case climate::CLIMATE_FAN_AUTO: + default: + return RAS_2819T_FAN_AUTO; + } +} + +/** + * Get fan speed encoding for RAS-2819T rc_code_2 packet (second packet) + */ +struct Ras2819tSecondPacketCodes { + uint8_t fan_byte; + Ras2819tPacketSuffix suffix; +}; + +static Ras2819tSecondPacketCodes get_ras_2819t_second_packet_codes(climate::ClimateFanMode fan_mode) { + switch (fan_mode) { + case climate::CLIMATE_FAN_QUIET: + return {RAS_2819T_FAN2_QUIET, RAS_2819T_SUFFIX_QUIET}; + case climate::CLIMATE_FAN_LOW: + return {RAS_2819T_FAN2_LOW, RAS_2819T_SUFFIX_LOW}; + case climate::CLIMATE_FAN_MEDIUM: + return {RAS_2819T_FAN2_MEDIUM, RAS_2819T_SUFFIX_MEDIUM}; + case climate::CLIMATE_FAN_HIGH: + return {RAS_2819T_FAN2_HIGH, RAS_2819T_SUFFIX_HIGH}; + case climate::CLIMATE_FAN_AUTO: + default: + return {RAS_2819T_FAN2_AUTO, RAS_2819T_SUFFIX_AUTO}; + } +} + +/** + * Get temperature code for RAS-2819T protocol + */ +static uint8_t get_ras_2819t_temp_code(float temperature) { + int temp_index = static_cast(temperature) - 18; + if (temp_index < 0 || temp_index >= static_cast(sizeof(RAS_2819T_TEMP_CODES))) { + ESP_LOGW(TAG, "Temperature %.1f°C out of range [18-30°C], defaulting to 24°C", temperature); + return 0x40; // Default to 24°C + } + + return RAS_2819T_TEMP_CODES[temp_index]; +} + +/** + * Decode temperature from RAS-2819T temp code + */ +static float decode_ras_2819t_temperature(uint8_t temp_code) { + uint8_t base_temp_code = temp_code & 0xF0; + + // Find the code in the temperature array + for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) { + if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) { + return static_cast(temp_index + 18); // 18°C is the minimum + } + } + + ESP_LOGW(TAG, "Unknown temp code: 0x%02X, defaulting to 24°C", base_temp_code); + return 24.0f; // Default to 24°C +} + +/** + * Decode fan speed from RAS-2819T IR codes + */ +static climate::ClimateFanMode decode_ras_2819t_fan_mode(uint16_t fan_code) { + switch (fan_code) { + case RAS_2819T_FAN_QUIET: + return climate::CLIMATE_FAN_QUIET; + case RAS_2819T_FAN_LOW: + return climate::CLIMATE_FAN_LOW; + case RAS_2819T_FAN_MEDIUM: + return climate::CLIMATE_FAN_MEDIUM; + case RAS_2819T_FAN_HIGH: + return climate::CLIMATE_FAN_HIGH; + case RAS_2819T_FAN_AUTO: + default: + return climate::CLIMATE_FAN_AUTO; + } +} + +/** + * Validate RAS-2819T IR command structure and content + */ +static bool is_valid_ras_2819t_command(uint64_t rc_code_1, uint64_t rc_code_2 = 0) { + // Check header of first packet + uint16_t header1 = (rc_code_1 >> 32) & 0xFFFF; + if (header1 != RAS_2819T_VALID_HEADER1) { + return false; + } + + // Single packet commands + if (rc_code_2 == 0) { + for (uint64_t valid_cmd : RAS_2819T_VALID_SINGLE_COMMANDS) { + if (rc_code_1 == valid_cmd) { + return true; + } + } + // Additional validation for unknown single packets + return false; + } + + // Two-packet commands - validate second packet header + uint8_t header2 = (rc_code_2 >> 40) & 0xFF; + if (header2 != RAS_2819T_VALID_HEADER2) { + return false; + } + + // Validate temperature complement in first packet (byte 4 should be ~byte 5) + uint8_t temp_byte = (rc_code_1 >> 8) & 0xFF; + uint8_t temp_complement = rc_code_1 & 0xFF; + if (temp_byte != static_cast(~temp_complement)) { + return false; + } + + // Validate fan speed combinations make sense + uint16_t fan_code = (rc_code_1 >> 16) & 0xFFFF; + uint8_t fan2_byte = (rc_code_2 >> 32) & 0xFF; + + // Check if fan codes are from known valid patterns + bool valid_fan_combo = false; + if (fan_code == RAS_2819T_FAN_AUTO && fan2_byte == RAS_2819T_FAN2_AUTO) + valid_fan_combo = true; + if (fan_code == RAS_2819T_FAN_QUIET && fan2_byte == RAS_2819T_FAN2_QUIET) + valid_fan_combo = true; + if (fan_code == RAS_2819T_FAN_LOW && fan2_byte == RAS_2819T_FAN2_LOW) + valid_fan_combo = true; + if (fan_code == RAS_2819T_FAN_MEDIUM && fan2_byte == RAS_2819T_FAN2_MEDIUM) + valid_fan_combo = true; + if (fan_code == RAS_2819T_FAN_HIGH && fan2_byte == RAS_2819T_FAN2_HIGH) + valid_fan_combo = true; + if (fan_code == 0x1FE0 && fan2_byte == RAS_2819T_AUTO_DRY_FAN_BYTE) + valid_fan_combo = true; // AUTO/DRY + + return valid_fan_combo; +} + void ToshibaClimate::setup() { if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { @@ -126,16 +403,43 @@ void ToshibaClimate::setup() { this->minimum_temperature_ = this->temperature_min_(); this->maximum_temperature_ = this->temperature_max_(); this->swing_modes_ = this->toshiba_swing_modes_(); + + // Ensure swing mode is always initialized to a valid value + if (this->swing_modes_.empty() || this->swing_modes_.find(this->swing_mode) == this->swing_modes_.end()) { + // No swing support for this model or current swing mode not supported, reset to OFF + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + + // Ensure mode is valid - ESPHome should only use standard climate modes + if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_HEAT && + this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT_COOL && + this->mode != climate::CLIMATE_MODE_DRY && this->mode != climate::CLIMATE_MODE_FAN_ONLY) { + ESP_LOGW(TAG, "Invalid mode detected during setup, resetting to OFF"); + this->mode = climate::CLIMATE_MODE_OFF; + } + + // Ensure fan mode is valid + if (!this->fan_mode.has_value()) { + ESP_LOGW(TAG, "Fan mode not set during setup, defaulting to AUTO"); + this->fan_mode = climate::CLIMATE_FAN_AUTO; + } + // Never send nan to HA if (std::isnan(this->target_temperature)) this->target_temperature = 24; + // Log final state for debugging HA errors + ESP_LOGV(TAG, "Setup complete - Mode: %d, Fan: %s, Swing: %d, Temp: %.1f", static_cast(this->mode), + this->fan_mode.has_value() ? std::to_string(static_cast(this->fan_mode.value())).c_str() : "NONE", + static_cast(this->swing_mode), this->target_temperature); } void ToshibaClimate::transmit_state() { if (this->model_ == MODEL_RAC_PT1411HWRU_C || this->model_ == MODEL_RAC_PT1411HWRU_F) { - transmit_rac_pt1411hwru_(); + this->transmit_rac_pt1411hwru_(); + } else if (this->model_ == MODEL_RAS_2819T) { + this->transmit_ras_2819t_(); } else { - transmit_generic_(); + this->transmit_generic_(); } } @@ -230,7 +534,7 @@ void ToshibaClimate::transmit_generic_() { auto transmit = this->transmitter_->transmit(); auto *data = transmit.get_data(); - encode_(data, message, message_length, 1); + this->encode_(data, message, message_length, 1); transmit.perform(); } @@ -348,15 +652,12 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { message[11] += message[index]; } } - ESP_LOGV(TAG, "*** Generated codes: 0x%.2X%.2X%.2X%.2X%.2X%.2X 0x%.2X%.2X%.2X%.2X%.2X%.2X", message[0], message[1], - message[2], message[3], message[4], message[5], message[6], message[7], message[8], message[9], message[10], - message[11]); // load first block of IR code and repeat it once - encode_(data, &message[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); + this->encode_(data, &message[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); // load second block of IR code, if present if (message[6] != 0) { - encode_(data, &message[6], RAC_PT1411HWRU_MESSAGE_LENGTH, 0); + this->encode_(data, &message[6], RAC_PT1411HWRU_MESSAGE_LENGTH, 0); } transmit.perform(); @@ -366,19 +667,19 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { data->space(TOSHIBA_PACKET_SPACE); switch (this->swing_mode) { case climate::CLIMATE_SWING_VERTICAL: - encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); + this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); break; case climate::CLIMATE_SWING_OFF: default: - encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); + this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); } data->space(TOSHIBA_PACKET_SPACE); transmit.perform(); if (this->sensor_) { - transmit_rac_pt1411hwru_temp_(true, false); + this->transmit_rac_pt1411hwru_temp_(true, false); } } @@ -430,15 +731,217 @@ void ToshibaClimate::transmit_rac_pt1411hwru_temp_(const bool cs_state, const bo // Byte 5: Footer lower/bitwise complement of byte 4 message[5] = ~message[4]; - ESP_LOGV(TAG, "*** Generated code: 0x%.2X%.2X%.2X%.2X%.2X%.2X", message[0], message[1], message[2], message[3], - message[4], message[5]); // load IR code and repeat it once - encode_(data, message, RAC_PT1411HWRU_MESSAGE_LENGTH, 1); + this->encode_(data, message, RAC_PT1411HWRU_MESSAGE_LENGTH, 1); transmit.perform(); } } +void ToshibaClimate::transmit_ras_2819t_() { + // Handle swing mode transmission for RAS-2819T + // Note: RAS-2819T uses a toggle command, so we need to track state changes + + // Check if ONLY swing mode changed (and no other climate parameters) + bool swing_changed = (this->swing_mode != this->last_swing_mode_); + bool mode_changed = (this->mode != this->last_mode_); + bool fan_changed = (this->fan_mode != this->last_fan_mode_); + bool temp_changed = (abs(this->target_temperature - this->last_target_temperature_) > 0.1f); + + bool only_swing_changed = swing_changed && !mode_changed && !fan_changed && !temp_changed; + + if (only_swing_changed) { + // Send ONLY swing toggle command (like the physical remote does) + auto swing_transmit = this->transmitter_->transmit(); + auto *swing_data = swing_transmit.get_data(); + + // Convert toggle command to bytes for transmission + uint8_t swing_message[RAS_2819T_MESSAGE_LENGTH]; + swing_message[0] = (RAS_2819T_SWING_TOGGLE >> 40) & 0xFF; + swing_message[1] = (RAS_2819T_SWING_TOGGLE >> 32) & 0xFF; + swing_message[2] = (RAS_2819T_SWING_TOGGLE >> 24) & 0xFF; + swing_message[3] = (RAS_2819T_SWING_TOGGLE >> 16) & 0xFF; + swing_message[4] = (RAS_2819T_SWING_TOGGLE >> 8) & 0xFF; + swing_message[5] = RAS_2819T_SWING_TOGGLE & 0xFF; + + // Use single packet transmission WITH repeat (like regular commands) + this->encode_(swing_data, swing_message, RAS_2819T_MESSAGE_LENGTH, 1); + swing_transmit.perform(); + + // Update all state tracking + this->last_swing_mode_ = this->swing_mode; + this->last_mode_ = this->mode; + this->last_fan_mode_ = this->fan_mode; + this->last_target_temperature_ = this->target_temperature; + + // Immediately publish the state change to Home Assistant + this->publish_state(); + + return; // Exit early - don't send climate command + } + + // If we get here, send the regular climate command (temperature/mode/fan) + uint8_t message1[RAS_2819T_MESSAGE_LENGTH] = {0}; + uint8_t message2[RAS_2819T_MESSAGE_LENGTH] = {0}; + float temperature = + clamp(this->target_temperature, TOSHIBA_RAS_2819T_TEMP_C_MIN, TOSHIBA_RAS_2819T_TEMP_C_MAX); + + // Build first packet (RAS_2819T_HEADER1 + 4 bytes) + message1[0] = (RAS_2819T_HEADER1 >> 8) & 0xFF; + message1[1] = RAS_2819T_HEADER1 & 0xFF; + + // Handle OFF mode + if (this->mode == climate::CLIMATE_MODE_OFF) { + // Extract bytes from power off command constant + message1[2] = (RAS_2819T_POWER_OFF_COMMAND >> 24) & 0xFF; + message1[3] = (RAS_2819T_POWER_OFF_COMMAND >> 16) & 0xFF; + message1[4] = (RAS_2819T_POWER_OFF_COMMAND >> 8) & 0xFF; + message1[5] = RAS_2819T_POWER_OFF_COMMAND & 0xFF; + // No second packet for OFF + } else { + // Get temperature and fan encoding + uint8_t temp_code = get_ras_2819t_temp_code(temperature); + + // Get fan speed encoding for rc_code_1 + climate::ClimateFanMode effective_fan_mode = this->fan_mode.value(); + + // Dry mode only supports AUTO fan speed + if (this->mode == climate::CLIMATE_MODE_DRY) { + effective_fan_mode = climate::CLIMATE_FAN_AUTO; + if (this->fan_mode.value() != climate::CLIMATE_FAN_AUTO) { + ESP_LOGW(TAG, "Dry mode only supports AUTO fan speed, forcing AUTO"); + } + } + + uint16_t fan_code = get_ras_2819t_fan_code(effective_fan_mode); + + // Mode and temperature encoding + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + // All cooling temperatures support fan speed control + message1[2] = (fan_code >> 8) & 0xFF; + message1[3] = fan_code & 0xFF; + message1[4] = temp_code; + message1[5] = ~temp_code; + break; + + case climate::CLIMATE_MODE_HEAT: + // Heating supports fan speed control + message1[2] = (fan_code >> 8) & 0xFF; + message1[3] = fan_code & 0xFF; + // Heat mode adds offset to temperature code + message1[4] = temp_code | RAS_2819T_HEAT_TEMP_OFFSET; + message1[5] = ~(temp_code | RAS_2819T_HEAT_TEMP_OFFSET); + break; + + case climate::CLIMATE_MODE_HEAT_COOL: + // Auto mode uses fixed encoding + message1[2] = RAS_2819T_AUTO_BYTE2; + message1[3] = RAS_2819T_AUTO_BYTE3; + message1[4] = temp_code | RAS_2819T_AUTO_TEMP_OFFSET; + message1[5] = ~(temp_code | RAS_2819T_AUTO_TEMP_OFFSET); + break; + + case climate::CLIMATE_MODE_DRY: + // Dry mode uses fixed encoding and forces AUTO fan + message1[2] = RAS_2819T_DRY_BYTE2; + message1[3] = RAS_2819T_DRY_BYTE3; + message1[4] = temp_code | RAS_2819T_DRY_TEMP_OFFSET; + message1[5] = ~message1[4]; + break; + + case climate::CLIMATE_MODE_FAN_ONLY: + // Fan only mode supports fan speed control + message1[2] = (fan_code >> 8) & 0xFF; + message1[3] = fan_code & 0xFF; + message1[4] = RAS_2819T_FAN_ONLY_TEMP; + message1[5] = RAS_2819T_FAN_ONLY_TEMP_INV; + break; + + default: + // Default case supports fan speed control + message1[2] = (fan_code >> 8) & 0xFF; + message1[3] = fan_code & 0xFF; + message1[4] = temp_code; + message1[5] = ~temp_code; + break; + } + + // Build second packet (RAS_2819T_HEADER2 + 4 bytes) + message2[0] = RAS_2819T_HEADER2; + + // Get fan speed encoding for rc_code_2 + Ras2819tSecondPacketCodes second_packet_codes = get_ras_2819t_second_packet_codes(effective_fan_mode); + + // Determine header byte 2 and fan encoding based on mode + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + message2[1] = second_packet_codes.fan_byte; + message2[2] = 0x00; + message2[3] = second_packet_codes.suffix.byte3; + message2[4] = second_packet_codes.suffix.byte4; + message2[5] = second_packet_codes.suffix.byte5; + break; + + case climate::CLIMATE_MODE_HEAT: + message2[1] = second_packet_codes.fan_byte; + message2[2] = 0x00; + message2[3] = second_packet_codes.suffix.byte3; + message2[4] = 0x00; + message2[5] = RAS_2819T_HEAT_SUFFIX; + break; + + case climate::CLIMATE_MODE_HEAT_COOL: + case climate::CLIMATE_MODE_DRY: + // Auto/Dry modes use fixed values regardless of fan setting + message2[1] = RAS_2819T_AUTO_DRY_FAN_BYTE; + message2[2] = 0x00; + message2[3] = 0x00; + message2[4] = 0x00; + message2[5] = RAS_2819T_AUTO_DRY_SUFFIX; + break; + + case climate::CLIMATE_MODE_FAN_ONLY: + message2[1] = second_packet_codes.fan_byte; + message2[2] = 0x00; + message2[3] = second_packet_codes.suffix.byte3; + message2[4] = 0x00; + message2[5] = RAS_2819T_HEAT_SUFFIX; + break; + + default: + message2[1] = second_packet_codes.fan_byte; + message2[2] = 0x00; + message2[3] = second_packet_codes.suffix.byte3; + message2[4] = second_packet_codes.suffix.byte4; + message2[5] = second_packet_codes.suffix.byte5; + break; + } + } + + // Log final messages being transmitted + + // Transmit using proper Toshiba protocol timing + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + // Use existing Toshiba encode function for proper timing + this->encode_(data, message1, RAS_2819T_MESSAGE_LENGTH, 1); + + if (this->mode != climate::CLIMATE_MODE_OFF) { + // Send second packet with gap + this->encode_(data, message2, RAS_2819T_MESSAGE_LENGTH, 0); + } + + transmit.perform(); + + // Update all state tracking after successful transmission + this->last_swing_mode_ = this->swing_mode; + this->last_mode_ = this->mode; + this->last_fan_mode_ = this->fan_mode; + this->last_target_temperature_ = this->target_temperature; +} + uint8_t ToshibaClimate::is_valid_rac_pt1411hwru_header_(const uint8_t *message) { const std::vector header{RAC_PT1411HWRU_MESSAGE_HEADER0, RAC_PT1411HWRU_CS_HEADER, RAC_PT1411HWRU_SWING_HEADER}; @@ -464,11 +967,11 @@ bool ToshibaClimate::compare_rac_pt1411hwru_packets_(const uint8_t *message1, co bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { uint8_t checksum = 0; - switch (is_valid_rac_pt1411hwru_header_(message)) { + switch (this->is_valid_rac_pt1411hwru_header_(message)) { case RAC_PT1411HWRU_MESSAGE_HEADER0: case RAC_PT1411HWRU_CS_HEADER: case RAC_PT1411HWRU_SWING_HEADER: - if (is_valid_rac_pt1411hwru_header_(message) && (message[2] == static_cast(~message[3])) && + if (this->is_valid_rac_pt1411hwru_header_(message) && (message[2] == static_cast(~message[3])) && (message[4] == static_cast(~message[5]))) { return true; } @@ -490,7 +993,103 @@ bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { return false; } +bool ToshibaClimate::process_ras_2819t_command_(const remote_base::ToshibaAcData &toshiba_data) { + // Check for power-off command (single packet) + if (toshiba_data.rc_code_2 == 0 && toshiba_data.rc_code_1 == RAS_2819T_POWER_OFF_COMMAND) { + this->mode = climate::CLIMATE_MODE_OFF; + ESP_LOGI(TAG, "Mode: OFF"); + this->publish_state(); + return true; + } + + // Check for swing toggle command (single packet) + if (toshiba_data.rc_code_2 == 0 && toshiba_data.rc_code_1 == RAS_2819T_SWING_TOGGLE) { + // Toggle swing mode + if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL) { + this->swing_mode = climate::CLIMATE_SWING_OFF; + ESP_LOGI(TAG, "Swing: OFF"); + } else { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + ESP_LOGI(TAG, "Swing: VERTICAL"); + } + this->publish_state(); + return true; + } + + // Handle regular two-packet commands (mode/temperature/fan changes) + if (toshiba_data.rc_code_2 != 0) { + // Convert to byte array for easier processing + uint8_t message1[6], message2[6]; + for (uint8_t i = 0; i < 6; i++) { + message1[i] = (toshiba_data.rc_code_1 >> (40 - i * 8)) & 0xFF; + message2[i] = (toshiba_data.rc_code_2 >> (40 - i * 8)) & 0xFF; + } + + // Decode the protocol using message1 (rc_code_1) + uint8_t temp_code = message1[4]; + + // Decode mode - check bytes 2-3 pattern and temperature code + if ((message1[2] == 0x7B) && (message1[3] == 0x84)) { + // OFF mode has specific pattern + this->mode = climate::CLIMATE_MODE_OFF; + ESP_LOGI(TAG, "Mode: OFF"); + } else if ((message1[2] == 0x1F) && (message1[3] == 0xE0)) { + // 0x1FE0 pattern is used for AUTO, DRY, and low-temp COOL + if ((temp_code & 0x0F) == 0x08) { + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + ESP_LOGI(TAG, "Mode: AUTO"); + } else if ((temp_code & 0x0F) == 0x04) { + this->mode = climate::CLIMATE_MODE_DRY; + ESP_LOGI(TAG, "Mode: DRY"); + } else { + this->mode = climate::CLIMATE_MODE_COOL; + ESP_LOGI(TAG, "Mode: COOL (low temp)"); + } + } else { + // Variable fan speed patterns - decode by temperature code + if ((temp_code & 0x0F) == 0x0C) { + this->mode = climate::CLIMATE_MODE_HEAT; + ESP_LOGI(TAG, "Mode: HEAT"); + } else if (message1[5] == 0x1B) { + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + ESP_LOGI(TAG, "Mode: FAN_ONLY"); + } else { + this->mode = climate::CLIMATE_MODE_COOL; + ESP_LOGI(TAG, "Mode: COOL"); + } + } + + // Decode fan speed from rc_code_1 + uint16_t fan_code = (message1[2] << 8) | message1[3]; + this->fan_mode = decode_ras_2819t_fan_mode(fan_code); + + // Decode temperature + if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_FAN_ONLY) { + this->target_temperature = decode_ras_2819t_temperature(temp_code); + } + + this->publish_state(); + return true; + } else { + ESP_LOGD(TAG, "Unknown single-packet RAS-2819T command: 0x%" PRIX64, toshiba_data.rc_code_1); + return false; + } +} + bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { + // Try modern ToshibaAcProtocol decoder first (handles RAS-2819T and potentially others) + remote_base::ToshibaAcProtocol toshiba_protocol; + auto decode_result = toshiba_protocol.decode(data); + + if (decode_result.has_value()) { + auto toshiba_data = decode_result.value(); + // Validate and process RAS-2819T commands + if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { + return this->process_ras_2819t_command_(toshiba_data); + } + } + + // Fall back to generic processing for older protocols uint8_t message[18] = {0}; uint8_t message_length = TOSHIBA_HEADER_LENGTH, temperature_code = 0; @@ -499,11 +1098,11 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } // Read incoming bits into buffer - if (!decode_(&data, message, message_length)) { + if (!this->decode_(&data, message, message_length)) { return false; } // Determine incoming message protocol version and/or length - if (is_valid_rac_pt1411hwru_header_(message)) { + if (this->is_valid_rac_pt1411hwru_header_(message)) { // We already received four bytes message_length = RAC_PT1411HWRU_MESSAGE_LENGTH - 4; } else if ((message[0] ^ message[1] ^ message[2]) != message[3]) { @@ -514,11 +1113,11 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { message_length = message[2] + 2; } // Decode the remaining bytes - if (!decode_(&data, &message[4], message_length)) { + if (!this->decode_(&data, &message[4], message_length)) { return false; } // If this is a RAC-PT1411HWRU message, we expect the first packet a second time and also possibly a third packet - if (is_valid_rac_pt1411hwru_header_(message)) { + if (this->is_valid_rac_pt1411hwru_header_(message)) { // There is always a space between packets if (!data.expect_item(TOSHIBA_BIT_MARK, TOSHIBA_GAP_SPACE)) { return false; @@ -527,7 +1126,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { if (!data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE)) { return false; } - if (!decode_(&data, &message[6], RAC_PT1411HWRU_MESSAGE_LENGTH)) { + if (!this->decode_(&data, &message[6], RAC_PT1411HWRU_MESSAGE_LENGTH)) { return false; } // If this is a RAC-PT1411HWRU message, there may also be a third packet. @@ -535,25 +1134,25 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { if (data.expect_item(TOSHIBA_BIT_MARK, TOSHIBA_GAP_SPACE)) { // Validate header 3 data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE); - if (decode_(&data, &message[12], RAC_PT1411HWRU_MESSAGE_LENGTH)) { - if (!is_valid_rac_pt1411hwru_message_(&message[12])) { + if (this->decode_(&data, &message[12], RAC_PT1411HWRU_MESSAGE_LENGTH)) { + if (!this->is_valid_rac_pt1411hwru_message_(&message[12])) { // If a third packet was received but the checksum is not valid, fail return false; } } } - if (!compare_rac_pt1411hwru_packets_(&message[0], &message[6])) { + if (!this->compare_rac_pt1411hwru_packets_(&message[0], &message[6])) { // If the first two packets don't match each other, fail return false; } - if (!is_valid_rac_pt1411hwru_message_(&message[0])) { + if (!this->is_valid_rac_pt1411hwru_message_(&message[0])) { // If the first packet isn't valid, fail return false; } } // Header has been verified, now determine protocol version and set the climate component properties - switch (is_valid_rac_pt1411hwru_header_(message)) { + switch (this->is_valid_rac_pt1411hwru_header_(message)) { // Power, temperature, mode, fan speed case RAC_PT1411HWRU_MESSAGE_HEADER0: // Get the mode @@ -608,7 +1207,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; } // Get the target temperature - if (is_valid_rac_pt1411hwru_message_(&message[12])) { + if (this->is_valid_rac_pt1411hwru_message_(&message[12])) { temperature_code = (message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG); if (message[15] & RAC_PT1411HWRU_FLAG_FAH) { diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index 83e85c34db..d76833f406 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/toshiba_ac_protocol.h" namespace esphome { namespace toshiba { @@ -10,6 +11,7 @@ enum Model { MODEL_GENERIC = 0, // Temperature range is from 17 to 30 MODEL_RAC_PT1411HWRU_C = 1, // Temperature range is from 16 to 30 MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 + MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 }; // Supported temperature ranges @@ -19,6 +21,8 @@ const float TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN = 16.0; const float TOSHIBA_RAC_PT1411HWRU_TEMP_C_MAX = 30.0; const float TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN = 60.0; const float TOSHIBA_RAC_PT1411HWRU_TEMP_F_MAX = 86.0; +const float TOSHIBA_RAS_2819T_TEMP_C_MIN = 18.0; +const float TOSHIBA_RAS_2819T_TEMP_C_MAX = 30.0; class ToshibaClimate : public climate_ir::ClimateIR { public: @@ -35,6 +39,9 @@ class ToshibaClimate : public climate_ir::ClimateIR { void transmit_generic_(); void transmit_rac_pt1411hwru_(); void transmit_rac_pt1411hwru_temp_(bool cs_state = true, bool cs_send_update = true); + void transmit_ras_2819t_(); + // Process RAS-2819T IR command data + bool process_ras_2819t_command_(const remote_base::ToshibaAcData &toshiba_data); // Returns the header if valid, else returns zero uint8_t is_valid_rac_pt1411hwru_header_(const uint8_t *message); // Returns true if message is a valid RAC-PT1411HWRU IR message, regardless if first or second packet @@ -43,11 +50,26 @@ class ToshibaClimate : public climate_ir::ClimateIR { bool compare_rac_pt1411hwru_packets_(const uint8_t *message1, const uint8_t *message2); bool on_receive(remote_base::RemoteReceiveData data) override; + private: + // RAS-2819T state tracking for swing mode optimization + climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF}; + climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF}; + optional last_fan_mode_{}; + float last_target_temperature_{24.0f}; + float temperature_min_() { - return (this->model_ == MODEL_GENERIC) ? TOSHIBA_GENERIC_TEMP_C_MIN : TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; + if (this->model_ == MODEL_RAC_PT1411HWRU_C || this->model_ == MODEL_RAC_PT1411HWRU_F) + return TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; + if (this->model_ == MODEL_RAS_2819T) + return TOSHIBA_RAS_2819T_TEMP_C_MIN; + return TOSHIBA_GENERIC_TEMP_C_MIN; // Default to GENERIC for unknown models } float temperature_max_() { - return (this->model_ == MODEL_GENERIC) ? TOSHIBA_GENERIC_TEMP_C_MAX : TOSHIBA_RAC_PT1411HWRU_TEMP_C_MAX; + if (this->model_ == MODEL_RAC_PT1411HWRU_C || this->model_ == MODEL_RAC_PT1411HWRU_F) + return TOSHIBA_RAC_PT1411HWRU_TEMP_C_MAX; + if (this->model_ == MODEL_RAS_2819T) + return TOSHIBA_RAS_2819T_TEMP_C_MAX; + return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } std::set toshiba_swing_modes_() { return (this->model_ == MODEL_GENERIC) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index d90efd18bc..c3ba7ddc2b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -380,24 +380,25 @@ void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) { if (this->on_connect_) { this->on_connect_(rsp); } - this->sessions_.insert(rsp); + this->sessions_.push_back(rsp); } void AsyncEventSource::loop() { // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions - auto it = this->sessions_.begin(); - while (it != this->sessions_.end()) { - auto *ses = *it; + for (size_t i = 0; i < this->sessions_.size();) { + auto *ses = this->sessions_[i]; // If the session has a dead socket (marked by destroy callback) if (ses->fd_.load() == 0) { ESP_LOGD(TAG, "Removing dead event source session"); - it = this->sessions_.erase(it); delete ses; // NOLINT(cppcoreguidelines-owning-memory) + // Remove by swapping with last element (O(1) removal, order doesn't matter for sessions) + this->sessions_[i] = this->sessions_.back(); + this->sessions_.pop_back(); } else { ses->loop(); - ++it; + ++i; } } } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index bf93dcbd34..5ec6fec009 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -8,7 +8,6 @@ #include #include #include -#include #include #include #include @@ -315,7 +314,10 @@ class AsyncEventSource : public AsyncWebHandler { protected: std::string url_; - std::set sessions_; + // Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards). + // Linear search is faster than red-black tree overhead for this small dataset. + // Only operations needed: add session, remove session, iterate sessions - no need for sorted order. + std::vector sessions_; connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; }; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index a784123006..1f742dc1a8 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -402,8 +402,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) # Disable Enterprise WiFi support if no EAP is configured - if CORE.is_esp32 and not has_eap: - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + if CORE.is_esp32: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) @@ -447,6 +447,8 @@ async def to_code(config): var.get_disconnect_trigger(), [], on_disconnect_config ) + CORE.add_job(final_step) + @automation.register_condition("wifi.connected", WiFiConnectedCondition, cv.Schema({})) async def wifi_connected_to_code(config, condition_id, template_arg, args): @@ -468,6 +470,28 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) +KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" + + +def request_wifi_scan_results(): + """Request that WiFi scan results be kept in memory after connection. + + Components that need access to scan results after WiFi is connected should + call this function during their code generation. This prevents the WiFi component from + freeing scan result memory after successful connection. + """ + CORE.data[KEEP_SCAN_RESULTS_KEY] = True + + +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure scan result retention.""" + if CORE.data.get(KEEP_SCAN_RESULTS_KEY, False): + cg.add( + cg.RawExpression("wifi::global_wifi_component->set_keep_scan_results(true)") + ) + + @automation.register_action( "wifi.configure", WiFiConfigureAction, diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index aa197b36e5..5aa2a03a14 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -549,7 +549,7 @@ void WiFiComponent::start_scanning() { // Using insertion sort instead of std::stable_sort saves flash memory // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) // IMPORTANT: This sort is stable (preserves relative order of equal elements) -static void insertion_sort_scan_results(std::vector &results) { +template static void insertion_sort_scan_results(VectorType &results) { const size_t size = results.size(); for (size_t i = 1; i < size; i++) { // Make a copy to avoid issues with move semantics during comparison @@ -713,6 +713,12 @@ void WiFiComponent::check_connecting_finished() { this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; this->num_retried_ = 0; + // Free scan results memory unless a component needs them + if (!this->keep_scan_results_) { + this->scan_result_.clear(); + this->scan_result_.shrink_to_fit(); + } + if (this->fast_connect_) { this->save_fast_connect_settings_(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 0e0f89d2c1..9d32071b2b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -121,6 +121,14 @@ struct EAPAuth { using bssid_t = std::array; +// Use std::vector for RP2040 since scan count is unknown (callback-based) +// Use FixedVector for other platforms where count is queried first +#ifdef USE_RP2040 +template using wifi_scan_vector_t = std::vector; +#else +template using wifi_scan_vector_t = FixedVector; +#endif + class WiFiAP { public: void set_ssid(const std::string &ssid); @@ -278,7 +286,7 @@ class WiFiComponent : public Component { const std::string &get_use_address() const; void set_use_address(const std::string &use_address); - const std::vector &get_scan_result() const { return scan_result_; } + const wifi_scan_vector_t &get_scan_result() const { return scan_result_; } network::IPAddress wifi_soft_ap_ip(); @@ -316,6 +324,7 @@ class WiFiComponent : public Component { int8_t wifi_rssi(); void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } Trigger<> *get_connect_trigger() const { return this->connect_trigger_; }; Trigger<> *get_disconnect_trigger() const { return this->disconnect_trigger_; }; @@ -385,7 +394,7 @@ class WiFiComponent : public Component { std::string use_address_; std::vector sta_; std::vector sta_priorities_; - std::vector scan_result_; + wifi_scan_vector_t scan_result_; WiFiAP selected_ap_; WiFiAP ap_; optional output_power_; @@ -424,6 +433,7 @@ class WiFiComponent : public Component { #endif bool enable_on_boot_; bool got_ipv4_address_{false}; + bool keep_scan_results_{false}; // Pointers at the end (naturally aligned) Trigger<> *connect_trigger_{new Trigger<>()}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 3b3b4b139c..59909b2cb5 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -696,7 +696,15 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { this->retry_connect(); return; } + + // Count the number of results first auto *head = reinterpret_cast(arg); + size_t count = 0; + for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { + count++; + } + + this->scan_result_.init(count); for (bss_info *it = head; it != nullptr; it = STAILQ_NEXT(it, next)) { WiFiScanResult res({it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]}, std::string(reinterpret_cast(it->ssid), it->ssid_len), it->channel, it->rssi, diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index ccec800205..951f5803a6 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -784,7 +784,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } records.resize(number); - scan_result_.reserve(number); + scan_result_.init(number); for (int i = 0; i < number; i++) { auto &record = records[i]; bssid_t bssid; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index b15f710150..cb179d9022 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -411,7 +411,7 @@ void WiFiComponent::wifi_scan_done_callback_() { if (num < 0) return; - this->scan_result_.reserve(static_cast(num)); + this->scan_result_.init(static_cast(num)); for (int i = 0; i < num; i++) { String ssid = WiFi.SSID(i); wifi_auth_mode_t authmode = WiFi.encryptionType(i); diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index 4ceb73a695..a4da582c55 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import text_sensor +from esphome.components import text_sensor, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BSSID, @@ -77,7 +77,9 @@ async def to_code(config): await setup_conf(config, CONF_SSID) await setup_conf(config, CONF_BSSID) await setup_conf(config, CONF_MAC_ADDRESS) - await setup_conf(config, CONF_SCAN_RESULTS) + if CONF_SCAN_RESULTS in config: + await setup_conf(config, CONF_SCAN_RESULTS) + wifi.request_wifi_scan_results() await setup_conf(config, CONF_DNS_ADDRESS) if conf := config.get(CONF_IP_ADDRESS): wifi_info = await text_sensor.new_text_sensor(config[CONF_IP_ADDRESS]) diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index ff4644163e..634c99876b 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -222,18 +222,25 @@ def copy_files(): ] in ["xiao_ble"]: fake_board_manifest = """ { -"frameworks": [ - "zephyr" -], -"name": "esphome nrf52", -"upload": { - "maximum_ram_size": 248832, - "maximum_size": 815104 -}, -"url": "https://esphome.io/", -"vendor": "esphome" + "frameworks": [ + "zephyr" + ], + "name": "esphome nrf52", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200 + }, + "url": "https://esphome.io/", + "vendor": "esphome", + "build": { + "softdevice": { + "sd_fwid": "0x00B6" + } + } } """ + write_file_if_changed( CORE.relative_build_path(f"boards/{zephyr_data()[KEY_BOARD]}.json"), fake_board_manifest, diff --git a/esphome/config.py b/esphome/config.py index 10a5733575..6adecb5c65 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -12,7 +12,7 @@ from typing import Any import voluptuous as vol from esphome import core, loader, pins, yaml_util -from esphome.config_helpers import Extend, Remove +from esphome.config_helpers import Extend, Remove, merge_dicts_ordered import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, @@ -922,10 +922,9 @@ def validate_config( if CONF_SUBSTITUTIONS in config or command_line_substitutions: from esphome.components import substitutions - result[CONF_SUBSTITUTIONS] = { - **(config.get(CONF_SUBSTITUTIONS) or {}), - **command_line_substitutions, - } + result[CONF_SUBSTITUTIONS] = merge_dicts_ordered( + config.get(CONF_SUBSTITUTIONS) or {}, command_line_substitutions + ) result.add_output_path([CONF_SUBSTITUTIONS], CONF_SUBSTITUTIONS) try: substitutions.do_substitution_pass(config, command_line_substitutions) diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index 00cd8f9818..88cfa49fdc 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -10,6 +10,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.util import OrderedDict # Pre-build lookup map from (platform, framework) tuples to PlatformFramework enum _PLATFORM_FRAMEWORK_LOOKUP = { @@ -17,6 +18,25 @@ _PLATFORM_FRAMEWORK_LOOKUP = { } +def merge_dicts_ordered(*dicts: dict) -> OrderedDict: + """Merge multiple dicts into an OrderedDict, preserving key order. + + This is a helper to ensure that dictionary merging preserves OrderedDict type, + which is important for operations like move_to_end(). + + Args: + *dicts: Variable number of dictionaries to merge (later dicts override earlier ones) + + Returns: + OrderedDict with merged contents + """ + result = OrderedDict() + for d in dicts: + if d: + result.update(d) + return result + + class Extend: def __init__(self, value): self.value = value @@ -60,7 +80,11 @@ def merge_config(full_old, full_new): if isinstance(new, dict): if not isinstance(old, dict): return new - res = old.copy() + # Preserve OrderedDict type by copying to OrderedDict if either input is OrderedDict + if isinstance(old, OrderedDict) or isinstance(new, OrderedDict): + res = OrderedDict(old) + else: + res = old.copy() for k, v in new.items(): if isinstance(v, Remove) and k in old: del res[k] diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ebfedf2017..e2f0b835c9 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -244,6 +244,20 @@ RESERVED_IDS = [ "uart0", "uart1", "uart2", + # ESP32 ROM functions + "crc16_be", + "crc16_le", + "crc32_be", + "crc32_le", + "crc8_be", + "crc8_le", + "dbg_state", + "debug_timer", + "one_bits", + "recv_packet", + "send_packet", + "check_pos", + "software_reset", ] diff --git a/esphome/const.py b/esphome/const.py index 086b5b4ce3..ce1c033e41 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -696,6 +696,7 @@ CONF_OPEN_DRAIN = "open_drain" CONF_OPEN_DRAIN_INTERRUPT = "open_drain_interrupt" CONF_OPEN_DURATION = "open_duration" CONF_OPEN_ENDSTOP = "open_endstop" +CONF_OPENTHREAD = "openthread" CONF_OPERATION = "operation" CONF_OPTIMISTIC = "optimistic" CONF_OPTION = "option" @@ -836,6 +837,7 @@ CONF_RMT_CHANNEL = "rmt_channel" CONF_RMT_SYMBOLS = "rmt_symbols" CONF_ROTATION = "rotation" CONF_ROW = "row" +CONF_ROWS = "rows" CONF_RS_PIN = "rs_pin" CONF_RTD_NOMINAL_RESISTANCE = "rtd_nominal_resistance" CONF_RTD_WIRES = "rtd_wires" @@ -1298,6 +1300,7 @@ DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" DEVICE_CLASS_SWITCH = "switch" DEVICE_CLASS_TAMPER = "tamper" DEVICE_CLASS_TEMPERATURE = "temperature" +DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta" DEVICE_CLASS_TIMESTAMP = "timestamp" DEVICE_CLASS_UPDATE = "update" DEVICE_CLASS_VIBRATION = "vibration" diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index ba942e5e43..f1248e0035 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -7,6 +7,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/scheduler.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" #include @@ -14,7 +15,7 @@ namespace esphome { template class AndCondition : public Condition { public: - explicit AndCondition(const std::vector *> &conditions) : conditions_(conditions) {} + explicit AndCondition(std::initializer_list *> conditions) : conditions_(conditions) {} bool check(Ts... x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -25,12 +26,12 @@ template class AndCondition : public Condition { } protected: - std::vector *> conditions_; + FixedVector *> conditions_; }; template class OrCondition : public Condition { public: - explicit OrCondition(const std::vector *> &conditions) : conditions_(conditions) {} + explicit OrCondition(std::initializer_list *> conditions) : conditions_(conditions) {} bool check(Ts... x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -41,7 +42,7 @@ template class OrCondition : public Condition { } protected: - std::vector *> conditions_; + FixedVector *> conditions_; }; template class NotCondition : public Condition { @@ -55,7 +56,7 @@ template class NotCondition : public Condition { template class XorCondition : public Condition { public: - explicit XorCondition(const std::vector *> &conditions) : conditions_(conditions) {} + explicit XorCondition(std::initializer_list *> conditions) : conditions_(conditions) {} bool check(Ts... x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -66,7 +67,7 @@ template class XorCondition : public Condition { } protected: - std::vector *> conditions_; + FixedVector *> conditions_; }; template class LambdaCondition : public Condition { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f770edaa00..1afb296fc0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -202,7 +202,7 @@ #define USB_HOST_MAX_REQUESTS 16 #ifdef USE_ARDUINO -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 2, 1) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 2) #define USE_ETHERNET #define USE_ETHERNET_KSZ8081 #endif diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e352c9c415..326718e974 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -197,6 +197,18 @@ template class FixedVector { public: FixedVector() = default; + /// Constructor from initializer list - allocates exact size needed + /// This enables brace initialization: FixedVector v = {1, 2, 3}; + FixedVector(std::initializer_list init_list) { + init(init_list.size()); + size_t idx = 0; + for (const auto &item : init_list) { + new (data_ + idx) T(item); + ++idx; + } + size_ = init_list.size(); + } + ~FixedVector() { cleanup_(); } // Disable copy operations (avoid accidental expensive copies) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py index 35b67c0d23..6035b4a1d6 100644 --- a/esphome/dashboard/settings.py +++ b/esphome/dashboard/settings.py @@ -10,6 +10,10 @@ from esphome.helpers import get_bool_env from .util.password import password_hash +# Sentinel file name used for CORE.config_path when dashboard initializes. +# This ensures .parent returns the config directory instead of root. +_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml" + class DashboardSettings: """Settings for the dashboard.""" @@ -48,7 +52,12 @@ class DashboardSettings: self.config_dir = Path(args.configuration) self.absolute_config_dir = self.config_dir.resolve() self.verbose = args.verbose - CORE.config_path = self.config_dir / "." + # Set to a sentinel file so .parent gives us the config directory. + # Previously this was `os.path.join(self.config_dir, ".")` which worked because + # os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent + # normalizes to Path("/config") first, then .parent returns Path("/"), breaking + # secret resolution. Using a sentinel file ensures .parent gives the correct directory. + CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE @property def relative_url(self) -> str: diff --git a/esphome/espota2.py b/esphome/espota2.py index 17a1da8235..2b1b9a8328 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -242,7 +242,7 @@ def send_check( def perform_ota( - sock: socket.socket, password: str, file_handle: io.IOBase, filename: Path + sock: socket.socket, password: str | None, file_handle: io.IOBase, filename: Path ) -> None: file_contents = file_handle.read() file_size = len(file_contents) @@ -278,13 +278,13 @@ def perform_ota( def perform_auth( sock: socket.socket, - password: str, + password: str | None, hash_func: Callable[..., Any], nonce_size: int, hash_name: str, ) -> None: """Perform challenge-response authentication using specified hash algorithm.""" - if not password: + if password is None: raise OTAError("ESP requests password, but no password given!") nonce_bytes = receive_exactly( @@ -385,7 +385,7 @@ def perform_ota( def run_ota_impl_( - remote_host: str | list[str], remote_port: int, password: str, filename: Path + remote_host: str | list[str], remote_port: int, password: str | None, filename: Path ) -> tuple[int, str | None]: from esphome.core import CORE @@ -436,7 +436,7 @@ def run_ota_impl_( def run_ota( - remote_host: str | list[str], remote_port: int, password: str, filename: Path + remote_host: str | list[str], remote_port: int, password: str | None, filename: Path ) -> tuple[int, str | None]: try: return run_ota_impl_(remote_host, remote_port, password, filename) diff --git a/esphome/git.py b/esphome/git.py index 62fe37a3fe..4ff07ffe75 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -5,6 +5,7 @@ import hashlib import logging from pathlib import Path import re +import shutil import subprocess import urllib.parse @@ -17,14 +18,60 @@ _LOGGER = logging.getLogger(__name__) NEVER_REFRESH = TimePeriodSeconds(seconds=-1) -def run_git_command(cmd, cwd=None) -> str: - _LOGGER.debug("Running git command: %s", " ".join(cmd)) +class GitException(cv.Invalid): + """Base exception for git-related errors.""" + + +class GitNotInstalledError(GitException): + """Exception raised when git is not installed on the system.""" + + +class GitCommandError(GitException): + """Exception raised when a git command fails.""" + + +class GitRepositoryError(GitException): + """Exception raised when a git repository is in an invalid state.""" + + +def run_git_command(cmd: list[str], git_dir: Path | None = None) -> str: + if git_dir is not None: + _LOGGER.debug( + "Running git command with repository isolation: %s (git_dir=%s)", + " ".join(cmd), + git_dir, + ) + else: + _LOGGER.debug("Running git command: %s", " ".join(cmd)) + + # Set up environment for repository isolation if git_dir is provided + # Force git to only operate on this specific repository by setting + # GIT_DIR and GIT_WORK_TREE. This prevents git from walking up the + # directory tree to find parent repositories when the target repo's + # .git directory is corrupt. Without this, commands like 'git stash' + # could accidentally operate on parent repositories (e.g., the main + # ESPHome repo) instead of failing, causing data loss. + env: dict[str, str] | None = None + cwd: str | None = None + if git_dir is not None: + env = { + **subprocess.os.environ, + "GIT_DIR": str(Path(git_dir) / ".git"), + "GIT_WORK_TREE": str(git_dir), + } + cwd = str(git_dir) + try: ret = subprocess.run( - cmd, cwd=cwd, capture_output=True, check=False, close_fds=False + cmd, + cwd=cwd, + capture_output=True, + check=False, + close_fds=False, + env=env, ) except FileNotFoundError as err: - raise cv.Invalid( + raise GitNotInstalledError( "git is not installed but required for external_components.\n" "Please see https://git-scm.com/book/en/v2/Getting-Started-Installing-Git for installing git" ) from err @@ -33,8 +80,8 @@ def run_git_command(cmd, cwd=None) -> str: err_str = ret.stderr.decode("utf-8") lines = [x.strip() for x in err_str.splitlines()] if lines[-1].startswith("fatal:"): - raise cv.Invalid(lines[-1][len("fatal: ") :]) - raise cv.Invalid(err_str) + raise GitCommandError(lines[-1][len("fatal: ") :]) + raise GitCommandError(err_str) return ret.stdout.decode("utf-8").strip() @@ -55,6 +102,7 @@ def clone_or_update( username: str = None, password: str = None, submodules: list[str] | None = None, + _recover_broken: bool = True, ) -> tuple[Path, Callable[[], None] | None]: key = f"{url}@{ref}" @@ -75,15 +123,15 @@ def clone_or_update( # We need to fetch the PR branch first, otherwise git will complain # about missing objects _LOGGER.info("Fetching %s", ref) - run_git_command(["git", "fetch", "--", "origin", ref], str(repo_dir)) - run_git_command(["git", "reset", "--hard", "FETCH_HEAD"], str(repo_dir)) + run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir) + run_git_command(["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir) if submodules is not None: _LOGGER.info( - "Initialising submodules (%s) for %s", ", ".join(submodules), key + "Initializing submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, str(repo_dir) + ["git", "submodule", "update", "--init"] + submodules, git_dir=repo_dir ) else: @@ -99,32 +147,82 @@ def clone_or_update( file_timestamp = Path(repo_dir / ".git" / "HEAD") age = datetime.now() - datetime.fromtimestamp(file_timestamp.stat().st_mtime) if refresh is None or age.total_seconds() > refresh.total_seconds: - old_sha = run_git_command(["git", "rev-parse", "HEAD"], str(repo_dir)) - _LOGGER.info("Updating %s", key) - _LOGGER.debug("Location: %s", repo_dir) - # Stash local changes (if any) - run_git_command( - ["git", "stash", "push", "--include-untracked"], str(repo_dir) - ) - # Fetch remote ref - cmd = ["git", "fetch", "--", "origin"] - if ref is not None: - cmd.append(ref) - run_git_command(cmd, str(repo_dir)) - # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch) - run_git_command(["git", "reset", "--hard", "FETCH_HEAD"], str(repo_dir)) + # Try to update the repository, recovering from broken state if needed + old_sha: str | None = None + try: + # First verify the repository is valid by checking HEAD + # Use git_dir parameter to prevent git from walking up to parent repos + old_sha = run_git_command( + ["git", "rev-parse", "HEAD"], git_dir=repo_dir + ) + + _LOGGER.info("Updating %s", key) + _LOGGER.debug("Location: %s", repo_dir) + + # Stash local changes (if any) + # Use git_dir to ensure this only affects the specific repo + run_git_command( + ["git", "stash", "push", "--include-untracked"], + git_dir=repo_dir, + ) + + # Fetch remote ref + cmd = ["git", "fetch", "--", "origin"] + if ref is not None: + cmd.append(ref) + run_git_command(cmd, git_dir=repo_dir) + + # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch) + run_git_command( + ["git", "reset", "--hard", "FETCH_HEAD"], + git_dir=repo_dir, + ) + except GitException as err: + # Repository is in a broken state or update failed + # Only attempt recovery once to prevent infinite recursion + if not _recover_broken: + _LOGGER.error( + "Repository %s recovery failed, cannot retry (already attempted once)", + key, + ) + raise + + _LOGGER.warning( + "Repository %s has issues (%s), attempting recovery", + key, + err, + ) + _LOGGER.info("Removing broken repository at %s", repo_dir) + shutil.rmtree(repo_dir) + _LOGGER.info("Successfully removed broken repository, re-cloning...") + + # Recursively call clone_or_update to re-clone + # Set _recover_broken=False to prevent infinite recursion + result = clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + username=username, + password=password, + submodules=submodules, + _recover_broken=False, + ) + _LOGGER.info("Repository %s successfully recovered", key) + return result if submodules is not None: _LOGGER.info( "Updating submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, str(repo_dir) + ["git", "submodule", "update", "--init"] + submodules, + git_dir=repo_dir, ) def revert(): _LOGGER.info("Reverting changes to %s -> %s", key, old_sha) - run_git_command(["git", "reset", "--hard", old_sha], str(repo_dir)) + run_git_command(["git", "reset", "--hard", old_sha], git_dir=repo_dir) return repo_dir, revert diff --git a/netlify.toml b/netlify.toml index 7783414a91..5f177e6b3a 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,3 +1,4 @@ [build] command = "script/build-api-docs" publish = "api-docs" + environment = { PYTHON_VERSION = "3.13" } diff --git a/platformio.ini b/platformio.ini index 44b466a2b3..6b2a8657bb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -125,9 +125,9 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script ; This are common settings for the ESP32 (all variants) using Arduino. [common:esp32-arduino] extends = common:arduino -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-1/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.2.1/esp32-3.2.1.zip + pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.2/esp32-3.3.2.zip framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -161,9 +161,9 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21-2/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.31-1/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.4.2/esp-idf-v5.4.2.zip + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.1/esp-idf-v5.5.1.zip framework = espidf lib_deps = diff --git a/requirements.txt b/requirements.txt index 1abfed0324..92ab24e754 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==41.17.0 +aioesphomeapi==42.0.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import @@ -23,10 +23,6 @@ cairosvg==2.8.2 freetype-py==2.5.1 jinja2==3.1.6 -# esp-idf requires this, but doesn't bundle it by default -# https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24 -kconfiglib==13.7.1 - # esp-idf >= 5.0 requires this pyparsing >= 3.0 diff --git a/requirements_test.txt b/requirements_test.txt index c4227fdbde..4c60a31d7f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ -pylint==4.0.0 +pylint==4.0.1 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.0 # also change in .pre-commit-config.yaml when updating +ruff==0.14.1 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 39e1e66c75..98edfef145 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -56,6 +56,10 @@ DIRECT_BUS_TYPES = ("i2c", "spi", "uart", "modbus") # These components can be merged with any other group NO_BUSES_SIGNATURE = "no_buses" +# Prefix for isolated component signatures +# Isolated components have unique signatures and cannot be merged with others +ISOLATED_SIGNATURE_PREFIX = "isolated_" + # Base bus components - these ARE the bus implementations and should not # be flagged as needing migration since they are the platform/base components BASE_BUS_COMPONENTS = { @@ -75,9 +79,10 @@ ISOLATED_COMPONENTS = { "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", "lvgl": "Defines multiple SDL displays on host platform that conflict when merged with other display configs", + "mapping": "Uses dict format for image/display sections incompatible with standard list format - ESPHome merge_config cannot handle", + "openthread": "Conflicts with wifi: used by most components", + "openthread_info": "Conflicts with wifi: used by most components", "matrix_keypad": "Needs isolation due to keypad", - "mcp4725": "no YAML config to specify i2c bus id", - "mcp47a1": "no YAML config to specify i2c bus id", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", @@ -366,6 +371,143 @@ def analyze_all_components( return components, non_groupable, direct_bus_components +@lru_cache(maxsize=256) +def _get_bus_configs(buses: tuple[str, ...]) -> frozenset[tuple[str, str]]: + """Map bus type to set of configs for that type. + + Args: + buses: Tuple of bus package names (e.g., ("uart_9600", "i2c")) + + Returns: + Frozenset of (base_type, full_config) tuples + Example: frozenset({("uart", "uart_9600"), ("i2c", "i2c")}) + """ + # Split on underscore to get base type: "uart_9600" -> "uart", "i2c" -> "i2c" + return frozenset((bus.split("_", 1)[0], bus) for bus in buses) + + +@lru_cache(maxsize=1024) +def are_buses_compatible(buses1: tuple[str, ...], buses2: tuple[str, ...]) -> bool: + """Check if two bus tuples are compatible for merging. + + Two bus lists are compatible if they don't have conflicting configurations + for the same bus type. For example: + - ("ble", "uart") and ("i2c",) are compatible (different buses) + - ("uart_9600",) and ("uart_19200",) are NOT compatible (same bus, different configs) + - ("uart_9600",) and ("uart_9600",) are compatible (same bus, same config) + + Args: + buses1: First tuple of bus package names + buses2: Second tuple of bus package names + + Returns: + True if buses can be merged without conflicts + """ + configs1 = _get_bus_configs(buses1) + configs2 = _get_bus_configs(buses2) + + # Group configs by base type + bus_types1: dict[str, set[str]] = {} + for base_type, full_config in configs1: + if base_type not in bus_types1: + bus_types1[base_type] = set() + bus_types1[base_type].add(full_config) + + bus_types2: dict[str, set[str]] = {} + for base_type, full_config in configs2: + if base_type not in bus_types2: + bus_types2[base_type] = set() + bus_types2[base_type].add(full_config) + + # Check for conflicts: same bus type with different configs + for bus_type, configs in bus_types1.items(): + if bus_type not in bus_types2: + continue # No conflict - different bus types + # Same bus type - check if configs match + if configs != bus_types2[bus_type]: + return False # Conflict - same bus type, different configs + + return True # No conflicts found + + +def merge_compatible_bus_groups( + grouped_components: dict[tuple[str, str], list[str]], +) -> dict[tuple[str, str], list[str]]: + """Merge groups with compatible (non-conflicting) buses. + + This function takes groups keyed by (platform, bus_signature) and merges + groups that share the same platform and have compatible bus configurations. + Two groups can be merged if their buses don't conflict - meaning they don't + have different configurations for the same bus type. + + For example: + - ["ble"] + ["uart"] = compatible (different buses) + - ["uart_9600"] + ["uart_19200"] = incompatible (same bus, different configs) + - ["uart_9600"] + ["uart_9600"] = compatible (same bus, same config) + + Args: + grouped_components: Dictionary mapping (platform, signature) to list of component names + + Returns: + Dictionary with same structure but with compatible groups merged + """ + merged_groups: dict[tuple[str, str], list[str]] = {} + processed_keys: set[tuple[str, str]] = set() + + for (platform1, sig1), comps1 in sorted(grouped_components.items()): + if (platform1, sig1) in processed_keys: + continue + + # Skip NO_BUSES_SIGNATURE - kept separate for flexible batch distribution + # These components have no bus requirements and can be added to any batch + # as "fillers" for load balancing across CI runners + if sig1 == NO_BUSES_SIGNATURE: + merged_groups[(platform1, sig1)] = comps1 + processed_keys.add((platform1, sig1)) + continue + + # Skip isolated components - they can't be merged with others + if sig1.startswith(ISOLATED_SIGNATURE_PREFIX): + merged_groups[(platform1, sig1)] = comps1 + processed_keys.add((platform1, sig1)) + continue + + # Start with this group's components + merged_comps: list[str] = list(comps1) + merged_sig: str = sig1 + processed_keys.add((platform1, sig1)) + + # Get buses for this group as tuple for caching + buses1: tuple[str, ...] = tuple(sorted(sig1.split("+"))) + + # Try to merge with other groups on same platform + for (platform2, sig2), comps2 in sorted(grouped_components.items()): + if (platform2, sig2) in processed_keys: + continue + if platform2 != platform1: + continue # Different platforms can't be merged + if sig2 == NO_BUSES_SIGNATURE: + continue # Keep separate for flexible batch distribution + if sig2.startswith(ISOLATED_SIGNATURE_PREFIX): + continue # Isolated components can't be merged + + # Check if buses are compatible + buses2: tuple[str, ...] = tuple(sorted(sig2.split("+"))) + if are_buses_compatible(buses1, buses2): + # Compatible! Merge this group + merged_comps.extend(comps2) + processed_keys.add((platform2, sig2)) + # Update merged signature to include all unique buses + all_buses: set[str] = set(buses1) | set(buses2) + merged_sig = "+".join(sorted(all_buses)) + buses1 = tuple(sorted(all_buses)) # Update for next iteration + + # Store merged group + merged_groups[(platform1, merged_sig)] = merged_comps + + return merged_groups + + def create_grouping_signature( platform_buses: dict[str, list[str]], platform: str ) -> str: diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 9a55f1d136..4936434fc2 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -11,6 +11,7 @@ from typing import Any import aioesphomeapi.api_options_pb2 as pb import google.protobuf.descriptor_pb2 as descriptor +from google.protobuf.descriptor_pb2 import FieldDescriptorProto class WireType(IntEnum): @@ -148,7 +149,7 @@ class TypeInfo(ABC): @property def repeated(self) -> bool: """Check if the field is repeated.""" - return self._field.label == 3 + return self._field.label == FieldDescriptorProto.LABEL_REPEATED @property def wire_type(self) -> WireType: @@ -337,7 +338,7 @@ def create_field_type_info( needs_encode: bool = True, ) -> TypeInfo: """Create the appropriate TypeInfo instance for a field, handling repeated fields and custom options.""" - if field.label == 3: # repeated + if field.label == FieldDescriptorProto.LABEL_REPEATED: # Check if this repeated field has fixed_array_with_length_define option if ( fixed_size := get_field_opt(field, pb.fixed_array_with_length_define) @@ -1879,6 +1880,9 @@ def build_message_type( ) public_content.append("#endif") + # Collect fixed_vector fields for custom decode generation + fixed_vector_fields = [] + for field in desc.field: # Skip deprecated fields completely if field.options.deprecated: @@ -1887,7 +1891,7 @@ def build_message_type( # Validate that fixed_array_size is only used in encode-only messages if ( needs_decode - and field.label == 3 + and field.label == FieldDescriptorProto.LABEL_REPEATED and get_field_opt(field, pb.fixed_array_size) is not None ): raise ValueError( @@ -1900,7 +1904,7 @@ def build_message_type( # Validate that fixed_array_with_length_define is only used in encode-only messages if ( needs_decode - and field.label == 3 + and field.label == FieldDescriptorProto.LABEL_REPEATED and get_field_opt(field, pb.fixed_array_with_length_define) is not None ): raise ValueError( @@ -1910,6 +1914,14 @@ def build_message_type( f"since we cannot trust or control the number of items received from clients." ) + # Collect fixed_vector repeated fields for custom decode generation + if ( + needs_decode + and field.label == FieldDescriptorProto.LABEL_REPEATED + and get_field_opt(field, pb.fixed_vector, False) + ): + fixed_vector_fields.append((field.name, field.number)) + ti = create_field_type_info(field, needs_decode, needs_encode) # Skip field declarations for fields that are in the base class @@ -2018,6 +2030,22 @@ def build_message_type( prot = "bool decode_64bit(uint32_t field_id, Proto64Bit value) override;" protected_content.insert(0, prot) + # Generate custom decode() override for messages with FixedVector fields + if fixed_vector_fields: + # Generate the decode() implementation in cpp + o = f"void {desc.name}::decode(const uint8_t *buffer, size_t length) {{\n" + # Count and init each FixedVector field + for field_name, field_number in fixed_vector_fields: + o += f" uint32_t count_{field_name} = ProtoDecodableMessage::count_repeated_field(buffer, length, {field_number});\n" + o += f" this->{field_name}.init(count_{field_name});\n" + # Call parent decode to populate the fields + o += " ProtoDecodableMessage::decode(buffer, length);\n" + o += "}\n" + cpp += o + # Generate the decode() declaration in header (public method) + prot = "void decode(const uint8_t *buffer, size_t length) override;" + public_content.append(prot) + # Only generate encode method if this message needs encoding and has fields if needs_encode and encode: o = f"void {desc.name}::encode(ProtoWriteBuffer buffer) const {{" diff --git a/script/ci-custom.py b/script/ci-custom.py index bc1ebda93b..6b01623d92 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -501,7 +501,7 @@ def lint_constants_usage(): continue errs.append( f"Constant {highlight(constant)} is defined in {len(uses)} files. Please move all definitions of the " - f"constant to const.py (Uses: {', '.join(uses)}) in a separate PR. " + f"constant to const.py (Uses: {', '.join(str(u) for u in uses)}) in a separate PR. " "See https://developers.esphome.io/contributing/code/#python" ) return errs diff --git a/script/determine-jobs.py b/script/determine-jobs.py index ea43ed71ca..fa44941c29 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -38,6 +38,7 @@ Options: from __future__ import annotations import argparse +from functools import cache import json import os from pathlib import Path @@ -52,7 +53,6 @@ from helpers import ( changed_files, get_all_dependencies, get_components_from_integration_fixtures, - parse_list_components_output, root_path, ) @@ -219,6 +219,24 @@ def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) return any(file.endswith(extensions) for file in changed_files(branch)) +@cache +def _component_has_tests(component: str) -> bool: + """Check if a component has test files. + + Cached to avoid repeated filesystem operations for the same component. + + Args: + component: Component name to check + + Returns: + True if the component has test YAML files + """ + tests_dir = Path(root_path) / "tests" / "components" / component + if not tests_dir.exists(): + return False + return any(tests_dir.glob("test.*.yaml")) + + def detect_single_component_for_memory_impact( branch: str | None = None, ) -> dict[str, Any]: @@ -335,23 +353,37 @@ def main() -> None: run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) - # Get changed components using list-components.py for exact compatibility + # Get both directly changed and all changed components (with dependencies) in one call script_path = Path(__file__).parent / "list-components.py" - cmd = [sys.executable, str(script_path), "--changed"] + cmd = [sys.executable, str(script_path), "--changed-with-deps"] if args.branch: cmd.extend(["-b", args.branch]) result = subprocess.run(cmd, capture_output=True, text=True, check=True) - changed_components = parse_list_components_output(result.stdout) + component_data = json.loads(result.stdout) + directly_changed_components = component_data["directly_changed"] + changed_components = component_data["all_changed"] # Filter to only components that have test files # Components without tests shouldn't generate CI test jobs - tests_dir = Path(root_path) / "tests" / "components" changed_components_with_tests = [ + component for component in changed_components if _component_has_tests(component) + ] + + # Get directly changed components with tests (for isolated testing) + # These will be tested WITHOUT --testing-mode in CI to enable full validation + # (pin conflicts, etc.) since they contain the actual changes being reviewed + directly_changed_with_tests = [ component - for component in changed_components - if (component_test_dir := tests_dir / component).exists() - and any(component_test_dir.glob("test.*.yaml")) + for component in directly_changed_components + if _component_has_tests(component) + ] + + # Get dependency-only components (for grouped testing) + dependency_only_components = [ + component + for component in changed_components_with_tests + if component not in directly_changed_components ] # Detect single component change for memory impact analysis @@ -365,7 +397,11 @@ def main() -> None: "python_linters": run_python_linters, "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, + "directly_changed_components_with_tests": directly_changed_with_tests, + "dependency_only_components_with_tests": dependency_only_components, "component_test_count": len(changed_components_with_tests), + "directly_changed_count": len(directly_changed_with_tests), + "dependency_only_count": len(dependency_only_components), "memory_impact": memory_impact, } diff --git a/script/list-components.py b/script/list-components.py index 9ab1cdd852..9abb2bc345 100755 --- a/script/list-components.py +++ b/script/list-components.py @@ -185,33 +185,89 @@ def main(): "-c", "--changed", action="store_true", - help="List all components required for testing based on changes", + help="List all components with dependencies (used by clang-tidy). " + "When base test infrastructure changes, returns ALL components.", + ) + parser.add_argument( + "--changed-direct", + action="store_true", + help="List only directly changed components, ignoring infrastructure changes " + "(used by CI for isolation decisions)", + ) + parser.add_argument( + "--changed-with-deps", + action="store_true", + help="Output JSON with both directly changed and all changed components " + "(with dependencies), ignoring infrastructure changes (used by CI for test determination)", ) parser.add_argument( "-b", "--branch", help="Branch to compare changed files against" ) args = parser.parse_args() - if args.branch and not args.changed: - parser.error("--branch requires --changed") + if args.branch and not ( + args.changed or args.changed_direct or args.changed_with_deps + ): + parser.error( + "--branch requires --changed, --changed-direct, or --changed-with-deps" + ) - if args.changed: - # When --changed is passed, only get the changed files + if args.changed or args.changed_direct or args.changed_with_deps: + # When --changed* is passed, only get the changed files changed = changed_files(args.branch) - # If any base test file(s) changed, there's no need to filter out components - if any("tests/test_build_components" in file for file in changed): - # Need to get all component files + # If any base test file(s) changed, we need to check all components + # BUT only for --changed (used by clang-tidy for comprehensive checking) + # NOT for --changed-direct or --changed-with-deps (used by CI for targeted testing) + # + # Flag usage: + # - --changed: Used by clang-tidy (script/helpers.py get_changed_components) + # Returns: All components with dependencies when base test files change + # Reason: Test infrastructure changes may affect any component + # + # - --changed-direct: Used by CI isolation (script/determine-jobs.py) + # Returns: Only components with actual code changes (not infrastructure) + # Reason: Only directly changed components need isolated testing + # + # - --changed-with-deps: Used by CI test determination (script/determine-jobs.py) + # Returns: Components with code changes + their dependencies (not infrastructure) + # Reason: CI needs to test changed components and their dependents + base_test_changed = any( + "tests/test_build_components" in file for file in changed + ) + + if base_test_changed and not args.changed_direct and not args.changed_with_deps: + # Base test infrastructure changed - load all component files + # This is for --changed (clang-tidy) which needs comprehensive checking files = get_all_component_files() else: - # Only look at changed component files + # Only look at changed component files (ignore infrastructure changes) + # For --changed-direct: only actual component code changes matter (for isolation) + # For --changed-with-deps: only actual component code changes matter (for testing) files = [f for f in changed if filter_component_files(f)] else: # Get all component files files = get_all_component_files() - for c in get_components(files, args.changed): - print(c) + if args.changed_with_deps: + # Return JSON with both directly changed and all changed components + import json + + directly_changed = get_components(files, False) + all_changed = get_components(files, True) + output = { + "directly_changed": directly_changed, + "all_changed": all_changed, + } + print(json.dumps(output)) + elif args.changed_direct: + # Return only directly changed components (without dependencies) + for c in get_components(files, False): + print(c) + else: + # Return all changed components (with dependencies) - default behavior + for c in get_components(files, args.changed): + print(c) if __name__ == "__main__": diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index a19b65038c..59774edba9 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -16,6 +16,7 @@ The merger handles: from __future__ import annotations import argparse +from functools import lru_cache from pathlib import Path import re import sys @@ -28,6 +29,10 @@ from esphome import yaml_util from esphome.config_helpers import merge_config from script.analyze_component_buses import PACKAGE_DEPENDENCIES, get_common_bus_packages +# Prefix for dependency markers in package tracking +# Used to mark packages that are included transitively (e.g., uart via modbus) +DEPENDENCY_MARKER_PREFIX = "_dep_" + def load_yaml_file(yaml_file: Path) -> dict: """Load YAML file using ESPHome's YAML loader. @@ -44,6 +49,34 @@ def load_yaml_file(yaml_file: Path) -> dict: return yaml_util.load_yaml(yaml_file) +@lru_cache(maxsize=256) +def get_component_packages( + component_name: str, platform: str, tests_dir_str: str +) -> dict: + """Get packages dict from a component's test file with caching. + + This function is cached to avoid re-loading and re-parsing the same file + multiple times when extracting packages during cross-bus merging. + + Args: + component_name: Name of the component + platform: Platform name (e.g., "esp32-idf") + tests_dir_str: String path to tests/components directory (must be string for cache hashability) + + Returns: + Dictionary with 'packages' key containing the raw packages dict from the YAML, + or empty dict if no packages section exists + """ + tests_dir = Path(tests_dir_str) + test_file = tests_dir / component_name / f"test.{platform}.yaml" + comp_data = load_yaml_file(test_file) + + if "packages" not in comp_data or not isinstance(comp_data["packages"], dict): + return {} + + return comp_data["packages"] + + def extract_packages_from_yaml(data: dict) -> dict[str, str]: """Extract COMMON BUS package includes from parsed YAML. @@ -82,7 +115,7 @@ def extract_packages_from_yaml(data: dict) -> dict[str, str]: if dep not in common_bus_packages: continue # Mark as included via dependency - packages[f"_dep_{dep}"] = f"(included via {name})" + packages[f"{DEPENDENCY_MARKER_PREFIX}{dep}"] = f"(included via {name})" return packages @@ -195,6 +228,9 @@ def merge_component_configs( # Start with empty config merged_config_data = {} + # Convert tests_dir to string for caching + tests_dir_str = str(tests_dir) + # Process each component for comp_name in component_names: comp_dir = tests_dir / comp_name @@ -206,26 +242,29 @@ def merge_component_configs( # Load the component's test file comp_data = load_yaml_file(test_file) - # Validate packages are compatible - # Components with no packages (no_buses) can merge with any group + # Merge packages from all components (cross-bus merging) + # Components can have different packages (e.g., one with ble, another with uart) + # as long as they don't conflict (checked by are_buses_compatible before calling this) comp_packages = extract_packages_from_yaml(comp_data) if all_packages is None: - # First component - set the baseline - all_packages = comp_packages - elif not comp_packages: - # This component has no packages (no_buses) - it can merge with any group - pass - elif not all_packages: - # Previous components had no packages, but this one does - adopt these packages - all_packages = comp_packages - elif comp_packages != all_packages: - # Both have packages but they differ - this is an error - raise ValueError( - f"Component {comp_name} has different packages than previous components. " - f"Expected: {all_packages}, Got: {comp_packages}. " - f"All components must use the same common bus configs to be merged." - ) + # First component - initialize package dict + all_packages = comp_packages if comp_packages else {} + elif comp_packages: + # Merge packages - combine all unique package types + # If both have the same package type, verify they're identical + for pkg_name, pkg_config in comp_packages.items(): + if pkg_name in all_packages: + # Same package type - verify config matches + if all_packages[pkg_name] != pkg_config: + raise ValueError( + f"Component {comp_name} has conflicting config for package '{pkg_name}'. " + f"Expected: {all_packages[pkg_name]}, Got: {pkg_config}. " + f"Components with conflicting bus configs cannot be merged." + ) + else: + # New package type - add it + all_packages[pkg_name] = pkg_config # Handle $component_dir by replacing with absolute path # This allows components that use local file references to be grouped @@ -287,26 +326,51 @@ def merge_component_configs( # merge_config handles list merging with ID-based deduplication automatically merged_config_data = merge_config(merged_config_data, comp_data) - # Add packages back (only once, since they're identical) - # IMPORTANT: Only re-add common bus packages (spi, i2c, uart, etc.) + # Add merged packages back (union of all component packages) + # IMPORTANT: Only include common bus packages (spi, i2c, uart, etc.) # Do NOT re-add component-specific packages as they contain unprefixed $component_dir refs if all_packages: - first_comp_data = load_yaml_file( - tests_dir / component_names[0] / f"test.{platform}.yaml" - ) - if "packages" in first_comp_data and isinstance( - first_comp_data["packages"], dict - ): - # Filter to only include common bus packages - # Only dict format can contain common bus packages - common_bus_packages = get_common_bus_packages() - filtered_packages = { - name: value - for name, value in first_comp_data["packages"].items() - if name in common_bus_packages - } - if filtered_packages: - merged_config_data["packages"] = filtered_packages + # Build packages dict from merged all_packages + # all_packages is a dict mapping package_name -> str(package_value) + # We need to reconstruct the actual package values by loading them from any component + # Since packages with the same name must have identical configs (verified above), + # we can load the package value from the first component that has each package + common_bus_packages = get_common_bus_packages() + merged_packages: dict[str, Any] = {} + + # Collect packages that are included as dependencies + # If modbus is present, uart is included via modbus.packages.uart + packages_to_skip: set[str] = set() + for pkg_name in all_packages: + if pkg_name.startswith(DEPENDENCY_MARKER_PREFIX): + # Extract the actual package name (remove _dep_ prefix) + dep_name = pkg_name[len(DEPENDENCY_MARKER_PREFIX) :] + packages_to_skip.add(dep_name) + + for pkg_name in all_packages: + # Skip dependency markers + if pkg_name.startswith(DEPENDENCY_MARKER_PREFIX): + continue + # Skip non-common-bus packages + if pkg_name not in common_bus_packages: + continue + # Skip packages that are included as dependencies of other packages + # This prevents duplicate definitions (e.g., uart via modbus + uart separately) + if pkg_name in packages_to_skip: + continue + + # Find a component that has this package and extract its value + # Uses cached lookup to avoid re-loading the same files + for comp_name in component_names: + comp_packages = get_component_packages( + comp_name, platform, tests_dir_str + ) + if pkg_name in comp_packages: + merged_packages[pkg_name] = comp_packages[pkg_name] + break + + if merged_packages: + merged_config_data["packages"] = merged_packages # Deduplicate items with same ID (keeps first occurrence) merged_config_data = deduplicate_by_id(merged_config_data) diff --git a/script/split_components_for_ci.py b/script/split_components_for_ci.py index ad2261499c..dff46d3619 100755 --- a/script/split_components_for_ci.py +++ b/script/split_components_for_ci.py @@ -22,9 +22,11 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from script.analyze_component_buses import ( ISOLATED_COMPONENTS, + ISOLATED_SIGNATURE_PREFIX, NO_BUSES_SIGNATURE, analyze_all_components, create_grouping_signature, + merge_compatible_bus_groups, ) # Weighting for batch creation @@ -33,6 +35,10 @@ from script.analyze_component_buses import ( ISOLATED_WEIGHT = 10 GROUPABLE_WEIGHT = 1 +# Platform used for batching (platform-agnostic batching) +# Batches are split across CI runners and each runner tests all platforms +ALL_PLATFORMS = "all" + def has_test_files(component_name: str, tests_dir: Path) -> bool: """Check if a component has test files. @@ -56,16 +62,20 @@ def create_intelligent_batches( components: list[str], tests_dir: Path, batch_size: int = 40, -) -> list[list[str]]: + directly_changed: set[str] | None = None, +) -> tuple[list[list[str]], dict[tuple[str, str], list[str]]]: """Create batches optimized for component grouping. Args: components: List of component names to batch tests_dir: Path to tests/components directory batch_size: Target size for each batch + directly_changed: Set of directly changed components (for logging only) Returns: - List of component batches (lists of component names) + Tuple of (batches, signature_groups) where: + - batches: List of component batches (lists of component names) + - signature_groups: Dict mapping (platform, signature) to component lists """ # Filter out components without test files # Platform components like 'climate' and 'climate_ir' don't have test files @@ -89,16 +99,26 @@ def create_intelligent_batches( # Group components by their bus signature ONLY (ignore platform) # All platforms will be tested by test_build_components.py for each batch - # Key: signature, Value: list of components - signature_groups: dict[str, list[str]] = defaultdict(list) + # Key: (platform, signature), Value: list of components + # We use ALL_PLATFORMS since batching is platform-agnostic + signature_groups: dict[tuple[str, str], list[str]] = defaultdict(list) for component in components_with_tests: # Components that can't be grouped get unique signatures - # This includes both manually curated ISOLATED_COMPONENTS and - # automatically detected non_groupable components + # This includes: + # - Manually curated ISOLATED_COMPONENTS + # - Automatically detected non_groupable components + # - Directly changed components (passed via --isolate in CI) # These can share a batch/runner but won't be grouped/merged - if component in ISOLATED_COMPONENTS or component in non_groupable: - signature_groups[f"isolated_{component}"].append(component) + is_isolated = ( + component in ISOLATED_COMPONENTS + or component in non_groupable + or (directly_changed and component in directly_changed) + ) + if is_isolated: + signature_groups[ + (ALL_PLATFORMS, f"{ISOLATED_SIGNATURE_PREFIX}{component}") + ].append(component) continue # Get signature from any platform (they should all have the same buses) @@ -108,11 +128,17 @@ def create_intelligent_batches( if buses: signature = create_grouping_signature({platform: buses}, platform) # Group by signature only - platform doesn't matter for batching - signature_groups[signature].append(component) + # Use ALL_PLATFORMS since we're batching across all platforms + signature_groups[(ALL_PLATFORMS, signature)].append(component) break # Only use first platform for grouping else: # No buses found for any platform - can be grouped together - signature_groups[NO_BUSES_SIGNATURE].append(component) + signature_groups[(ALL_PLATFORMS, NO_BUSES_SIGNATURE)].append(component) + + # Merge compatible bus groups (cross-bus optimization) + # This allows components with different buses (ble + uart) to be batched together + # improving the efficiency of test_build_components.py grouping + signature_groups = merge_compatible_bus_groups(signature_groups) # Create batches by keeping signature groups together # Components with the same signature stay in the same batches @@ -123,8 +149,8 @@ def create_intelligent_batches( # 2. Sort groupable signatures by size (largest first) # 3. "no_buses" components CAN be grouped together def sort_key(item): - signature, components = item - is_isolated = signature.startswith("isolated_") + (_platform, signature), components = item + is_isolated = signature.startswith(ISOLATED_SIGNATURE_PREFIX) # Put "isolated_*" last (1), groupable first (0) # Within each category, sort by size (largest first) return (is_isolated, -len(components)) @@ -140,8 +166,8 @@ def create_intelligent_batches( current_batch = [] current_weight = 0 - for signature, group_components in sorted_groups: - is_isolated = signature.startswith("isolated_") + for (_platform, signature), group_components in sorted_groups: + is_isolated = signature.startswith(ISOLATED_SIGNATURE_PREFIX) weight_per_component = ISOLATED_WEIGHT if is_isolated else GROUPABLE_WEIGHT for component in group_components: @@ -160,7 +186,7 @@ def create_intelligent_batches( if current_batch: batches.append(current_batch) - return batches + return batches, signature_groups def main() -> int: @@ -187,6 +213,10 @@ def main() -> int: default=Path("tests/components"), help="Path to tests/components directory", ) + parser.add_argument( + "--directly-changed", + help="JSON array of directly changed component names (for logging only)", + ) parser.add_argument( "--output", "-o", @@ -208,11 +238,21 @@ def main() -> int: print("Components must be a JSON array", file=sys.stderr) return 1 + # Parse directly changed components list from JSON (if provided) + directly_changed = None + if args.directly_changed: + try: + directly_changed = set(json.loads(args.directly_changed)) + except json.JSONDecodeError as e: + print(f"Error parsing directly-changed JSON: {e}", file=sys.stderr) + return 1 + # Create intelligent batches - batches = create_intelligent_batches( + batches, signature_groups = create_intelligent_batches( components=components, tests_dir=args.tests_dir, batch_size=args.batch_size, + directly_changed=directly_changed, ) # Convert batches to space-separated strings for CI @@ -233,18 +273,94 @@ def main() -> int: # Re-analyze to get isolated component counts for summary _, non_groupable, _ = analyze_all_components(args.tests_dir) + # Show grouping details + print("\n=== Component Grouping Details ===", file=sys.stderr) + # Sort groups by signature for readability + groupable_groups = [] + isolated_groups = [] + for (platform, signature), group_comps in sorted(signature_groups.items()): + if signature.startswith(ISOLATED_SIGNATURE_PREFIX): + isolated_groups.append((signature, group_comps)) + else: + groupable_groups.append((signature, group_comps)) + + if groupable_groups: + print( + f"\nGroupable signatures ({len(groupable_groups)} merged groups after cross-bus optimization):", + file=sys.stderr, + ) + for signature, group_comps in sorted( + groupable_groups, key=lambda x: (-len(x[1]), x[0]) + ): + # Check if this is a merged signature (contains +) + is_merged = "+" in signature and signature != NO_BUSES_SIGNATURE + # Special handling for no_buses components + if signature == NO_BUSES_SIGNATURE: + print( + f" [{signature}]: {len(group_comps)} components (used as fillers across batches)", + file=sys.stderr, + ) + else: + merge_indicator = " [MERGED]" if is_merged else "" + print( + f" [{signature}]{merge_indicator}: {len(group_comps)} components", + file=sys.stderr, + ) + # Show first few components as examples + examples = ", ".join(sorted(group_comps)[:8]) + if len(group_comps) > 8: + examples += f", ... (+{len(group_comps) - 8} more)" + print(f" → {examples}", file=sys.stderr) + + if isolated_groups: + print( + f"\nIsolated components ({len(isolated_groups)} components - tested individually):", + file=sys.stderr, + ) + isolated_names = sorted( + [comp for _, comps in isolated_groups for comp in comps] + ) + # Group isolated components for compact display + for i in range(0, len(isolated_names), 10): + chunk = isolated_names[i : i + 10] + print(f" {', '.join(chunk)}", file=sys.stderr) + # Count isolated vs groupable components all_batched_components = [comp for batch in batches for comp in batch] isolated_count = sum( 1 for comp in all_batched_components - if comp in ISOLATED_COMPONENTS or comp in non_groupable + if comp in ISOLATED_COMPONENTS + or comp in non_groupable + or (directly_changed and comp in directly_changed) ) groupable_count = actual_components - isolated_count print("\n=== Intelligent Batch Summary ===", file=sys.stderr) print(f"Total components requested: {len(components)}", file=sys.stderr) print(f"Components with test files: {actual_components}", file=sys.stderr) + + # Show breakdown of directly changed vs dependencies + if directly_changed: + direct_count = sum( + 1 for comp in all_batched_components if comp in directly_changed + ) + dep_count = actual_components - direct_count + direct_comps = [ + comp for comp in all_batched_components if comp in directly_changed + ] + dep_comps = [ + comp for comp in all_batched_components if comp not in directly_changed + ] + print( + f" - Direct changes: {direct_count} ({', '.join(sorted(direct_comps))})", + file=sys.stderr, + ) + print( + f" - Dependencies: {dep_count} ({', '.join(sorted(dep_comps))})", + file=sys.stderr, + ) + print(f" - Groupable (weight=1): {groupable_count}", file=sys.stderr) print(f" - Isolated (weight=10): {isolated_count}", file=sys.stderr) if actual_components < len(components): diff --git a/script/test_build_components.py b/script/test_build_components.py index e27bd695c1..df092c091d 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -17,11 +17,13 @@ from __future__ import annotations import argparse from collections import defaultdict +from dataclasses import dataclass import hashlib import os from pathlib import Path import subprocess import sys +import time # Add esphome to path sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -34,32 +36,49 @@ from script.analyze_component_buses import ( analyze_all_components, create_grouping_signature, is_platform_component, + merge_compatible_bus_groups, uses_local_file_references, ) from script.merge_component_configs import merge_component_configs -# Platform-specific maximum group sizes -# ESP8266 has limited IRAM and can't handle large component groups -PLATFORM_MAX_GROUP_SIZE = { - "esp8266-ard": 10, # ESP8266 Arduino has limited IRAM - "esp8266-idf": 10, # ESP8266 IDF also has limited IRAM - # BK72xx now uses BK7252 board (1.62MB flash vs 1.03MB) - no limit needed - # Other platforms can handle larger groups -} + +@dataclass +class TestResult: + """Store information about a single test run.""" + + test_id: str + components: list[str] + platform: str + success: bool + duration: float + command: str = "" + test_type: str = "compile" # "config" or "compile" def show_disk_space_if_ci(esphome_command: str) -> None: """Show disk space usage if running in CI during compile. + Only shows output during compilation (not config validation) since + disk space is only relevant when actually building firmware. + Args: esphome_command: The esphome command being run (config/compile/clean) """ - if os.environ.get("GITHUB_ACTIONS") and esphome_command == "compile": - print("\n" + "=" * 80) - print("Disk Space After Build:") - print("=" * 80) - subprocess.run(["df", "-h"], check=False) - print("=" * 80 + "\n") + # Only show disk space during compilation in CI + # Config validation doesn't build anything so disk space isn't relevant + if not os.environ.get("GITHUB_ACTIONS"): + return + if esphome_command != "compile": + return + + print("\n" + "=" * 80) + print("Disk Space After Build:") + print("=" * 80) + # Use sys.stdout.flush() to ensure output appears immediately + sys.stdout.flush() + subprocess.run(["df", "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr) + print("=" * 80 + "\n") + sys.stdout.flush() def find_component_tests( @@ -80,7 +99,8 @@ def find_component_tests( if not comp_dir.is_dir(): continue - for test_file in comp_dir.glob("test.*.yaml"): + # Find test files matching test.*.yaml or test-*.yaml patterns + for test_file in comp_dir.glob("test[.-]*.yaml"): component_tests[comp_dir.name].append(test_file) return dict(component_tests) @@ -128,6 +148,140 @@ def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]: return dict(platform_files) +def group_components_by_platform( + failed_results: list[TestResult], +) -> dict[tuple[str, str], list[str]]: + """Group failed components by platform and test type for simplified reproduction commands. + + Args: + failed_results: List of failed test results + + Returns: + Dictionary mapping (platform, test_type) to list of component names + """ + platform_components: dict[tuple[str, str], list[str]] = {} + for result in failed_results: + key = (result.platform, result.test_type) + if key not in platform_components: + platform_components[key] = [] + platform_components[key].extend(result.components) + + # Remove duplicates and sort for each platform + return { + key: sorted(set(components)) for key, components in platform_components.items() + } + + +def format_github_summary(test_results: list[TestResult]) -> str: + """Format test results as GitHub Actions job summary markdown. + + Args: + test_results: List of all test results + + Returns: + Markdown formatted summary string + """ + # Separate results into passed and failed + passed_results = [r for r in test_results if r.success] + failed_results = [r for r in test_results if not r.success] + + lines = [] + + # Header with emoji based on success/failure + if failed_results: + lines.append("## :x: Component Tests Failed\n") + else: + lines.append("## :white_check_mark: Component Tests Passed\n") + + # Summary statistics + total_time = sum(r.duration for r in test_results) + # Determine test type from results (all should be the same) + test_type = test_results[0].test_type if test_results else "unknown" + lines.append( + f"**Results:** {len(passed_results)} passed, {len(failed_results)} failed\n" + ) + lines.append(f"**Total time:** {total_time:.1f}s\n") + lines.append(f"**Test type:** `{test_type}`\n") + + # Show failed tests if any + if failed_results: + lines.append("### Failed Tests\n") + lines.append("| Test | Components | Platform | Duration |\n") + lines.append("|------|-----------|----------|----------|\n") + for result in failed_results: + components_str = ", ".join(result.components) + lines.append( + f"| `{result.test_id}` | {components_str} | {result.platform} | {result.duration:.1f}s |\n" + ) + lines.append("\n") + + # Show simplified commands to reproduce failures + # Group all failed components by platform for a single command per platform + lines.append("
\n") + lines.append("Commands to reproduce failures\n\n") + lines.append("```bash\n") + + # Generate one command per platform and test type + platform_components = group_components_by_platform(failed_results) + for platform, test_type in sorted(platform_components.keys()): + components_csv = ",".join(platform_components[(platform, test_type)]) + lines.append( + f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}\n" + ) + + lines.append("```\n") + lines.append("
\n") + + # Show passed tests + if passed_results: + lines.append("### Passed Tests\n\n") + lines.append(f"{len(passed_results)} tests passed successfully\n") + + # Separate grouped and individual tests + grouped_results = [r for r in passed_results if len(r.components) > 1] + individual_results = [r for r in passed_results if len(r.components) == 1] + + if grouped_results: + lines.append("#### Grouped Tests\n") + lines.append("| Components | Platform | Count | Duration |\n") + lines.append("|-----------|----------|-------|----------|\n") + for result in grouped_results: + components_str = ", ".join(result.components) + lines.append( + f"| {components_str} | {result.platform} | {len(result.components)} | {result.duration:.1f}s |\n" + ) + lines.append("\n") + + if individual_results: + lines.append("#### Individual Tests\n") + # Show first 10 individual tests with timing + if len(individual_results) <= 10: + lines.extend( + f"- `{result.test_id}` - {result.duration:.1f}s\n" + for result in individual_results + ) + else: + lines.extend( + f"- `{result.test_id}` - {result.duration:.1f}s\n" + for result in individual_results[:10] + ) + lines.append(f"\n...and {len(individual_results) - 10} more\n") + lines.append("\n") + + return "".join(lines) + + +def write_github_summary(test_results: list[TestResult]) -> None: + """Write GitHub Actions job summary with test results and timing. + + Args: + test_results: List of all test results + """ + summary_content = format_github_summary(test_results) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as f: + f.write(summary_content) + + def extract_platform_with_version(base_file: Path) -> str: """Extract platform with version from base filename. @@ -151,7 +305,7 @@ def run_esphome_test( esphome_command: str, continue_on_fail: bool, use_testing_mode: bool = False, -) -> tuple[bool, str]: +) -> TestResult: """Run esphome test for a single component. Args: @@ -166,7 +320,7 @@ def run_esphome_test( use_testing_mode: Whether to use --testing-mode flag Returns: - Tuple of (success status, command string) + TestResult object with test details and timing """ test_name = test_file.stem.split(".")[0] @@ -221,9 +375,13 @@ def run_esphome_test( if use_testing_mode: print(" (using --testing-mode)") + start_time = time.time() + test_id = f"{component}.{test_name}.{platform_with_version}" + try: result = subprocess.run(cmd, check=False) success = result.returncode == 0 + duration = time.time() - start_time # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) @@ -236,12 +394,30 @@ def run_esphome_test( print(cmd_str) print() raise subprocess.CalledProcessError(result.returncode, cmd) - return success, cmd_str + + return TestResult( + test_id=test_id, + components=[component], + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) except subprocess.CalledProcessError: + duration = time.time() - start_time # Re-raise if we're not continuing on fail if not continue_on_fail: raise - return False, cmd_str + return TestResult( + test_id=test_id, + components=[component], + platform=platform_with_version, + success=False, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_test( @@ -253,7 +429,7 @@ def run_grouped_test( tests_dir: Path, esphome_command: str, continue_on_fail: bool, -) -> tuple[bool, str]: +) -> TestResult: """Run esphome test for a group of components with shared bus configs. Args: @@ -267,7 +443,7 @@ def run_grouped_test( continue_on_fail: Whether to continue on failure Returns: - Tuple of (success status, command string) + TestResult object with test details and timing """ # Create merged config group_name = "_".join(components[:3]) # Use first 3 components for name @@ -294,8 +470,17 @@ def run_grouped_test( print(f"Error merging configs for {components}: {e}") if not continue_on_fail: raise - # Return empty command string since we failed before building the command - return False, f"# Failed during config merge: {e}" + # Return TestResult for merge failure + test_id = f"GROUPED[{','.join(components)}].{platform_with_version}" + return TestResult( + test_id=test_id, + components=components, + platform=platform_with_version, + success=False, + duration=0.0, + command=f"# Failed during config merge: {e}", + test_type=esphome_command, + ) # Create test file that includes merged config output_file = build_dir / f"test_{group_name}.{platform_with_version}.yaml" @@ -334,9 +519,13 @@ def run_grouped_test( print(f"> [GROUPED: {components_str}] [{platform_with_version}]") print(" (using --testing-mode)") + start_time = time.time() + test_id = f"GROUPED[{','.join(components)}].{platform_with_version}" + try: result = subprocess.run(cmd, check=False) success = result.returncode == 0 + duration = time.time() - start_time # Show disk space after build in CI during compile show_disk_space_if_ci(esphome_command) @@ -349,12 +538,30 @@ def run_grouped_test( print(cmd_str) print() raise subprocess.CalledProcessError(result.returncode, cmd) - return success, cmd_str + + return TestResult( + test_id=test_id, + components=components, + platform=platform_with_version, + success=success, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) except subprocess.CalledProcessError: + duration = time.time() - start_time # Re-raise if we're not continuing on fail if not continue_on_fail: raise - return False, cmd_str + return TestResult( + test_id=test_id, + components=components, + platform=platform_with_version, + success=False, + duration=duration, + command=cmd_str, + test_type=esphome_command, + ) def run_grouped_component_tests( @@ -365,7 +572,8 @@ def run_grouped_component_tests( build_dir: Path, esphome_command: str, continue_on_fail: bool, -) -> tuple[set[tuple[str, str]], list[str], list[str], dict[str, str]]: + additional_isolated: set[str] | None = None, +) -> tuple[set[tuple[str, str]], list[TestResult]]: """Run grouped component tests. Args: @@ -376,14 +584,13 @@ def run_grouped_component_tests( build_dir: Path to build directory esphome_command: ESPHome command (config/compile) continue_on_fail: Whether to continue on failure + additional_isolated: Additional components to treat as isolated (not grouped) Returns: - Tuple of (tested_components, passed_tests, failed_tests, failed_commands) + Tuple of (tested_components, test_results) """ tested_components = set() - passed_tests = [] - failed_tests = [] - failed_commands = {} # Map test_id to command string + test_results = [] # Group components by platform and bus signature grouped_components: dict[tuple[str, str], list[str]] = defaultdict(list) @@ -397,6 +604,17 @@ def run_grouped_component_tests( # Track why components can't be grouped (for detailed output) non_groupable_reasons = {} + # Merge additional isolated components with predefined ones + # ISOLATED COMPONENTS are tested individually WITHOUT --testing-mode + # This is critical because: + # - Grouped tests use --testing-mode which disables pin conflict checks and other validation + # - These checks are disabled to allow config merging (multiple components in one build) + # - For directly changed components (via --isolate), we need full validation to catch issues + # - Dependencies are safe to group since they weren't modified in the PR + all_isolated = set(ISOLATED_COMPONENTS.keys()) + if additional_isolated: + all_isolated.update(additional_isolated) + # Group by (platform, bus_signature) for component, platforms in component_buses.items(): if component not in all_tests: @@ -404,7 +622,7 @@ def run_grouped_component_tests( # Skip components that must be tested in isolation # These are shown separately and should not be in non_groupable_reasons - if component in ISOLATED_COMPONENTS: + if component in all_isolated: continue # Skip base bus components (these test the bus platforms themselves) @@ -449,19 +667,37 @@ def run_grouped_component_tests( if signature: grouped_components[(platform, signature)].append(component) + # Merge groups with compatible buses (cross-bus grouping optimization) + # This allows mixing components with different buses (e.g., ble + uart) + # as long as they don't have conflicting configurations for the same bus type + grouped_components = merge_compatible_bus_groups(grouped_components) + # Print detailed grouping plan print("\nGrouping Plan:") print("-" * 80) - # Show isolated components (must test individually due to known issues) - isolated_in_tests = [c for c in ISOLATED_COMPONENTS if c in all_tests] + # Show isolated components (must test individually due to known issues or direct changes) + isolated_in_tests = [c for c in all_isolated if c in all_tests] if isolated_in_tests: - print( - f"\n⚠ {len(isolated_in_tests)} components must be tested in isolation (known build issues):" - ) - for comp in sorted(isolated_in_tests): - reason = ISOLATED_COMPONENTS[comp] - print(f" - {comp}: {reason}") + predefined_isolated = [c for c in isolated_in_tests if c in ISOLATED_COMPONENTS] + additional_in_tests = [ + c for c in isolated_in_tests if c in (additional_isolated or set()) + ] + + if predefined_isolated: + print( + f"\n⚠ {len(predefined_isolated)} components must be tested in isolation (known build issues):" + ) + for comp in sorted(predefined_isolated): + reason = ISOLATED_COMPONENTS[comp] + print(f" - {comp}: {reason}") + + if additional_in_tests: + print( + f"\n✓ {len(additional_in_tests)} components tested in isolation (directly changed in PR):" + ) + for comp in sorted(additional_in_tests): + print(f" - {comp}") # Show base bus components (test the bus platform implementations) base_bus_in_tests = [c for c in BASE_BUS_COMPONENTS if c in all_tests] @@ -534,28 +770,6 @@ def run_grouped_component_tests( # No other groups for this platform - keep no_buses components together grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps - # Split groups that exceed platform-specific maximum sizes - # ESP8266 has limited IRAM and can't handle large component groups - split_groups = {} - for (platform, signature), components in list(grouped_components.items()): - max_size = PLATFORM_MAX_GROUP_SIZE.get(platform) - if max_size and len(components) > max_size: - # Split this group into smaller groups - print( - f"\n ℹ️ Splitting {platform} group (signature: {signature}) " - f"from {len(components)} to max {max_size} components per group" - ) - # Remove original group - del grouped_components[(platform, signature)] - # Create split groups - for i in range(0, len(components), max_size): - split_components = components[i : i + max_size] - # Create unique signature for each split group - split_signature = f"{signature}_split{i // max_size + 1}" - split_groups[(platform, split_signature)] = split_components - # Add split groups back - grouped_components.update(split_groups) - groups_to_test = [] individual_tests = set() # Use set to avoid duplicates @@ -646,7 +860,7 @@ def run_grouped_component_tests( continue # Run grouped test - success, cmd_str = run_grouped_test( + test_result = run_grouped_test( components=components_to_group, platform=platform, platform_with_version=platform_with_version, @@ -661,17 +875,10 @@ def run_grouped_component_tests( for comp in components_to_group: tested_components.add((comp, platform_with_version)) - # Record result for each component - show all components in grouped tests - test_id = ( - f"GROUPED[{','.join(components_to_group)}].{platform_with_version}" - ) - if success: - passed_tests.append(test_id) - else: - failed_tests.append(test_id) - failed_commands[test_id] = cmd_str + # Store test result + test_results.append(test_result) - return tested_components, passed_tests, failed_tests, failed_commands + return tested_components, test_results def run_individual_component_test( @@ -684,9 +891,7 @@ def run_individual_component_test( esphome_command: str, continue_on_fail: bool, tested_components: set[tuple[str, str]], - passed_tests: list[str], - failed_tests: list[str], - failed_commands: dict[str, str], + test_results: list[TestResult], ) -> None: """Run an individual component test if not already tested in a group. @@ -700,16 +905,13 @@ def run_individual_component_test( esphome_command: ESPHome command continue_on_fail: Whether to continue on failure tested_components: Set of already tested components - passed_tests: List to append passed test IDs - failed_tests: List to append failed test IDs - failed_commands: Dict to store failed test commands + test_results: List to append test results """ # Skip if already tested in a group if (component, platform_with_version) in tested_components: return - test_name = test_file.stem.split(".")[0] - success, cmd_str = run_esphome_test( + test_result = run_esphome_test( component=component, test_file=test_file, platform=platform, @@ -719,12 +921,7 @@ def run_individual_component_test( esphome_command=esphome_command, continue_on_fail=continue_on_fail, ) - test_id = f"{component}.{test_name}.{platform_with_version}" - if success: - passed_tests.append(test_id) - else: - failed_tests.append(test_id) - failed_commands[test_id] = cmd_str + test_results.append(test_result) def test_components( @@ -733,6 +930,7 @@ def test_components( esphome_command: str, continue_on_fail: bool, enable_grouping: bool = True, + isolated_components: set[str] | None = None, ) -> int: """Test components with optional intelligent grouping. @@ -742,6 +940,10 @@ def test_components( esphome_command: ESPHome command (config/compile) continue_on_fail: Whether to continue on failure enable_grouping: Whether to enable component grouping + isolated_components: Set of component names to test in isolation (not grouped). + These are tested WITHOUT --testing-mode to enable full validation + (pin conflicts, etc). This is used in CI for directly changed components + to catch issues that would be missed with --testing-mode. Returns: Exit code (0 for success, 1 for failure) @@ -768,19 +970,12 @@ def test_components( print(f"Found {len(all_tests)} components to test") # Run tests - failed_tests = [] - passed_tests = [] + test_results = [] tested_components = set() # Track which components were tested in groups - failed_commands = {} # Track commands for failed tests # First, run grouped tests if grouping is enabled if enable_grouping: - ( - tested_components, - passed_tests, - failed_tests, - failed_commands, - ) = run_grouped_component_tests( + tested_components, grouped_results = run_grouped_component_tests( all_tests=all_tests, platform_filter=platform_filter, platform_bases=platform_bases, @@ -788,7 +983,9 @@ def test_components( build_dir=build_dir, esphome_command=esphome_command, continue_on_fail=continue_on_fail, + additional_isolated=isolated_components, ) + test_results.extend(grouped_results) # Then run individual tests for components not in groups for component, test_files in sorted(all_tests.items()): @@ -814,9 +1011,7 @@ def test_components( esphome_command=esphome_command, continue_on_fail=continue_on_fail, tested_components=tested_components, - passed_tests=passed_tests, - failed_tests=failed_tests, - failed_commands=failed_commands, + test_results=test_results, ) else: # Platform-specific test @@ -848,31 +1043,40 @@ def test_components( esphome_command=esphome_command, continue_on_fail=continue_on_fail, tested_components=tested_components, - passed_tests=passed_tests, - failed_tests=failed_tests, - failed_commands=failed_commands, + test_results=test_results, ) + # Separate results into passed and failed + passed_results = [r for r in test_results if r.success] + failed_results = [r for r in test_results if not r.success] + # Print summary print("\n" + "=" * 80) - print(f"Test Summary: {len(passed_tests)} passed, {len(failed_tests)} failed") + print(f"Test Summary: {len(passed_results)} passed, {len(failed_results)} failed") print("=" * 80) - if failed_tests: + if failed_results: print("\nFailed tests:") - for test in failed_tests: - print(f" - {test}") + for result in failed_results: + print(f" - {result.test_id}") - # Print failed commands at the end for easy copy-paste from CI logs + # Print simplified commands grouped by platform and test type for easy copy-paste print("\n" + "=" * 80) - print("Failed test commands (copy-paste to reproduce locally):") + print("Commands to reproduce failures (copy-paste to reproduce locally):") print("=" * 80) - for test in failed_tests: - if test in failed_commands: - print(f"\n# {test}") - print(failed_commands[test]) + platform_components = group_components_by_platform(failed_results) + for platform, test_type in sorted(platform_components.keys()): + components_csv = ",".join(platform_components[(platform, test_type)]) + print( + f"script/test_build_components.py -c {components_csv} -t {platform} -e {test_type}" + ) print() + # Write GitHub Actions job summary if in CI + if os.environ.get("GITHUB_STEP_SUMMARY"): + write_github_summary(test_results) + + if failed_results: return 1 return 0 @@ -912,18 +1116,30 @@ def main() -> int: action="store_true", help="Disable component grouping (test each component individually)", ) + parser.add_argument( + "--isolate", + help="Comma-separated list of components to test in isolation (not grouped with others). " + "These are tested WITHOUT --testing-mode to enable full validation. " + "Used in CI for directly changed components to catch pin conflicts and other issues.", + ) args = parser.parse_args() # Parse component patterns component_patterns = [p.strip() for p in args.components.split(",")] + # Parse isolated components + isolated_components = None + if args.isolate: + isolated_components = {c.strip() for c in args.isolate.split(",") if c.strip()} + return test_components( component_patterns=component_patterns, platform_filter=args.target, esphome_command=args.esphome_command, continue_on_fail=args.continue_on_fail, enable_grouping=not args.no_grouping, + isolated_components=isolated_components, ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index fbb3222812..e68f6fbfba 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -69,7 +69,7 @@ def run_schema_validation(config: ConfigType) -> None: { "id": "display_id", "model": "custom", - "dimensions": {"width": 320, "height": 240}, + "dimensions": {"width": 260, "height": 260}, "draw_rounding": 13, "init_sequence": [[0xA0, 0x01]], }, @@ -336,7 +336,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp diff --git a/tests/components/ade7880/test.esp8266-ard.yaml b/tests/components/ade7880/test.esp8266-ard.yaml index 81a04d0724..8b5e47f0b5 100644 --- a/tests/components/ade7880/test.esp8266-ard.yaml +++ b/tests/components/ade7880/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ substitutions: - irq0_pin: GPIO13 + irq0_pin: GPIO0 irq1_pin: GPIO15 reset_pin: GPIO16 diff --git a/tests/components/ade7953_i2c/common.yaml b/tests/components/ade7953_i2c/common.yaml index 8b2a9588fe..5253759888 100644 --- a/tests/components/ade7953_i2c/common.yaml +++ b/tests/components/ade7953_i2c/common.yaml @@ -4,10 +4,13 @@ sensor: irq_pin: ${irq_pin} voltage: name: ADE7953 Voltage + id: ade7953_i2c_voltage current_a: name: ADE7953 Current A + id: ade7953_i2c_current_a current_b: name: ADE7953 Current B + id: ade7953_i2c_current_b power_factor_a: name: ADE7953 Power Factor A power_factor_b: diff --git a/tests/components/ade7953_spi/common.yaml b/tests/components/ade7953_spi/common.yaml index 30b5258a2a..f66ab697a1 100644 --- a/tests/components/ade7953_spi/common.yaml +++ b/tests/components/ade7953_spi/common.yaml @@ -4,13 +4,13 @@ sensor: irq_pin: ${irq_pin} voltage: name: ADE7953 Voltage - id: ade7953_voltage + id: ade7953_spi_voltage current_a: name: ADE7953 Current A - id: ade7953_current_a + id: ade7953_spi_current_a current_b: name: ADE7953 Current B - id: ade7953_current_b + id: ade7953_spi_current_b power_factor_a: name: ADE7953 Power Factor A power_factor_b: diff --git a/tests/components/as3935_i2c/common.yaml b/tests/components/as3935_i2c/common.yaml index a758bb7f56..d659486e83 100644 --- a/tests/components/as3935_i2c/common.yaml +++ b/tests/components/as3935_i2c/common.yaml @@ -1,13 +1,16 @@ as3935_i2c: + id: as3935_i2c_id i2c_id: i2c_bus irq_pin: ${irq_pin} binary_sensor: - platform: as3935 + as3935_id: as3935_i2c_id name: Storm Alert sensor: - platform: as3935 + as3935_id: as3935_i2c_id lightning_energy: name: Lightning Energy distance: diff --git a/tests/components/as3935_spi/common.yaml b/tests/components/as3935_spi/common.yaml index 5898d5d365..d2942dc01d 100644 --- a/tests/components/as3935_spi/common.yaml +++ b/tests/components/as3935_spi/common.yaml @@ -1,13 +1,16 @@ as3935_spi: + id: as3935_spi_id cs_pin: ${cs_pin} irq_pin: ${irq_pin} binary_sensor: - platform: as3935 + as3935_id: as3935_spi_id name: Storm Alert sensor: - platform: as3935 + as3935_id: as3935_spi_id lightning_energy: name: Lightning Energy distance: diff --git a/tests/components/axs15231/common.yaml b/tests/components/axs15231/common.yaml index 3f07af80ea..d4fd3becbb 100644 --- a/tests/components/axs15231/common.yaml +++ b/tests/components/axs15231/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: 19 pages: @@ -13,6 +13,6 @@ touchscreen: - platform: axs15231 i2c_id: i2c_bus id: axs15231_touchscreen - display: ssd1306_display + display: ssd1306_i2c_display interrupt_pin: 20 reset_pin: 18 diff --git a/tests/components/bh1900nux/common.yaml b/tests/components/bh1900nux/common.yaml new file mode 100644 index 0000000000..3438418702 --- /dev/null +++ b/tests/components/bh1900nux/common.yaml @@ -0,0 +1,6 @@ +sensor: + - platform: bh1900nux + i2c_id: i2c_bus + name: Temperature Living Room + address: 0x48 + update_interval: 30s diff --git a/tests/components/bh1900nux/test.esp32-c3-idf.yaml b/tests/components/bh1900nux/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..9990d96d29 --- /dev/null +++ b/tests/components/bh1900nux/test.esp32-c3-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-c3-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/bh1900nux/test.esp32-idf.yaml b/tests/components/bh1900nux/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/bh1900nux/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/bh1900nux/test.esp8266-ard.yaml b/tests/components/bh1900nux/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/bh1900nux/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bh1900nux/test.rp2040-ard.yaml b/tests/components/bh1900nux/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/bh1900nux/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..edb18c1ada --- /dev/null +++ b/tests/components/bluetooth_proxy/test.esp32-p4-idf.yaml @@ -0,0 +1,19 @@ +<<: !include common.yaml + +esp32_ble_tracker: + max_connections: 9 + +bluetooth_proxy: + active: true + connection_slots: 9 + +esp32_hosted: + active_high: true + variant: ESP32C6 + reset_pin: GPIO54 + cmd_pin: GPIO19 + clk_pin: GPIO18 + d0_pin: GPIO14 + d1_pin: GPIO15 + d2_pin: GPIO16 + d3_pin: GPIO17 diff --git a/tests/components/bme280_i2c/common.yaml b/tests/components/bme280_i2c/common.yaml index e6d41d209c..a31a6f9a6c 100644 --- a/tests/components/bme280_i2c/common.yaml +++ b/tests/components/bme280_i2c/common.yaml @@ -3,12 +3,12 @@ sensor: i2c_id: i2c_bus address: 0x76 temperature: - id: bme280_temperature + id: bme280_i2c_temperature name: BME280 Temperature humidity: - id: bme280_humidity + id: bme280_i2c_humidity name: BME280 Humidity pressure: - id: bme280_pressure + id: bme280_i2c_pressure name: BME280 Pressure update_interval: 15s diff --git a/tests/components/bme280_spi/common.yaml b/tests/components/bme280_spi/common.yaml index 9a50b410fb..d97b475f0e 100644 --- a/tests/components/bme280_spi/common.yaml +++ b/tests/components/bme280_spi/common.yaml @@ -2,12 +2,12 @@ sensor: - platform: bme280_spi cs_pin: ${cs_pin} temperature: - id: bme280_temperature + id: bme280_spi_temperature name: BME280 Temperature humidity: - id: bme280_humidity + id: bme280_spi_humidity name: BME280 Humidity pressure: - id: bme280_pressure + id: bme280_spi_pressure name: BME280 Pressure update_interval: 15s diff --git a/tests/components/bmp280_i2c/common.yaml b/tests/components/bmp280_i2c/common.yaml index 785343de7d..77a9db7fc5 100644 --- a/tests/components/bmp280_i2c/common.yaml +++ b/tests/components/bmp280_i2c/common.yaml @@ -3,10 +3,10 @@ sensor: i2c_id: i2c_bus address: 0x77 temperature: - id: bmp280_temperature + id: bmp280_i2c_temperature name: Outside Temperature pressure: name: Outside Pressure - id: bmp280_pressure + id: bmp280_i2c_pressure iir_filter: 16x update_interval: 15s diff --git a/tests/components/bmp280_spi/common.yaml b/tests/components/bmp280_spi/common.yaml index fa88967ca4..1be54f6b74 100644 --- a/tests/components/bmp280_spi/common.yaml +++ b/tests/components/bmp280_spi/common.yaml @@ -2,10 +2,10 @@ sensor: - platform: bmp280_spi cs_pin: ${cs_pin} temperature: - id: bmp280_temperature + id: bmp280_spi_temperature name: Outside Temperature pressure: name: Outside Pressure - id: bmp280_pressure + id: bmp280_spi_pressure iir_filter: 16x update_interval: 15s diff --git a/tests/components/bmp3xx_i2c/common.yaml b/tests/components/bmp3xx_i2c/common.yaml index ebc4921b84..e651072f25 100644 --- a/tests/components/bmp3xx_i2c/common.yaml +++ b/tests/components/bmp3xx_i2c/common.yaml @@ -3,8 +3,10 @@ sensor: i2c_id: i2c_bus address: 0x77 temperature: + id: bmp3xx_i2c_temperature name: BMP Temperature oversampling: 16x pressure: + id: bmp3xx_i2c_pressure name: BMP Pressure iir_filter: 2X diff --git a/tests/components/bmp3xx_spi/common.yaml b/tests/components/bmp3xx_spi/common.yaml index d6acef1833..b59d46c967 100644 --- a/tests/components/bmp3xx_spi/common.yaml +++ b/tests/components/bmp3xx_spi/common.yaml @@ -2,8 +2,10 @@ sensor: - platform: bmp3xx_spi cs_pin: ${cs_pin} temperature: + id: bmp3xx_spi_temperature name: BMP Temperature oversampling: 16x pressure: + id: bmp3xx_spi_pressure name: BMP Pressure iir_filter: 2X diff --git a/tests/components/camera/test.esp32-idf.yaml b/tests/components/camera/test.esp32-idf.yaml index 3d93dd6418..ef8f74d4eb 100644 --- a/tests/components/camera/test.esp32-idf.yaml +++ b/tests/components/camera/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - camera: !include ../../test_build_components/common/camera/esp32-idf.yaml + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/camera_encoder/test.esp32-idf.yaml b/tests/components/camera_encoder/test.esp32-idf.yaml index 3d93dd6418..ef8f74d4eb 100644 --- a/tests/components/camera_encoder/test.esp32-idf.yaml +++ b/tests/components/camera_encoder/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - camera: !include ../../test_build_components/common/camera/esp32-idf.yaml + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/chsc6x/test.esp32-c3-idf.yaml b/tests/components/chsc6x/test.esp32-c3-idf.yaml index f32f147a44..f0de4107d7 100644 --- a/tests/components/chsc6x/test.esp32-c3-idf.yaml +++ b/tests/components/chsc6x/test.esp32-c3-idf.yaml @@ -7,8 +7,8 @@ display: id: ili9xxx_display model: GC9A01A invert_colors: True - cs_pin: 10 - dc_pin: 6 + cs_pin: 11 + dc_pin: 7 pages: - id: page1 lambda: |- diff --git a/tests/components/chsc6x/test.esp32-idf.yaml b/tests/components/chsc6x/test.esp32-idf.yaml index ea3686d8bd..fa7c72150e 100644 --- a/tests/components/chsc6x/test.esp32-idf.yaml +++ b/tests/components/chsc6x/test.esp32-idf.yaml @@ -4,6 +4,7 @@ packages: display: - platform: ili9xxx + spi_id: spi_bus id: ili9xxx_display model: GC9A01A invert_colors: True @@ -16,5 +17,6 @@ display: touchscreen: - platform: chsc6x + i2c_id: i2c_bus display: ili9xxx_display interrupt_pin: 20 diff --git a/tests/components/ektf2232/common.yaml b/tests/components/ektf2232/common.yaml index 8ab57be46f..1c4d768b08 100644 --- a/tests/components/ektf2232/common.yaml +++ b/tests/components/ektf2232/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -15,7 +15,7 @@ touchscreen: id: ektf2232_touchscreen interrupt_pin: ${interrupt_pin} reset_pin: ${touch_reset_pin} - display: ssd1306_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/ens160_i2c/common.yaml b/tests/components/ens160_i2c/common.yaml index 685c8d3fee..1da2bacec2 100644 --- a/tests/components/ens160_i2c/common.yaml +++ b/tests/components/ens160_i2c/common.yaml @@ -3,8 +3,11 @@ sensor: i2c_id: i2c_bus address: 0x53 eco2: + id: ens160_i2c_eco2 name: "ENS160 eCO2" tvoc: + id: ens160_i2c_tvoc name: "ENS160 Total Volatile Organic Compounds" aqi: + id: ens160_i2c_aqi name: "ENS160 Air Quality Index" diff --git a/tests/components/ens160_spi/common.yaml b/tests/components/ens160_spi/common.yaml index 53000a5e96..46a3f40b40 100644 --- a/tests/components/ens160_spi/common.yaml +++ b/tests/components/ens160_spi/common.yaml @@ -2,8 +2,11 @@ sensor: - platform: ens160_spi cs_pin: ${cs_pin} eco2: + id: ens160_spi_eco2 name: "ENS160 eCO2" tvoc: + id: ens160_spi_tvoc name: "ENS160 Total Volatile Organic Compounds" aqi: + id: ens160_spi_aqi name: "ENS160 Air Quality Index" diff --git a/tests/components/esp32/test.esp32-idf.yaml b/tests/components/esp32/test.esp32-idf.yaml index ccf0b7cbd5..6338fe98dd 100644 --- a/tests/components/esp32/test.esp32-idf.yaml +++ b/tests/components/esp32/test.esp32-idf.yaml @@ -5,6 +5,7 @@ esp32: advanced: enable_lwip_mdns_queries: true enable_lwip_bridge_interface: true + disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization wifi: ssid: MySSID diff --git a/tests/components/esp32/test.esp32-s3-idf.yaml b/tests/components/esp32/test.esp32-s3-idf.yaml index 1d5a5e52a4..4ae5e6b999 100644 --- a/tests/components/esp32/test.esp32-s3-idf.yaml +++ b/tests/components/esp32/test.esp32-s3-idf.yaml @@ -4,6 +4,7 @@ esp32: type: esp-idf advanced: execute_from_psram: true + disable_libc_locks_in_iram: true # Test default RAM optimization enabled psram: mode: octal diff --git a/tests/components/esp32_camera/test.esp32-idf.yaml b/tests/components/esp32_camera/test.esp32-idf.yaml index 3d93dd6418..ef8f74d4eb 100644 --- a/tests/components/esp32_camera/test.esp32-idf.yaml +++ b/tests/components/esp32_camera/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - camera: !include ../../test_build_components/common/camera/esp32-idf.yaml + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/esp32_camera_web_server/test.esp32-idf.yaml b/tests/components/esp32_camera_web_server/test.esp32-idf.yaml index 3d93dd6418..ef8f74d4eb 100644 --- a/tests/components/esp32_camera_web_server/test.esp32-idf.yaml +++ b/tests/components/esp32_camera_web_server/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - camera: !include ../../test_build_components/common/camera/esp32-idf.yaml + i2c_camera: !include ../../test_build_components/common/i2c_camera/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/font/common.yaml b/tests/components/font/common.yaml index 2d2e970536..6ba52e3d97 100644 --- a/tests/components/font/common.yaml +++ b/tests/components/font/common.yaml @@ -49,6 +49,7 @@ font: display: - platform: ssd1306_i2c + i2c_id: i2c_bus id: ssd1306_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} diff --git a/tests/components/ft63x6/test.esp8266-ard.yaml b/tests/components/ft63x6/test.esp8266-ard.yaml index d6b6903029..3ac5c645e3 100644 --- a/tests/components/ft63x6/test.esp8266-ard.yaml +++ b/tests/components/ft63x6/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ substitutions: - interrupt_pin: GPIO12 + interrupt_pin: GPIO0 reset_pin: GPIO16 packages: diff --git a/tests/components/graph/common.yaml b/tests/components/graph/common.yaml index 578de5a60c..11e2a16ca1 100644 --- a/tests/components/graph/common.yaml +++ b/tests/components/graph/common.yaml @@ -11,6 +11,7 @@ graph: display: - platform: ssd1306_i2c + i2c_id: i2c_bus id: ssd1306_display model: SSD1306_128X64 reset_pin: ${reset_pin} diff --git a/tests/components/graphical_display_menu/common.yaml b/tests/components/graphical_display_menu/common.yaml index 0b8f20d64b..6cee2af232 100644 --- a/tests/components/graphical_display_menu/common.yaml +++ b/tests/components/graphical_display_menu/common.yaml @@ -1,6 +1,6 @@ display: - platform: ssd1306_i2c - id: ssd1306_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -36,7 +36,7 @@ switch: graphical_display_menu: id: test_graphical_display_menu - display: ssd1306_display + display: ssd1306_i2c_display font: roboto active: false mode: rotary diff --git a/tests/components/gt911/common.yaml b/tests/components/gt911/common.yaml index 5f9748afb6..ff464cda24 100644 --- a/tests/components/gt911/common.yaml +++ b/tests/components/gt911/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${display_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: gt911 i2c_id: i2c_bus id: gt911_touchscreen - display: ssd1306_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/hx711/test.esp8266-ard.yaml b/tests/components/hx711/test.esp8266-ard.yaml index defef165e3..e7c017ed99 100644 --- a/tests/components/hx711/test.esp8266-ard.yaml +++ b/tests/components/hx711/test.esp8266-ard.yaml @@ -1,5 +1,5 @@ substitutions: - clk_pin: GPIO4 - dout_pin: GPIO5 + clk_pin: GPIO0 + dout_pin: GPIO2 <<: !include common.yaml diff --git a/tests/components/ina2xx_i2c/common.yaml b/tests/components/ina2xx_i2c/common.yaml index 7d586f136e..748ab94c98 100644 --- a/tests/components/ina2xx_i2c/common.yaml +++ b/tests/components/ina2xx_i2c/common.yaml @@ -7,9 +7,22 @@ sensor: max_current: 40 A adc_range: 1 temperature_coefficient: 50 - shunt_voltage: "INA2xx Shunt Voltage" - bus_voltage: "INA2xx Bus Voltage" - current: "INA2xx Current" - power: "INA2xx Power" - energy: "INA2xx Energy" - charge: "INA2xx Charge" + reset_on_boot: true + shunt_voltage: + id: ina2xx_i2c_shunt_voltage + name: "INA2xx Shunt Voltage" + bus_voltage: + id: ina2xx_i2c_bus_voltage + name: "INA2xx Bus Voltage" + current: + id: ina2xx_i2c_current + name: "INA2xx Current" + power: + id: ina2xx_i2c_power + name: "INA2xx Power" + energy: + id: ina2xx_i2c_energy + name: "INA2xx Energy" + charge: + id: ina2xx_i2c_charge + name: "INA2xx Charge" diff --git a/tests/components/ina2xx_spi/common.yaml b/tests/components/ina2xx_spi/common.yaml index d9b2300e26..8de77eba26 100644 --- a/tests/components/ina2xx_spi/common.yaml +++ b/tests/components/ina2xx_spi/common.yaml @@ -6,9 +6,21 @@ sensor: max_current: 40 A adc_range: 1 temperature_coefficient: 50 - shunt_voltage: "INA2xx Shunt Voltage" - bus_voltage: "INA2xx Bus Voltage" - current: "INA2xx Current" - power: "INA2xx Power" - energy: "INA2xx Energy" - charge: "INA2xx Charge" + shunt_voltage: + id: ina2xx_spi_shunt_voltage + name: "INA2xx Shunt Voltage" + bus_voltage: + id: ina2xx_spi_bus_voltage + name: "INA2xx Bus Voltage" + current: + id: ina2xx_spi_current + name: "INA2xx Current" + power: + id: ina2xx_spi_power + name: "INA2xx Power" + energy: + id: ina2xx_spi_energy + name: "INA2xx Energy" + charge: + id: ina2xx_spi_charge + name: "INA2xx Charge" diff --git a/tests/components/lilygo_t5_47/common.yaml b/tests/components/lilygo_t5_47/common.yaml index 7079139ec7..18f1ba10ae 100644 --- a/tests/components/lilygo_t5_47/common.yaml +++ b/tests/components/lilygo_t5_47/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${reset_pin} pages: @@ -14,7 +14,7 @@ touchscreen: i2c_id: i2c_bus id: lilygo_touchscreen interrupt_pin: ${interrupt_pin} - display: ssd1306_display + display: ssd1306_i2c_display on_touch: - logger.log: format: Touch at (%d, %d) diff --git a/tests/components/mcp4725/common.yaml b/tests/components/mcp4725/common.yaml index 871c2805a3..9352ebfd19 100644 --- a/tests/components/mcp4725/common.yaml +++ b/tests/components/mcp4725/common.yaml @@ -1,3 +1,4 @@ output: - platform: mcp4725 id: mcp4725_dac_output + i2c_id: i2c_bus diff --git a/tests/components/mcp47a1/common.yaml b/tests/components/mcp47a1/common.yaml index 3448fcfb31..5a5786ff0f 100644 --- a/tests/components/mcp47a1/common.yaml +++ b/tests/components/mcp47a1/common.yaml @@ -1,3 +1,4 @@ output: - platform: mcp47a1 id: output_mcp47a1 + i2c_id: i2c_bus diff --git a/tests/components/mipi_spi/common.yaml b/tests/components/mipi_spi/common.yaml index 03f807f53c..692a9f436e 100644 --- a/tests/components/mipi_spi/common.yaml +++ b/tests/components/mipi_spi/common.yaml @@ -10,7 +10,7 @@ display: invert_colors: true show_test_card: true spi_mode: mode0 - draw_rounding: 8 + draw_rounding: 4 use_axis_flips: true init_sequence: - [0xd0, 1, 2, 3] diff --git a/tests/components/mipi_spi/test.rp2040-ard.yaml b/tests/components/mipi_spi/test.rp2040-ard.yaml index 380cebcde3..6336652999 100644 --- a/tests/components/mipi_spi/test.rp2040-ard.yaml +++ b/tests/components/mipi_spi/test.rp2040-ard.yaml @@ -1,7 +1,7 @@ substitutions: dc_pin: GPIO14 cs_pin: GPIO13 - enable_pin: GPIO16 + enable_pin: GPIO17 reset_pin: GPIO20 packages: diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..3fe80209b6 --- /dev/null +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -0,0 +1,7 @@ +nrf52: + dfu: + reset_pin: + number: 14 + inverted: true + mode: + output: true diff --git a/tests/components/pn532_i2c/common.yaml b/tests/components/pn532_i2c/common.yaml index f328cd40ee..947e1151aa 100644 --- a/tests/components/pn532_i2c/common.yaml +++ b/tests/components/pn532_i2c/common.yaml @@ -1,9 +1,9 @@ pn532_i2c: i2c_id: i2c_bus - id: pn532_nfcc + id: pn532_nfcc_i2c binary_sensor: - platform: pn532 - pn532_id: pn532_nfcc + pn532_id: pn532_nfcc_i2c name: PN532 NFC Tag uid: 74-10-37-94 diff --git a/tests/components/pn532_spi/common.yaml b/tests/components/pn532_spi/common.yaml index e749a9896a..f9149af35f 100644 --- a/tests/components/pn532_spi/common.yaml +++ b/tests/components/pn532_spi/common.yaml @@ -1,9 +1,9 @@ pn532_spi: - id: pn532_nfcc + id: pn532_nfcc_spi cs_pin: ${cs_pin} binary_sensor: - platform: pn532 - pn532_id: pn532_nfcc + pn532_id: pn532_nfcc_spi name: PN532 NFC Tag uid: 74-10-37-94 diff --git a/tests/components/pn7160_i2c/common.yaml b/tests/components/pn7160_i2c/common.yaml index 9807bff0f0..fa9a876d1c 100644 --- a/tests/components/pn7160_i2c/common.yaml +++ b/tests/components/pn7160_i2c/common.yaml @@ -1,23 +1,23 @@ esphome: on_boot: then: - - tag.set_clean_mode: nfcc_pn7160 - - tag.set_format_mode: nfcc_pn7160 - - tag.set_read_mode: nfcc_pn7160 + - tag.set_clean_mode: nfcc_pn7160_i2c + - tag.set_format_mode: nfcc_pn7160_i2c + - tag.set_read_mode: nfcc_pn7160_i2c - tag.set_write_message: message: https://www.home-assistant.io/tag/pulse include_android_app_record: false - - tag.set_write_mode: nfcc_pn7160 + - tag.set_write_mode: nfcc_pn7160_i2c - tag.set_emulation_message: message: https://www.home-assistant.io/tag/pulse include_android_app_record: false - - tag.emulation_off: nfcc_pn7160 - - tag.emulation_on: nfcc_pn7160 - - tag.polling_off: nfcc_pn7160 - - tag.polling_on: nfcc_pn7160 + - tag.emulation_off: nfcc_pn7160_i2c + - tag.emulation_on: nfcc_pn7160_i2c + - tag.polling_off: nfcc_pn7160_i2c + - tag.polling_on: nfcc_pn7160_i2c pn7150_i2c: - id: nfcc_pn7160 + id: nfcc_pn7160_i2c i2c_id: i2c_bus irq_pin: ${irq_pin} ven_pin: ${ven_pin} diff --git a/tests/components/pn7160_spi/common.yaml b/tests/components/pn7160_spi/common.yaml index d467eb093f..53b37b38f4 100644 --- a/tests/components/pn7160_spi/common.yaml +++ b/tests/components/pn7160_spi/common.yaml @@ -1,23 +1,23 @@ esphome: on_boot: then: - - tag.set_clean_mode: nfcc_pn7160 - - tag.set_format_mode: nfcc_pn7160 - - tag.set_read_mode: nfcc_pn7160 + - tag.set_clean_mode: nfcc_pn7160_spi + - tag.set_format_mode: nfcc_pn7160_spi + - tag.set_read_mode: nfcc_pn7160_spi - tag.set_write_message: message: https://www.home-assistant.io/tag/pulse include_android_app_record: false - - tag.set_write_mode: nfcc_pn7160 + - tag.set_write_mode: nfcc_pn7160_spi - tag.set_emulation_message: message: https://www.home-assistant.io/tag/pulse include_android_app_record: false - - tag.emulation_off: nfcc_pn7160 - - tag.emulation_on: nfcc_pn7160 - - tag.polling_off: nfcc_pn7160 - - tag.polling_on: nfcc_pn7160 + - tag.emulation_off: nfcc_pn7160_spi + - tag.emulation_on: nfcc_pn7160_spi + - tag.polling_off: nfcc_pn7160_spi + - tag.polling_on: nfcc_pn7160_spi pn7160_spi: - id: nfcc_pn7160 + id: nfcc_pn7160_spi cs_pin: ${cs_pin} irq_pin: ${irq_pin} ven_pin: ${ven_pin} diff --git a/tests/components/rc522_i2c/common.yaml b/tests/components/rc522_i2c/common.yaml index 65b92a3e78..0e624351d4 100644 --- a/tests/components/rc522_i2c/common.yaml +++ b/tests/components/rc522_i2c/common.yaml @@ -1,5 +1,5 @@ rc522_i2c: - - id: rc522_nfcc + - id: rc522_nfcc_i2c i2c_id: i2c_bus update_interval: 1s on_tag: @@ -8,6 +8,6 @@ rc522_i2c: binary_sensor: - platform: rc522 - rc522_id: rc522_nfcc + rc522_id: rc522_nfcc_i2c name: RC522 NFC Tag uid: 74-10-37-94 diff --git a/tests/components/rc522_spi/common.yaml b/tests/components/rc522_spi/common.yaml index 4ce1d6584b..c4830850de 100644 --- a/tests/components/rc522_spi/common.yaml +++ b/tests/components/rc522_spi/common.yaml @@ -1,9 +1,9 @@ rc522_spi: - id: rc522_nfcc + id: rc522_nfcc_spi cs_pin: ${cs_pin} binary_sensor: - platform: rc522 - rc522_id: rc522_nfcc - name: PN532 NFC Tag + rc522_id: rc522_nfcc_spi + name: RC522 NFC Tag uid: 74-10-37-94 diff --git a/tests/components/selec_meter/test.esp8266-ard.yaml b/tests/components/selec_meter/test.esp8266-ard.yaml index 48a7307795..6daa08c22b 100644 --- a/tests/components/selec_meter/test.esp8266-ard.yaml +++ b/tests/components/selec_meter/test.esp8266-ard.yaml @@ -1,7 +1,7 @@ substitutions: tx_pin: GPIO0 rx_pin: GPIO2 - flow_control_pin: GPIO4 + flow_control_pin: GPIO15 packages: modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml diff --git a/tests/components/sn74hc595/test.esp8266-ard.yaml b/tests/components/sn74hc595/test.esp8266-ard.yaml index cc011e01d4..e0de8bb0a3 100644 --- a/tests/components/sn74hc595/test.esp8266-ard.yaml +++ b/tests/components/sn74hc595/test.esp8266-ard.yaml @@ -2,8 +2,8 @@ packages: spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml substitutions: - clock_pin: GPIO5 - data_pin: GPIO4 + clock_pin: GPIO15 + data_pin: GPIO16 latch_pin1: GPIO2 oe_pin1: GPIO0 latch_pin2: GPIO3 diff --git a/tests/components/ssd1306_i2c/common.yaml b/tests/components/ssd1306_i2c/common.yaml index e876fcb36a..09eb569a8e 100644 --- a/tests/components/ssd1306_i2c/common.yaml +++ b/tests/components/ssd1306_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1306_128X64 reset_pin: ${reset_pin} address: 0x3C - id: display1 + id: ssd1306_i2c_display contrast: 60% pages: - id: ssd1306_i2c_page1 diff --git a/tests/components/ssd1306_spi/common.yaml b/tests/components/ssd1306_spi/common.yaml index 2a2adb4146..0297abc192 100644 --- a/tests/components/ssd1306_spi/common.yaml +++ b/tests/components/ssd1306_spi/common.yaml @@ -1,5 +1,6 @@ display: - platform: ssd1306_spi + id: ssd1306_spi_display model: SSD1306 128x64 cs_pin: ${cs_pin} dc_pin: ${dc_pin} diff --git a/tests/components/ssd1327_i2c/common.yaml b/tests/components/ssd1327_i2c/common.yaml index c90e9678dd..c5f2123d9a 100644 --- a/tests/components/ssd1327_i2c/common.yaml +++ b/tests/components/ssd1327_i2c/common.yaml @@ -4,7 +4,7 @@ display: model: SSD1327_128x128 reset_pin: ${reset_pin} address: 0x3C - id: display1 + id: ssd1327_i2c_display pages: - id: ssd1327_i2c_page1 lambda: |- diff --git a/tests/components/ssd1327_spi/common.yaml b/tests/components/ssd1327_spi/common.yaml index 1aa4fb5a1c..b46e61e080 100644 --- a/tests/components/ssd1327_spi/common.yaml +++ b/tests/components/ssd1327_spi/common.yaml @@ -1,5 +1,6 @@ display: - platform: ssd1327_spi + id: ssd1327_spi_display model: SSD1327 128x128 cs_pin: ${cs_pin} dc_pin: ${dc_pin} diff --git a/tests/components/st7567_i2c/common.yaml b/tests/components/st7567_i2c/common.yaml index 9a4cd79faa..c81d6825e3 100644 --- a/tests/components/st7567_i2c/common.yaml +++ b/tests/components/st7567_i2c/common.yaml @@ -3,7 +3,7 @@ display: i2c_id: i2c_bus reset_pin: ${reset_pin} address: 0x3C - id: display1 + id: st7567_i2c_display pages: - id: st7567_i2c_page1 lambda: |- diff --git a/tests/components/st7567_spi/common.yaml b/tests/components/st7567_spi/common.yaml index b5a4074e13..25a8932ee1 100644 --- a/tests/components/st7567_spi/common.yaml +++ b/tests/components/st7567_spi/common.yaml @@ -1,5 +1,6 @@ display: - platform: st7567_spi + id: st7567_spi_display cs_pin: ${cs_pin} dc_pin: ${dc_pin} reset_pin: ${reset_pin} diff --git a/tests/components/syslog/common.yaml b/tests/components/syslog/common.yaml index cd6e63c9ec..daa913f009 100644 --- a/tests/components/syslog/common.yaml +++ b/tests/components/syslog/common.yaml @@ -6,7 +6,8 @@ udp: addresses: ["239.0.60.53"] time: - platform: host + - platform: host + id: host_time syslog: port: 514 diff --git a/tests/components/toshiba/common_ras2819t.yaml b/tests/components/toshiba/common_ras2819t.yaml new file mode 100644 index 0000000000..32081fca98 --- /dev/null +++ b/tests/components/toshiba/common_ras2819t.yaml @@ -0,0 +1,13 @@ +remote_transmitter: + pin: ${tx_pin} + carrier_duty_percent: 50% + +remote_receiver: + id: rcvr + pin: ${rx_pin} + +climate: + - platform: toshiba + name: "RAS-2819T Climate" + model: RAS-2819T + receiver_id: rcvr diff --git a/tests/components/toshiba/test_ras2819t.esp32-ard.yaml b/tests/components/toshiba/test_ras2819t.esp32-ard.yaml new file mode 100644 index 0000000000..00805baa01 --- /dev/null +++ b/tests/components/toshiba/test_ras2819t.esp32-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO5 + rx_pin: GPIO4 + +<<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml b/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml new file mode 100644 index 0000000000..00805baa01 --- /dev/null +++ b/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO5 + rx_pin: GPIO4 + +<<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp32-c3-idf.yaml b/tests/components/toshiba/test_ras2819t.esp32-c3-idf.yaml new file mode 100644 index 0000000000..00805baa01 --- /dev/null +++ b/tests/components/toshiba/test_ras2819t.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO5 + rx_pin: GPIO4 + +<<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp32-idf.yaml b/tests/components/toshiba/test_ras2819t.esp32-idf.yaml new file mode 100644 index 0000000000..00805baa01 --- /dev/null +++ b/tests/components/toshiba/test_ras2819t.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO5 + rx_pin: GPIO4 + +<<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml b/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml new file mode 100644 index 0000000000..00805baa01 --- /dev/null +++ b/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO5 + rx_pin: GPIO4 + +<<: !include common_ras2819t.yaml diff --git a/tests/components/tt21100/common.yaml b/tests/components/tt21100/common.yaml index bd1830ea8b..56089aed1e 100644 --- a/tests/components/tt21100/common.yaml +++ b/tests/components/tt21100/common.yaml @@ -1,7 +1,7 @@ display: - platform: ssd1306_i2c i2c_id: i2c_bus - id: ssd1306_display + id: ssd1306_i2c_display model: SSD1306_128X64 reset_pin: ${disp_reset_pin} pages: @@ -13,7 +13,7 @@ touchscreen: - platform: tt21100 i2c_id: i2c_bus id: tt21100_touchscreen - display: ssd1306_display + display: ssd1306_i2c_display interrupt_pin: ${interrupt_pin} reset_pin: ${reset_pin} diff --git a/tests/components/wk2132_i2c/test.esp32-idf.yaml b/tests/components/wk2132_i2c/test.esp32-idf.yaml index b47e39c389..6b748a8f20 100644 --- a/tests/components/wk2132_i2c/test.esp32-idf.yaml +++ b/tests/components/wk2132_i2c/test.esp32-idf.yaml @@ -1,4 +1,5 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml index e9d826aa7c..d7b149a6fd 100644 --- a/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml +++ b/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml @@ -4,5 +4,6 @@ substitutions: packages: i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2132_spi/common.yaml b/tests/components/wk2132_spi/common.yaml index a762c10c92..18294974b9 100644 --- a/tests/components/wk2132_spi/common.yaml +++ b/tests/components/wk2132_spi/common.yaml @@ -1,20 +1,20 @@ wk2132_spi: - - id: wk2132_spi_id + - id: wk2132_spi_bridge cs_pin: ${cs_pin} crystal: 11059200 data_rate: 1MHz uart: - - id: wk2132_spi_id0 + - id: wk2132_spi_uart0 channel: 0 baud_rate: 115200 stop_bits: 1 parity: none - - id: wk2132_spi_id1 + - id: wk2132_spi_uart1 channel: 1 baud_rate: 9600 # Ensures a sensor doesn't break validation sensor: - platform: a02yyuw - uart_id: wk2132_spi_id1 + uart_id: wk2132_spi_uart1 id: distance_sensor diff --git a/tests/components/wk2132_spi/test.esp32-idf.yaml b/tests/components/wk2132_spi/test.esp32-idf.yaml index a3352cf880..9202a691ba 100644 --- a/tests/components/wk2132_spi/test.esp32-idf.yaml +++ b/tests/components/wk2132_spi/test.esp32-idf.yaml @@ -3,5 +3,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2132_spi/test.esp32-s3-idf.yaml b/tests/components/wk2132_spi/test.esp32-s3-idf.yaml index 6a60c90fb2..9c7d36996e 100644 --- a/tests/components/wk2132_spi/test.esp32-s3-idf.yaml +++ b/tests/components/wk2132_spi/test.esp32-s3-idf.yaml @@ -6,5 +6,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2168_i2c/test.esp32-idf.yaml b/tests/components/wk2168_i2c/test.esp32-idf.yaml index b47e39c389..9d9f0d4931 100644 --- a/tests/components/wk2168_i2c/test.esp32-idf.yaml +++ b/tests/components/wk2168_i2c/test.esp32-idf.yaml @@ -1,4 +1,5 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml index e9d826aa7c..115812be97 100644 --- a/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml +++ b/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml @@ -4,5 +4,6 @@ substitutions: packages: i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2168_spi/test.esp32-idf.yaml b/tests/components/wk2168_spi/test.esp32-idf.yaml index a3352cf880..2b56a46b70 100644 --- a/tests/components/wk2168_spi/test.esp32-idf.yaml +++ b/tests/components/wk2168_spi/test.esp32-idf.yaml @@ -3,5 +3,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2168_spi/test.esp32-s3-idf.yaml b/tests/components/wk2168_spi/test.esp32-s3-idf.yaml index 6a60c90fb2..374fe64d16 100644 --- a/tests/components/wk2168_spi/test.esp32-s3-idf.yaml +++ b/tests/components/wk2168_spi/test.esp32-s3-idf.yaml @@ -6,5 +6,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2204_i2c/test.esp32-idf.yaml b/tests/components/wk2204_i2c/test.esp32-idf.yaml index b47e39c389..9d9f0d4931 100644 --- a/tests/components/wk2204_i2c/test.esp32-idf.yaml +++ b/tests/components/wk2204_i2c/test.esp32-idf.yaml @@ -1,4 +1,5 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml index e9d826aa7c..115812be97 100644 --- a/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml +++ b/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml @@ -4,5 +4,6 @@ substitutions: packages: i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2204_spi/common.yaml b/tests/components/wk2204_spi/common.yaml index 939c54cc40..0b62a7a009 100644 --- a/tests/components/wk2204_spi/common.yaml +++ b/tests/components/wk2204_spi/common.yaml @@ -1,28 +1,28 @@ wk2204_spi: - - id: wk2204_spi_id + - id: wk2204_spi_bridge cs_pin: ${cs_pin} crystal: 11059200 data_rate: 1MHz uart: - - id: wk2204_spi_id0 + - id: wk2204_spi_uart0 channel: 0 baud_rate: 115200 stop_bits: 1 parity: none - - id: wk2204_spi_id1 + - id: wk2204_spi_uart1 channel: 1 baud_rate: 921600 - - id: wk2204_spi_id2 + - id: wk2204_spi_uart2 channel: 2 baud_rate: 115200 stop_bits: 1 parity: none - - id: wk2204_spi_id3 + - id: wk2204_spi_uart3 channel: 3 baud_rate: 9600 # Ensures a sensor doesn't break validation sensor: - platform: a02yyuw - uart_id: wk2204_spi_id3 + uart_id: wk2204_spi_uart3 id: distance_sensor diff --git a/tests/components/wk2204_spi/test.esp32-idf.yaml b/tests/components/wk2204_spi/test.esp32-idf.yaml index a3352cf880..2b56a46b70 100644 --- a/tests/components/wk2204_spi/test.esp32-idf.yaml +++ b/tests/components/wk2204_spi/test.esp32-idf.yaml @@ -3,5 +3,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2204_spi/test.esp32-s3-idf.yaml b/tests/components/wk2204_spi/test.esp32-s3-idf.yaml index 6a60c90fb2..374fe64d16 100644 --- a/tests/components/wk2204_spi/test.esp32-s3-idf.yaml +++ b/tests/components/wk2204_spi/test.esp32-s3-idf.yaml @@ -6,5 +6,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2212_i2c/test.esp32-idf.yaml b/tests/components/wk2212_i2c/test.esp32-idf.yaml index b47e39c389..9d9f0d4931 100644 --- a/tests/components/wk2212_i2c/test.esp32-idf.yaml +++ b/tests/components/wk2212_i2c/test.esp32-idf.yaml @@ -1,4 +1,5 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml index e9d826aa7c..115812be97 100644 --- a/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml +++ b/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml @@ -4,5 +4,6 @@ substitutions: packages: i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2212_spi/test.esp32-idf.yaml b/tests/components/wk2212_spi/test.esp32-idf.yaml index a3352cf880..2b56a46b70 100644 --- a/tests/components/wk2212_spi/test.esp32-idf.yaml +++ b/tests/components/wk2212_spi/test.esp32-idf.yaml @@ -3,5 +3,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/wk2212_spi/test.esp32-s3-idf.yaml b/tests/components/wk2212_spi/test.esp32-s3-idf.yaml index 6a60c90fb2..374fe64d16 100644 --- a/tests/components/wk2212_spi/test.esp32-s3-idf.yaml +++ b/tests/components/wk2212_spi/test.esp32-s3-idf.yaml @@ -6,5 +6,6 @@ substitutions: packages: spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml <<: !include common.yaml diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py index c9097fe5e2..91a8ec70c3 100644 --- a/tests/dashboard/test_settings.py +++ b/tests/dashboard/test_settings.py @@ -2,11 +2,13 @@ from __future__ import annotations +from argparse import Namespace from pathlib import Path import tempfile import pytest +from esphome.core import CORE from esphome.dashboard.settings import DashboardSettings @@ -159,3 +161,63 @@ def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> No result = dashboard_settings.rel_path("123", "456.789") expected = dashboard_settings.config_dir / "123" / "456.789" assert result == expected + + +def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: + """Test that CORE.config_path.parent resolves to config_dir after parse_args. + + This is a regression test for issue #11280 where binary download failed + when using packages with secrets after the Path migration in 2025.10.0. + + The issue was that after switching from os.path to Path: + - Before: os.path.dirname("/config/.") → "/config" + - After: Path("/config/.").parent → Path("/") (normalized first!) + + The fix uses a sentinel file so .parent returns the correct directory: + - Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config") + """ + # Create test directory structure with secrets and packages + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create secrets.yaml with obviously fake test values + secrets_file = config_dir / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TEST-DUMMY-SSID\n" + "wifi_password: not-a-real-password-just-for-testing\n" + ) + + # Create package file that uses secrets + package_file = config_dir / "common.yaml" + package_file.write_text( + "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" + ) + + # Create main device config that includes the package + device_config = config_dir / "test-device.yaml" + device_config.write_text( + "esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n" + ) + + # Set up dashboard settings with our test config directory + settings = DashboardSettings() + args = Namespace( + configuration=str(config_dir), + password=None, + username=None, + ha_addon=False, + verbose=False, + ) + settings.parse_args(args) + + # Verify that CORE.config_path.parent correctly points to the config directory + # This is critical for secret resolution in yaml_util.py which does: + # main_config_dir = CORE.config_path.parent + # main_secret_yml = main_config_dir / "secrets.yaml" + assert CORE.config_path.parent == config_dir.resolve() + assert (CORE.config_path.parent / "secrets.yaml").exists() + assert (CORE.config_path.parent / "common.yaml").exists() + + # Verify that CORE.config_path itself uses the sentinel file + assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" + assert not CORE.config_path.exists() # Sentinel file doesn't actually exist diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 5bbe7e78fc..6c424e56d4 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +from argparse import Namespace import asyncio from collections.abc import Generator from contextlib import asynccontextmanager @@ -17,6 +18,8 @@ from tornado.ioloop import IOLoop from tornado.testing import bind_unused_port from tornado.websocket import WebSocketClientConnection, websocket_connect +from esphome import yaml_util +from esphome.core import CORE from esphome.dashboard import web_server from esphome.dashboard.const import DashboardEvent from esphome.dashboard.core import DASHBOARD @@ -1302,3 +1305,71 @@ async def test_dashboard_subscriber_refresh_event( # Give it a moment to clean up await asyncio.sleep(0.01) + + +@pytest.mark.asyncio +async def test_dashboard_yaml_loading_with_packages_and_secrets( + tmp_path: Path, +) -> None: + """Test dashboard YAML loading with packages referencing secrets. + + This is a regression test for issue #11280 where binary download failed + when using packages with secrets after the Path migration in 2025.10.0. + + This test verifies that CORE.config_path initialization in the dashboard + allows yaml_util.load_yaml() to correctly resolve secrets from packages. + """ + # Create test directory structure with secrets and packages + config_dir = tmp_path / "config" + config_dir.mkdir() + + # Create secrets.yaml with obviously fake test values + secrets_file = config_dir / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TEST-DUMMY-SSID\n" + "wifi_password: not-a-real-password-just-for-testing\n" + ) + + # Create package file that uses secrets + package_file = config_dir / "common.yaml" + package_file.write_text( + "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" + ) + + # Create main device config that includes the package + device_config = config_dir / "test-download-secrets.yaml" + device_config.write_text( + "esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n" + "packages:\n common: !include common.yaml\n" + ) + + # Initialize DASHBOARD settings with our test config directory + # This is what sets CORE.config_path - the critical code path for the bug + args = Namespace( + configuration=str(config_dir), + password=None, + username=None, + ha_addon=False, + verbose=False, + ) + DASHBOARD.settings.parse_args(args) + + # With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml" + # so CORE.config_path.parent would be config_dir + # Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir + # so CORE.config_path.parent would be tmp_path (the parent of config_dir) + + # The fix ensures CORE.config_path.parent points to config_dir + assert CORE.config_path.parent == config_dir.resolve(), ( + f"CORE.config_path.parent should point to config_dir. " + f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. " + f"CORE.config_path is {CORE.config_path}" + ) + + # Now load the YAML with packages that reference secrets + # This is where the bug would manifest - yaml_util.load_yaml would fail + # to find secrets.yaml because CORE.config_path.parent pointed to the wrong place + config = yaml_util.load_yaml(device_config) + # If we get here, secret resolution worked! + assert "esphome" in config + assert config["esphome"]["name"] == "test-download-secrets" diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 65eef4f785..f8557ef6b6 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -87,9 +87,11 @@ def test_main_all_tests_should_run( mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True - # Mock list-components.py output + # Mock list-components.py output (now returns JSON with --changed-with-deps) mock_result = Mock() - mock_result.stdout = "wifi\napi\nsensor\n" + mock_result.stdout = json.dumps( + {"directly_changed": ["wifi", "api"], "all_changed": ["wifi", "api", "sensor"]} + ) mock_subprocess_run.return_value = mock_result # Run main function with mocked argv @@ -138,7 +140,7 @@ def test_main_no_tests_should_run( # Mock empty list-components.py output mock_result = Mock() - mock_result.stdout = "" + mock_result.stdout = json.dumps({"directly_changed": [], "all_changed": []}) mock_subprocess_run.return_value = mock_result # Run main function with mocked argv @@ -207,7 +209,9 @@ def test_main_with_branch_argument( # Mock list-components.py output mock_result = Mock() - mock_result.stdout = "mqtt\n" + mock_result.stdout = json.dumps( + {"directly_changed": ["mqtt"], "all_changed": ["mqtt"]} + ) mock_subprocess_run.return_value = mock_result with patch("sys.argv", ["script.py", "-b", "main"]): @@ -222,7 +226,7 @@ def test_main_with_branch_argument( # Check that list-components.py was called with branch mock_subprocess_run.assert_called_once() call_args = mock_subprocess_run.call_args[0][0] - assert "--changed" in call_args + assert "--changed-with-deps" in call_args assert "-b" in call_args assert "main" in call_args @@ -449,7 +453,12 @@ def test_main_filters_components_without_tests( # Mock list-components.py output with 3 components # wifi: has tests, sensor: has tests, airthings_ble: no tests mock_result = Mock() - mock_result.stdout = "wifi\nsensor\nairthings_ble\n" + mock_result.stdout = json.dumps( + { + "directly_changed": ["wifi", "sensor"], + "all_changed": ["wifi", "sensor", "airthings_ble"], + } + ) mock_subprocess_run.return_value = mock_result # Create test directory structure @@ -474,6 +483,8 @@ def test_main_filters_components_without_tests( patch.object(determine_jobs, "root_path", str(tmp_path)), patch("sys.argv", ["determine-jobs.py"]), ): + # Clear the cache since we're mocking root_path + determine_jobs._component_has_tests.cache_clear() determine_jobs.main() # Check output diff --git a/tests/test_build_components/build_components_base.esp32-c3-idf.yaml b/tests/test_build_components/build_components_base.esp32-c3-idf.yaml index 18584497f4..73a85467d3 100644 --- a/tests/test_build_components/build_components_base.esp32-c3-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-c3-idf.yaml @@ -6,6 +6,9 @@ esp32: board: lolin_c3_mini framework: type: esp-idf + # Use custom partition table with larger app partition (3MB) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv logger: level: VERY_VERBOSE diff --git a/tests/test_build_components/build_components_base.esp32-idf.yaml b/tests/test_build_components/build_components_base.esp32-idf.yaml index a62a995e68..dcb951c1ed 100644 --- a/tests/test_build_components/build_components_base.esp32-idf.yaml +++ b/tests/test_build_components/build_components_base.esp32-idf.yaml @@ -3,9 +3,13 @@ esphome: friendly_name: $component_name esp32: - board: nodemcu-32s + # Use board with 8MB flash for testing large component groups + board: esp32-pico-devkitm-2 framework: type: esp-idf + # Use custom partition table with larger app partitions (3MB each) + # Default IDF partitions only allow 1.75MB which is too small for grouped tests + partitions: ../partitions_testing.csv logger: level: VERY_VERBOSE diff --git a/tests/test_build_components/build_components_base.nrf52-xiao-ble.yaml b/tests/test_build_components/build_components_base.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..2f3f91d957 --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-xiao-ble.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: xiao_ble + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/camera/esp32-idf.yaml b/tests/test_build_components/common/i2c_camera/esp32-idf.yaml similarity index 83% rename from tests/test_build_components/common/camera/esp32-idf.yaml rename to tests/test_build_components/common/i2c_camera/esp32-idf.yaml index 64f75c699a..a6e7c264cb 100644 --- a/tests/test_build_components/common/camera/esp32-idf.yaml +++ b/tests/test_build_components/common/i2c_camera/esp32-idf.yaml @@ -1,3 +1,10 @@ +# I2C bus for camera sensor +i2c: + - id: i2c_camera_bus + sda: 25 + scl: 23 + frequency: 400kHz + esp32_camera: name: ESP32 Camera data_pins: @@ -15,9 +22,7 @@ esp32_camera: external_clock: pin: 27 frequency: 20MHz - i2c_pins: - sda: 25 - scl: 23 + i2c_id: i2c_camera_bus reset_pin: 15 power_down_pin: 1 resolution: 640x480 diff --git a/tests/test_build_components/common/uart_bridge_2/esp32-idf.yaml b/tests/test_build_components/common/uart_bridge_2/esp32-idf.yaml new file mode 100644 index 0000000000..ff8a2f8d13 --- /dev/null +++ b/tests/test_build_components/common/uart_bridge_2/esp32-idf.yaml @@ -0,0 +1,11 @@ +# Common configuration for 2-channel UART bridge/expander chips +# Used by components like wk2132 that create 2 UART channels +# Defines standardized UART IDs: uart_id_0, uart_id_1 + +substitutions: + # These will be overridden by component-specific values + uart_bridge_address: "0x70" + +# Note: The actual UART instances are created by the bridge component +# This package just ensures all bridge components use the same ID naming convention +# so they can be grouped together without conflicts diff --git a/tests/test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml b/tests/test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml new file mode 100644 index 0000000000..ff8a2f8d13 --- /dev/null +++ b/tests/test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +# Common configuration for 2-channel UART bridge/expander chips +# Used by components like wk2132 that create 2 UART channels +# Defines standardized UART IDs: uart_id_0, uart_id_1 + +substitutions: + # These will be overridden by component-specific values + uart_bridge_address: "0x70" + +# Note: The actual UART instances are created by the bridge component +# This package just ensures all bridge components use the same ID naming convention +# so they can be grouped together without conflicts diff --git a/tests/test_build_components/common/uart_bridge_4/esp32-idf.yaml b/tests/test_build_components/common/uart_bridge_4/esp32-idf.yaml new file mode 100644 index 0000000000..bb88037947 --- /dev/null +++ b/tests/test_build_components/common/uart_bridge_4/esp32-idf.yaml @@ -0,0 +1,11 @@ +# Common configuration for 4-channel UART bridge/expander chips +# Used by components like wk2168, wk2204, wk2212 that create 4 UART channels +# Defines standardized UART IDs: uart_id_0, uart_id_1, uart_id_2, uart_id_3 + +substitutions: + # These will be overridden by component-specific values + uart_bridge_address: "0x70" + +# Note: The actual UART instances are created by the bridge component +# This package just ensures all bridge components use the same ID naming convention +# so they can be grouped together without conflicts diff --git a/tests/test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml b/tests/test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml new file mode 100644 index 0000000000..bb88037947 --- /dev/null +++ b/tests/test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +# Common configuration for 4-channel UART bridge/expander chips +# Used by components like wk2168, wk2204, wk2212 that create 4 UART channels +# Defines standardized UART IDs: uart_id_0, uart_id_1, uart_id_2, uart_id_3 + +substitutions: + # These will be overridden by component-specific values + uart_bridge_address: "0x70" + +# Note: The actual UART instances are created by the bridge component +# This package just ensures all bridge components use the same ID naming convention +# so they can be grouped together without conflicts diff --git a/tests/test_build_components/partitions_testing.csv b/tests/test_build_components/partitions_testing.csv new file mode 100644 index 0000000000..0ca8c24e05 --- /dev/null +++ b/tests/test_build_components/partitions_testing.csv @@ -0,0 +1,10 @@ +# ESP-IDF Partition Table for ESPHome Component Testing +# Single app partition to maximize space for large component group testing +# Fits in 4MB flash +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, , 0x2000, +phy_init, data, phy, , 0x1000, +factory, app, factory, 0x10000, 0x300000, +nvs_key, data, nvs_keys,, 0x1000, +coredump, data, coredump,, 0xEB000, diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 932221997c..fc61841500 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -96,6 +96,13 @@ def mock_run_git_command() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_subprocess_run() -> Generator[Mock, None, None]: + """Mock subprocess.run for testing.""" + with patch("subprocess.run") as mock: + yield mock + + @pytest.fixture def mock_get_idedata() -> Generator[Mock, None, None]: """Mock get_idedata for platformio_api.""" diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 52c72291d6..02f965782b 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -287,7 +287,7 @@ def test_perform_ota_no_auth(mock_socket: Mock, mock_file: io.BytesIO) -> None: mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") # Should not send any auth-related data auth_calls = [ @@ -317,7 +317,7 @@ def test_perform_ota_with_compression(mock_socket: Mock) -> None: mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") # Verify compressed content was sent # Get the binary size that was sent (4 bytes after features) @@ -347,7 +347,7 @@ def test_perform_ota_auth_without_password(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="ESP requests password, but no password given" ): - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") @pytest.mark.usefixtures("mock_time") @@ -413,7 +413,7 @@ def test_perform_ota_sha256_auth_without_password(mock_socket: Mock) -> None: with pytest.raises( espota2.OTAError, match="ESP requests password, but no password given" ): - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") def test_perform_ota_unexpected_auth_response(mock_socket: Mock) -> None: @@ -450,7 +450,7 @@ def test_perform_ota_unsupported_version(mock_socket: Mock) -> None: mock_socket.recv.side_effect = responses with pytest.raises(espota2.OTAError, match="Device uses unsupported OTA version"): - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") @pytest.mark.usefixtures("mock_time") @@ -471,7 +471,7 @@ def test_perform_ota_upload_error(mock_socket: Mock, mock_file: io.BytesIO) -> N mock_socket.recv.side_effect = recv_responses with pytest.raises(espota2.OTAError, match="Error receiving acknowledge chunk OK"): - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") @pytest.mark.usefixtures("mock_socket_constructor", "mock_resolve_ip") @@ -706,7 +706,7 @@ def test_perform_ota_version_differences( ] mock_socket.recv.side_effect = recv_responses - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") # For v1.0, verify that we only get the expected number of recv calls # v1.0 doesn't have chunk acknowledgments, so fewer recv calls @@ -732,7 +732,7 @@ def test_perform_ota_version_differences( ] mock_socket.recv.side_effect = recv_responses_v2 - espota2.perform_ota(mock_socket, "", mock_file, "test.bin") + espota2.perform_ota(mock_socket, None, mock_file, "test.bin") # For v2.0, verify more recv calls due to chunk acknowledgments assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK) diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 6a51206ec2..0411fe5e43 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -1,13 +1,204 @@ """Tests for git.py module.""" from datetime import datetime, timedelta -import hashlib import os from pathlib import Path +from typing import Any from unittest.mock import Mock +import pytest + from esphome import git from esphome.core import CORE, TimePeriodSeconds +from esphome.git import GitCommandError + + +def _compute_repo_dir(url: str, ref: str | None, domain: str) -> Path: + """Helper to compute the expected repo directory path using git module's logic.""" + key = f"{url}@{ref}" + return git._compute_destination_path(key, domain) + + +def _setup_old_repo(repo_dir: Path, days_old: int = 2) -> None: + """Helper to set up a git repo directory structure with an old timestamp. + + Args: + repo_dir: The repository directory path to create. + days_old: Number of days old to make the FETCH_HEAD file (default: 2). + """ + # Create repo directory + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + + # Create FETCH_HEAD file with old timestamp + fetch_head = git_dir / "FETCH_HEAD" + fetch_head.write_text("test") + old_time = datetime.now() - timedelta(days=days_old) + fetch_head.touch() + os.utime(fetch_head, (old_time.timestamp(), old_time.timestamp())) + + +def _get_git_command_type(cmd: list[str]) -> str | None: + """Helper to determine the type of git command from a command list. + + Args: + cmd: The git command list (e.g., ["git", "rev-parse", "HEAD"]). + + Returns: + The command type ("rev-parse", "stash", "fetch", "reset", "clone") or None. + """ + # Git commands are always in format ["git", "command", ...], so check index 1 + if len(cmd) > 1: + return cmd[1] + return None + + +def test_run_git_command_success(tmp_path: Path) -> None: + """Test that run_git_command returns output on success.""" + # Create a simple git repo to test with + repo_dir = tmp_path / "test_repo" + repo_dir.mkdir() + + # Initialize a git repo + result = git.run_git_command(["git", "init"], str(repo_dir)) + assert "Initialized empty Git repository" in result or result == "" + + # Verify we can run a command and get output + result = git.run_git_command(["git", "status", "--porcelain"], str(repo_dir)) + # Empty repo should have empty status + assert isinstance(result, str) + + +def test_run_git_command_with_git_dir_isolation( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """Test that git_dir parameter properly isolates git operations.""" + repo_dir = tmp_path / "test_repo" + repo_dir.mkdir() + git_dir = repo_dir / ".git" + git_dir.mkdir() + + # Configure mock to return success + mock_subprocess_run.return_value = Mock( + returncode=0, + stdout=b"test output", + stderr=b"", + ) + + result = git.run_git_command( + ["git", "rev-parse", "HEAD"], + git_dir=repo_dir, + ) + + # Verify subprocess.run was called + assert mock_subprocess_run.called + call_args = mock_subprocess_run.call_args + + # Verify environment was set + env = call_args[1]["env"] + assert "GIT_DIR" in env + assert "GIT_WORK_TREE" in env + assert env["GIT_DIR"] == str(repo_dir / ".git") + assert env["GIT_WORK_TREE"] == str(repo_dir) + + assert result == "test output" + + +def test_run_git_command_raises_git_not_installed_error( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """Test that FileNotFoundError is converted to GitNotInstalledError.""" + from esphome.git import GitNotInstalledError + + repo_dir = tmp_path / "test_repo" + + # Configure mock to raise FileNotFoundError + mock_subprocess_run.side_effect = FileNotFoundError("git not found") + + with pytest.raises(GitNotInstalledError, match="git is not installed"): + git.run_git_command(["git", "status"], git_dir=repo_dir) + + +def test_run_git_command_raises_git_command_error_on_failure( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """Test that failed git commands raise GitCommandError.""" + repo_dir = tmp_path / "test_repo" + + # Configure mock to return non-zero exit code + mock_subprocess_run.return_value = Mock( + returncode=1, + stdout=b"", + stderr=b"fatal: not a git repository", + ) + + with pytest.raises(GitCommandError, match="not a git repository"): + git.run_git_command(["git", "status"], git_dir=repo_dir) + + +def test_run_git_command_strips_fatal_prefix( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """Test that 'fatal: ' prefix is stripped from error messages.""" + repo_dir = tmp_path / "test_repo" + + # Configure mock to return error with "fatal: " prefix + mock_subprocess_run.return_value = Mock( + returncode=128, + stdout=b"", + stderr=b"fatal: repository not found\n", + ) + + with pytest.raises(GitCommandError) as exc_info: + git.run_git_command(["git", "clone", "invalid-url"], git_dir=repo_dir) + + # Error message should NOT include "fatal: " prefix + assert "fatal:" not in str(exc_info.value) + assert "repository not found" in str(exc_info.value) + + +def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None: + """Test that run_git_command works without git_dir (clone case).""" + # Configure mock to return success + mock_subprocess_run.return_value = Mock( + returncode=0, + stdout=b"Cloning into 'test_repo'...", + stderr=b"", + ) + + result = git.run_git_command(["git", "clone", "https://github.com/test/repo"]) + + # Verify subprocess.run was called + assert mock_subprocess_run.called + call_args = mock_subprocess_run.call_args + + # Verify environment does NOT have GIT_DIR or GIT_WORK_TREE set + # (it should use the default environment or None) + env = call_args[1].get("env") + if env is not None: + assert "GIT_DIR" not in env + assert "GIT_WORK_TREE" not in env + + # Verify cwd is None (default) + assert call_args[1].get("cwd") is None + + assert result == "Cloning into 'test_repo'..." + + +def test_run_git_command_without_git_dir_raises_error( + mock_subprocess_run: Mock, +) -> None: + """Test that run_git_command without git_dir can still raise errors.""" + # Configure mock to return error + mock_subprocess_run.return_value = Mock( + returncode=128, + stdout=b"", + stderr=b"fatal: repository not found\n", + ) + + with pytest.raises(GitCommandError, match="repository not found"): + git.run_git_command(["git", "clone", "https://invalid.url/repo.git"]) def test_clone_or_update_with_never_refresh( @@ -17,16 +208,10 @@ def test_clone_or_update_with_never_refresh( # Set up CORE.config_path so data_dir uses tmp_path CORE.config_path = tmp_path / "test.yaml" - # Compute the expected repo directory path url = "https://github.com/test/repo" ref = None - key = f"{url}@{ref}" domain = "test" - - # Compute hash-based directory name (matching _compute_destination_path logic) - h = hashlib.new("sha256") - h.update(key.encode()) - repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + repo_dir = _compute_repo_dir(url, ref, domain) # Create the git repo directory structure repo_dir.mkdir(parents=True) @@ -58,16 +243,10 @@ def test_clone_or_update_with_refresh_updates_old_repo( # Set up CORE.config_path so data_dir uses tmp_path CORE.config_path = tmp_path / "test.yaml" - # Compute the expected repo directory path url = "https://github.com/test/repo" ref = None - key = f"{url}@{ref}" domain = "test" - - # Compute hash-based directory name (matching _compute_destination_path logic) - h = hashlib.new("sha256") - h.update(key.encode()) - repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + repo_dir = _compute_repo_dir(url, ref, domain) # Create the git repo directory structure repo_dir.mkdir(parents=True) @@ -112,16 +291,10 @@ def test_clone_or_update_with_refresh_skips_fresh_repo( # Set up CORE.config_path so data_dir uses tmp_path CORE.config_path = tmp_path / "test.yaml" - # Compute the expected repo directory path url = "https://github.com/test/repo" ref = None - key = f"{url}@{ref}" domain = "test" - - # Compute hash-based directory name (matching _compute_destination_path logic) - h = hashlib.new("sha256") - h.update(key.encode()) - repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + repo_dir = _compute_repo_dir(url, ref, domain) # Create the git repo directory structure repo_dir.mkdir(parents=True) @@ -158,16 +331,10 @@ def test_clone_or_update_clones_missing_repo( # Set up CORE.config_path so data_dir uses tmp_path CORE.config_path = tmp_path / "test.yaml" - # Compute the expected repo directory path url = "https://github.com/test/repo" ref = None - key = f"{url}@{ref}" domain = "test" - - # Compute hash-based directory name (matching _compute_destination_path logic) - h = hashlib.new("sha256") - h.update(key.encode()) - repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + repo_dir = _compute_repo_dir(url, ref, domain) # Create base directory but NOT the repo itself base_dir = tmp_path / ".esphome" / domain @@ -200,16 +367,10 @@ def test_clone_or_update_with_none_refresh_always_updates( # Set up CORE.config_path so data_dir uses tmp_path CORE.config_path = tmp_path / "test.yaml" - # Compute the expected repo directory path url = "https://github.com/test/repo" ref = None - key = f"{url}@{ref}" domain = "test" - - # Compute hash-based directory name (matching _compute_destination_path logic) - h = hashlib.new("sha256") - h.update(key.encode()) - repo_dir = tmp_path / ".esphome" / domain / h.hexdigest()[:8] + repo_dir = _compute_repo_dir(url, ref, domain) # Create the git repo directory structure repo_dir.mkdir(parents=True) @@ -244,3 +405,269 @@ def test_clone_or_update_with_none_refresh_always_updates( if len(call[0]) > 0 and "fetch" in call[0][0] ] assert len(fetch_calls) > 0 + + +@pytest.mark.parametrize( + ("fail_command", "error_message"), + [ + ( + "rev-parse", + "ambiguous argument 'HEAD': unknown revision or path not in the working tree.", + ), + ("stash", "fatal: unable to write new index file"), + ( + "fetch", + "fatal: unable to access 'https://github.com/test/repo/': Could not resolve host", + ), + ("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"), + ], +) +def test_clone_or_update_recovers_from_git_failures( + tmp_path: Path, mock_run_git_command: Mock, fail_command: str, error_message: str +) -> None: + """Test that repos are re-cloned when various git commands fail.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + # Use helper to set up old repo + _setup_old_repo(repo_dir) + + # Track command call counts to make first call fail, subsequent calls succeed + call_counts: dict[str, int] = {} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + # Determine which command this is + cmd_type = _get_git_command_type(cmd) + + # Track call count for this command type + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + + # Fail on first call to the specified command, succeed on subsequent calls + if cmd_type == fail_command and call_counts[cmd_type] == 1: + raise GitCommandError(error_message) + + # Default successful responses + if cmd_type == "rev-parse": + return "abc123" + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # Verify recovery happened + call_list = mock_run_git_command.call_args_list + + # Should have attempted the failing command + assert any(fail_command in str(c) for c in call_list) + + # Should have called clone for recovery + assert any("clone" in str(c) for c in call_list) + + # Verify the repo directory path is returned + assert result_dir == repo_dir + + +def test_clone_or_update_fails_when_recovery_also_fails( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that we don't infinitely recurse when recovery also fails.""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + # Use helper to set up old repo + _setup_old_repo(repo_dir) + + # Mock git command to fail on clone (simulating network failure during recovery) + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type == "rev-parse": + # First time fails (broken repo) + raise GitCommandError( + "ambiguous argument 'HEAD': unknown revision or path not in the working tree." + ) + if cmd_type == "clone": + # Clone also fails (recovery fails) + raise GitCommandError("fatal: unable to access repository") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + + # Should raise after one recovery attempt fails + with pytest.raises(GitCommandError, match="fatal: unable to access repository"): + git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # Verify we only tried to clone once (no infinite recursion) + call_list = mock_run_git_command.call_args_list + clone_calls = [c for c in call_list if "clone" in c[0][0]] + # Should have exactly one clone call (the recovery attempt that failed) + assert len(clone_calls) == 1 + # Should have tried rev-parse once (which failed and triggered recovery) + rev_parse_calls = [c for c in call_list if "rev-parse" in c[0][0]] + assert len(rev_parse_calls) == 1 + + +def test_clone_or_update_recover_broken_flag_prevents_second_recovery( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that _recover_broken=False prevents a second recovery attempt (tests the raise path).""" + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + # Use helper to set up old repo + _setup_old_repo(repo_dir) + + # Track fetch calls to differentiate between first (in clone) and second (in recovery update) + call_counts: dict[str, int] = {} + + # Mock git command to fail on fetch during recovery's ref checkout + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + + # First attempt: rev-parse fails (broken repo) + if cmd_type == "rev-parse" and call_counts[cmd_type] == 1: + raise GitCommandError( + "ambiguous argument 'HEAD': unknown revision or path not in the working tree." + ) + + # Recovery: clone succeeds + if cmd_type == "clone": + return "" + + # Recovery: fetch for ref checkout fails + # This happens in the clone path when ref is not None (line 80 in git.py) + if cmd_type == "fetch" and call_counts[cmd_type] == 1: + raise GitCommandError("fatal: couldn't find remote ref main") + + # Default success + return "abc123" if cmd_type == "rev-parse" else "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + + # Should raise on the fetch during recovery (when _recover_broken=False) + # This tests the critical "if not _recover_broken: raise" path + with pytest.raises(GitCommandError, match="fatal: couldn't find remote ref main"): + git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # Verify the sequence of events + call_list = mock_run_git_command.call_args_list + + # Should have: rev-parse (fail, triggers recovery), clone (success), + # fetch (fail during ref checkout, raises because _recover_broken=False) + rev_parse_calls = [c for c in call_list if "rev-parse" in c[0][0]] + # Should have exactly one rev-parse call that failed + assert len(rev_parse_calls) == 1 + + clone_calls = [c for c in call_list if "clone" in c[0][0]] + # Should have exactly one clone call (the recovery attempt) + assert len(clone_calls) == 1 + + fetch_calls = [c for c in call_list if "fetch" in c[0][0]] + # Should have exactly one fetch call that failed (during ref checkout in recovery) + assert len(fetch_calls) == 1 + + +def test_clone_or_update_recover_broken_flag_prevents_infinite_loop( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Test that _recover_broken=False prevents infinite recursion when repo persists.""" + # This tests the critical "if not _recover_broken: raise" path at line 124-125 + # Set up CORE.config_path so data_dir uses tmp_path + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + # Use helper to set up old repo + _setup_old_repo(repo_dir) + + # Mock shutil.rmtree to NOT actually delete the directory + # This simulates a scenario where deletion fails (permissions, etc.) + import unittest.mock + + def mock_rmtree(path, *args, **kwargs): + # Don't actually delete - this causes the recursive call to still see the repo + pass + + # Mock git commands to always fail on stash + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "stash": + # Always fails + raise GitCommandError("fatal: unable to write new index file") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + + # Mock shutil.rmtree and test + # Should raise on the second attempt when _recover_broken=False + # This hits the "if not _recover_broken: raise" path + with ( + unittest.mock.patch("esphome.git.shutil.rmtree", side_effect=mock_rmtree), + pytest.raises(GitCommandError, match="fatal: unable to write new index file"), + ): + git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # Verify the sequence: stash fails twice (once triggering recovery, once raising) + call_list = mock_run_git_command.call_args_list + stash_calls = [c for c in call_list if "stash" in c[0][0]] + # Should have exactly two stash calls + assert len(stash_calls) == 2 diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index becf911fa3..59d0433aa4 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1062,7 +1062,7 @@ def test_upload_program_ota_with_file_arg( assert exit_code == 0 assert host == "192.168.1.100" mock_run_ota.assert_called_once_with( - ["192.168.1.100"], 3232, "", Path("custom.bin") + ["192.168.1.100"], 3232, None, Path("custom.bin") ) @@ -1119,7 +1119,9 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware = ( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) - mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware) + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, None, expected_firmware + ) @patch("esphome.__main__.importlib.import_module") @@ -1976,3 +1978,292 @@ def test_command_clean_all_args_used() -> None: # Verify the correct configuration paths were passed mock_clean_all.assert_any_call(["/path/to/config1"]) mock_clean_all.assert_any_call(["/path/to/config2", "/path/to/config3"]) + + +def test_upload_program_ota_static_ip_with_mqttip( + mock_mqtt_get_ip: Mock, + mock_run_ota: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with static IP and MQTTIP (issue #11260). + + This tests the scenario where a device has manual_ip (static IP) configured + and MQTT is also configured. The devices list contains both the static IP + and "MQTTIP" magic string. This previously failed because only the first + device was checked for MQTT resolution. + """ + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_mqtt_get_ip.return_value = ["192.168.2.50"] # Different subnet + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + # Simulates choose_upload_log_host returning static IP + MQTTIP + devices = ["192.168.1.100", "MQTTIP"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + + # Verify MQTT was resolved + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + + # Verify espota2.run_ota was called with both IPs + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware + ) + + +def test_upload_program_ota_multiple_mqttip_resolves_once( + mock_mqtt_get_ip: Mock, + mock_run_ota: Mock, + tmp_path: Path, +) -> None: + """Test that MQTT resolution only happens once even with multiple MQTT magic strings.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_mqtt_get_ip.return_value = ["192.168.2.50", "192.168.2.51"] + mock_run_ota.return_value = (0, "192.168.2.50") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + # Multiple MQTT magic strings in the list + devices = ["MQTTIP", "MQTT", "192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.2.50" + + # Verify MQTT was only resolved once despite multiple MQTT magic strings + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + + # Verify espota2.run_ota was called with all unique IPs + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware + ) + + +def test_upload_program_ota_mqttip_deduplication( + mock_mqtt_get_ip: Mock, + mock_run_ota: Mock, + tmp_path: Path, +) -> None: + """Test that duplicate IPs are filtered when MQTT returns same IP as static IP.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + # MQTT returns the same IP as the static IP + mock_mqtt_get_ip.return_value = ["192.168.1.100"] + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["192.168.1.100", "MQTTIP"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + + # Verify MQTT was resolved + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + + # Verify espota2.run_ota was called with deduplicated IPs (only one instance of 192.168.1.100) + # Note: Current implementation doesn't dedupe, so we'll get the IP twice + # This test documents current behavior - deduplication could be future enhancement + mock_run_ota.assert_called_once() + call_args = mock_run_ota.call_args[0] + # Should contain both the original IP and MQTT-resolved IP (even if duplicate) + assert "192.168.1.100" in call_args[0] + + +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api_static_ip_with_mqttip( + mock_run_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """Test show_logs with static IP and MQTTIP (issue #11260). + + This tests the scenario where a device has manual_ip (static IP) configured + and MQTT is also configured. The devices list contains both the static IP + and "MQTTIP" magic string. + """ + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + mock_mqtt_get_ip.return_value = ["192.168.2.50"] + + args = MockArgs(username="user", password="pass", client_id="client") + # Simulates choose_upload_log_host returning static IP + MQTTIP + devices = ["192.168.1.100", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + + # Verify MQTT was resolved + mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + + # Verify run_logs was called with both IPs + mock_run_logs.assert_called_once_with( + CORE.config, ["192.168.1.100", "192.168.2.50"] + ) + + +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api_multiple_mqttip_resolves_once( + mock_run_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """Test that MQTT resolution only happens once for show_logs with multiple MQTT magic strings.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + mock_mqtt_get_ip.return_value = ["192.168.2.50", "192.168.2.51"] + + args = MockArgs(username="user", password="pass", client_id="client") + # Multiple MQTT magic strings in the list + devices = ["MQTTIP", "192.168.1.100", "MQTT"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + + # Verify MQTT was only resolved once despite multiple MQTT magic strings + mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + + # Verify run_logs was called with all unique IPs (MQTT strings replaced with IPs) + # Note: "MQTT" is a different magic string from "MQTTIP", but both trigger MQTT resolution + # The _resolve_network_devices helper filters out both after first resolution + mock_run_logs.assert_called_once_with( + CORE.config, ["192.168.2.50", "192.168.2.51", "192.168.1.100"] + ) + + +def test_upload_program_ota_mqtt_timeout_fallback( + mock_mqtt_get_ip: Mock, + mock_run_ota: Mock, + tmp_path: Path, +) -> None: + """Test upload_program falls back to other devices when MQTT times out.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + # MQTT times out + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + # Static IP first, MQTTIP second + devices = ["192.168.1.100", "MQTTIP"] + + exit_code, host = upload_program(config, args, devices) + + # Should succeed using the static IP even though MQTT failed + assert exit_code == 0 + assert host == "192.168.1.100" + + # Verify MQTT was attempted + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + + # Verify espota2.run_ota was called with only the static IP (MQTT failed) + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, None, expected_firmware + ) + + +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api_mqtt_timeout_fallback( + mock_run_logs: Mock, + mock_mqtt_get_ip: Mock, +) -> None: + """Test show_logs falls back to other devices when MQTT times out.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + # MQTT times out + mock_mqtt_get_ip.side_effect = EsphomeError("Failed to find IP via MQTT") + + args = MockArgs(username="user", password="pass", client_id="client") + # Static IP first, MQTTIP second + devices = ["192.168.1.100", "MQTTIP"] + + result = show_logs(CORE.config, args, devices) + + # Should succeed using the static IP even though MQTT failed + assert result == 0 + + # Verify MQTT was attempted + mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + + # Verify run_logs was called with only the static IP (MQTT failed) + mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.100"]) diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index dd419aba9c..59396a4a83 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -2,9 +2,12 @@ import glob import logging from pathlib import Path -from esphome import yaml_util +from esphome import config as config_module, yaml_util from esphome.components import substitutions -from esphome.const import CONF_PACKAGES +from esphome.config_helpers import merge_config +from esphome.const import CONF_PACKAGES, CONF_SUBSTITUTIONS +from esphome.core import CORE +from esphome.util import OrderedDict _LOGGER = logging.getLogger(__name__) @@ -118,3 +121,200 @@ def test_substitutions_fixtures(fixture_path): if DEV_MODE: _LOGGER.error("Tests passed, but Dev mode is enabled.") assert not DEV_MODE # make sure DEV_MODE is disabled after you are finished. + + +def test_substitutions_with_command_line_maintains_ordered_dict() -> None: + """Test that substitutions remain an OrderedDict when command line substitutions are provided, + and that move_to_end() can be called successfully. + + This is a regression test for https://github.com/esphome/esphome/issues/11182 + where the config would become a regular dict and fail when move_to_end() was called. + """ + # Create an OrderedDict config with substitutions + config = OrderedDict() + config["esphome"] = {"name": "test"} + config[CONF_SUBSTITUTIONS] = {"var1": "value1", "var2": "value2"} + config["other_key"] = "other_value" + + # Command line substitutions that should override + command_line_subs = {"var2": "override", "var3": "new_value"} + + # Call do_substitution_pass with command line substitutions + substitutions.do_substitution_pass(config, command_line_subs) + + # Verify that config is still an OrderedDict + assert isinstance(config, OrderedDict), "Config should remain an OrderedDict" + + # Verify substitutions are at the beginning (move_to_end with last=False) + keys = list(config.keys()) + assert keys[0] == CONF_SUBSTITUTIONS, "Substitutions should be first key" + + # Verify substitutions were properly merged + assert config[CONF_SUBSTITUTIONS]["var1"] == "value1" + assert config[CONF_SUBSTITUTIONS]["var2"] == "override" + assert config[CONF_SUBSTITUTIONS]["var3"] == "new_value" + + # Verify config[CONF_SUBSTITUTIONS] is also an OrderedDict + assert isinstance(config[CONF_SUBSTITUTIONS], OrderedDict), ( + "Substitutions should be an OrderedDict" + ) + + +def test_substitutions_without_command_line_maintains_ordered_dict() -> None: + """Test that substitutions work correctly without command line substitutions.""" + config = OrderedDict() + config["esphome"] = {"name": "test"} + config[CONF_SUBSTITUTIONS] = {"var1": "value1"} + config["other_key"] = "other_value" + + # Call without command line substitutions + substitutions.do_substitution_pass(config, None) + + # Verify that config is still an OrderedDict + assert isinstance(config, OrderedDict), "Config should remain an OrderedDict" + + # Verify substitutions are at the beginning + keys = list(config.keys()) + assert keys[0] == CONF_SUBSTITUTIONS, "Substitutions should be first key" + + +def test_substitutions_after_merge_config_maintains_ordered_dict() -> None: + """Test that substitutions work after merge_config (packages scenario). + + This is a regression test for https://github.com/esphome/esphome/issues/11182 + where using packages would cause config to become a regular dict, breaking move_to_end(). + """ + # Simulate what happens with packages - merge two OrderedDict configs + base_config = OrderedDict() + base_config["esphome"] = {"name": "base"} + base_config[CONF_SUBSTITUTIONS] = {"var1": "value1"} + + package_config = OrderedDict() + package_config["sensor"] = [{"platform": "template"}] + package_config[CONF_SUBSTITUTIONS] = {"var2": "value2"} + + # Merge configs (simulating package merge) + merged_config = merge_config(base_config, package_config) + + # Verify merged config is still an OrderedDict + assert isinstance(merged_config, OrderedDict), ( + "Merged config should be an OrderedDict" + ) + + # Now try to run substitution pass on the merged config + substitutions.do_substitution_pass(merged_config, None) + + # Should not raise AttributeError + assert isinstance(merged_config, OrderedDict), ( + "Config should still be OrderedDict after substitution pass" + ) + keys = list(merged_config.keys()) + assert keys[0] == CONF_SUBSTITUTIONS, "Substitutions should be first key" + + +def test_validate_config_with_command_line_substitutions_maintains_ordered_dict( + tmp_path, +) -> None: + """Test that validate_config preserves OrderedDict when merging command-line substitutions. + + This tests the code path in config.py where result[CONF_SUBSTITUTIONS] is set + using merge_dicts_ordered() with command-line substitutions provided. + """ + # Create a minimal valid config + test_config = OrderedDict() + test_config["esphome"] = {"name": "test_device", "platform": "ESP32"} + test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"}) + test_config["esp32"] = {"board": "esp32dev"} + + # Command line substitutions that should override + command_line_subs = {"var2": "override", "var3": "new_value"} + + # Set up CORE for the test with a proper Path object + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("# test config") + CORE.config_path = test_yaml + + # Call validate_config with command line substitutions + result = config_module.validate_config(test_config, command_line_subs) + + # Verify that result[CONF_SUBSTITUTIONS] is an OrderedDict + assert isinstance(result.get(CONF_SUBSTITUTIONS), OrderedDict), ( + "Result substitutions should be an OrderedDict" + ) + + # Verify substitutions were properly merged + assert result[CONF_SUBSTITUTIONS]["var1"] == "value1" + assert result[CONF_SUBSTITUTIONS]["var2"] == "override" + assert result[CONF_SUBSTITUTIONS]["var3"] == "new_value" + + +def test_validate_config_without_command_line_substitutions_maintains_ordered_dict( + tmp_path, +) -> None: + """Test that validate_config preserves OrderedDict without command-line substitutions. + + This tests the code path in config.py where result[CONF_SUBSTITUTIONS] is set + using merge_dicts_ordered() when command_line_substitutions is None. + """ + # Create a minimal valid config + test_config = OrderedDict() + test_config["esphome"] = {"name": "test_device", "platform": "ESP32"} + test_config[CONF_SUBSTITUTIONS] = OrderedDict({"var1": "value1", "var2": "value2"}) + test_config["esp32"] = {"board": "esp32dev"} + + # Set up CORE for the test with a proper Path object + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("# test config") + CORE.config_path = test_yaml + + # Call validate_config without command line substitutions + result = config_module.validate_config(test_config, None) + + # Verify that result[CONF_SUBSTITUTIONS] is an OrderedDict + assert isinstance(result.get(CONF_SUBSTITUTIONS), OrderedDict), ( + "Result substitutions should be an OrderedDict" + ) + + # Verify substitutions are unchanged + assert result[CONF_SUBSTITUTIONS]["var1"] == "value1" + assert result[CONF_SUBSTITUTIONS]["var2"] == "value2" + + +def test_merge_config_preserves_ordered_dict() -> None: + """Test that merge_config preserves OrderedDict type. + + This is a regression test to ensure merge_config doesn't lose OrderedDict type + when merging configs, which causes AttributeError on move_to_end(). + """ + # Test OrderedDict + dict = OrderedDict + od = OrderedDict([("a", 1), ("b", 2)]) + d = {"b": 20, "c": 3} + result = merge_config(od, d) + assert isinstance(result, OrderedDict), ( + "OrderedDict + dict should return OrderedDict" + ) + + # Test dict + OrderedDict = OrderedDict + d = {"a": 1, "b": 2} + od = OrderedDict([("b", 20), ("c", 3)]) + result = merge_config(d, od) + assert isinstance(result, OrderedDict), ( + "dict + OrderedDict should return OrderedDict" + ) + + # Test OrderedDict + OrderedDict = OrderedDict + od1 = OrderedDict([("a", 1), ("b", 2)]) + od2 = OrderedDict([("b", 20), ("c", 3)]) + result = merge_config(od1, od2) + assert isinstance(result, OrderedDict), ( + "OrderedDict + OrderedDict should return OrderedDict" + ) + + # Test that dict + dict still returns regular dict (no unnecessary conversion) + d1 = {"a": 1, "b": 2} + d2 = {"b": 20, "c": 3} + result = merge_config(d1, d2) + assert isinstance(result, dict), "dict + dict should return dict" + assert not isinstance(result, OrderedDict), ( + "dict + dict should not return OrderedDict" + )