diff --git a/.coveragerc b/.coveragerc index 723242b288..12e48ec395 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,4 @@ [run] -omit = esphome/components/* +omit = + esphome/components/* + tests/integration/* diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 92d209cc34..f51bd84186 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 8a14dba5eb..5f524612ed 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,7 +43,7 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35488d96b..22bdeca429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt') }}" >> $GITHUB_OUTPUT @@ -68,7 +68,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -89,7 +89,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -110,7 +110,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -131,7 +131,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -152,7 +152,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -202,7 +202,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -214,12 +214,12 @@ jobs: if: matrix.os == 'windows-latest' run: | ./venv/Scripts/activate - pytest -vv --cov-report=xml --tb=native tests + pytest -vv --cov-report=xml --tb=native -n auto tests - name: Run pytest if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' run: | . venv/bin/activate - pytest -vv --cov-report=xml --tb=native tests + pytest -vv --cov-report=xml --tb=native -n auto tests - name: Upload coverage to Codecov uses: codecov/codecov-action@v5.4.3 with: @@ -232,7 +232,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -300,7 +300,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -356,7 +356,7 @@ jobs: count: ${{ steps.list-components.outputs.count }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 with: # Fetch enough history so `git merge-base refs/remotes/origin/dev HEAD` works. fetch-depth: 500 @@ -406,7 +406,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -432,7 +432,7 @@ jobs: matrix: ${{ steps.split.outputs.components }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Split components into 20 groups id: split run: | @@ -462,7 +462,7 @@ jobs: sudo apt-get install libsdl2-dev - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a310b7f083..eae01fe0b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,7 +60,7 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: @@ -92,7 +92,7 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Set up Python uses: actions/setup-python@v5.6.0 with: @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - name: Download digests uses: actions/download-artifact@v4.3.0 diff --git a/.github/workflows/yaml-lint.yml b/.github/workflows/yaml-lint.yml index 1c0b5f58ad..ed9b4407a2 100644 --- a/.github/workflows/yaml-lint.yml +++ b/.github/workflows/yaml-lint.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Run yamllint uses: frenck/action-yamllint@v1.5.0 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a76d5dd9b9..d55c00eea7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: - --branch=release - --branch=beta - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + rev: v3.20.0 hooks: - id: pyupgrade args: [--py310-plus] diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb466bce81..fe64955d6e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -7,11 +7,11 @@ #include #include #include "esphome/components/network/util.h" +#include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/version.h" -#include "esphome/core/application.h" #ifdef USE_DEEP_SLEEP #include "esphome/components/deep_sleep/deep_sleep_component.h" @@ -179,7 +179,11 @@ void APIConnection::loop() { // Section: Process Message start_time = millis(); - this->read_message(buffer.data_len, buffer.type, &buffer.container[buffer.data_offset]); + if (buffer.data_len > 0) { + this->read_message(buffer.data_len, buffer.type, &buffer.container[buffer.data_offset]); + } else { + this->read_message(0, buffer.type, nullptr); + } duration = millis() - start_time; this->section_stats_["process_message"].record_time(duration); diff --git a/esphome/components/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index 550f534b74..f9fe7f5a5e 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_LINE_FREQUENCY, CONF_POWER, + CONF_RESET, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, @@ -27,7 +28,6 @@ from esphome.const import ( CONF_CURRENT_REFERENCE = "current_reference" CONF_ENERGY_REFERENCE = "energy_reference" CONF_POWER_REFERENCE = "power_reference" -CONF_RESET = "reset" CONF_VOLTAGE_REFERENCE = "voltage_reference" DEPENDENCIES = ["uart"] diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index d6dbb52f18..f4235b31b4 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -16,7 +16,7 @@ CODEOWNERS = ["@neffs", "@kbx81"] DOMAIN = "bme68x_bsec2" -BSEC2_LIBRARY_VERSION = "v1.8.2610" +BSEC2_LIBRARY_VERSION = "1.10.2610" CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" @@ -145,7 +145,6 @@ CONFIG_SCHEMA_BASE = ( ): cv.positive_time_period_minutes, }, ) - .add_extra(cv.only_with_arduino) .add_extra(validate_bme68x) .add_extra(download_bme68x_blob) ) @@ -179,11 +178,13 @@ async def to_code_base(config): bsec2_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) cg.add(var.set_bsec2_configuration(bsec2_arr, len(rhs))) - # Although this component does not use SPI, the BSEC2 library requires the SPI library - cg.add_library("SPI", None) + # Although this component does not use SPI, the BSEC2 Arduino library requires the SPI library + if core.CORE.using_arduino: + cg.add_library("SPI", None) cg.add_library( "BME68x Sensor library", - "1.1.40407", + "1.3.40408", + "https://github.com/boschsensortec/Bosch-BME68x-Library", ) cg.add_library( "BSEC2 Software Library", diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index f83f20f1a5..07ad1fde90 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -1,4 +1,5 @@ #include "esphome/core/defines.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp index 874c8bf388..50eaf33add 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp @@ -1,4 +1,5 @@ #include "esphome/core/defines.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9c507d8a21..b211015865 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -57,6 +57,7 @@ from .const import ( # noqa VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -88,6 +89,7 @@ CPU_FREQUENCIES = { VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C2: get_cpu_frequencies(80, 120), VARIANT_ESP32C3: get_cpu_frequencies(80, 160), + VARIANT_ESP32C5: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 1a8f6d5332..e7cdac0d8e 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -2,6 +2,7 @@ from .const import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -1593,6 +1594,10 @@ BOARDS = { "name": "Ai-Thinker ESP-C3-M1-I-Kit", "variant": VARIANT_ESP32C3, }, + "esp32-c5-devkitc-1": { + "name": "Espressif ESP32-C5-DevKitC-1", + "variant": VARIANT_ESP32C5, + }, "esp32-c6-devkitc-1": { "name": "Espressif ESP32-C6-DevKitC-1", "variant": VARIANT_ESP32C6, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 8ca3905be9..9bef18847f 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -17,6 +17,7 @@ VARIANT_ESP32S2 = "ESP32S2" VARIANT_ESP32S3 = "ESP32S3" VARIANT_ESP32C2 = "ESP32C2" VARIANT_ESP32C3 = "ESP32C3" +VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32H2 = "ESP32H2" VARIANT_ESP32P4 = "ESP32P4" @@ -26,6 +27,7 @@ VARIANTS = [ VARIANT_ESP32S3, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -37,6 +39,7 @@ VARIANT_FRIENDLY = { VARIANT_ESP32S3: "ESP32-S3", VARIANT_ESP32C2: "ESP32-C2", VARIANT_ESP32C3: "ESP32-C3", + VARIANT_ESP32C5: "ESP32-C5", VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32H2: "ESP32-H2", VARIANT_ESP32P4: "ESP32-P4", diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index c90d68d00e..562bcba3c2 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -15,8 +15,9 @@ #ifdef USE_ARDUINO #include #else +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include - +#endif void setup(); void loop(); #endif @@ -63,7 +64,13 @@ uint32_t arch_get_cpu_cycle_count() { return cpu_hal_get_cycle_count(); } uint32_t arch_get_cpu_freq_hz() { uint32_t freq = 0; #ifdef USE_ESP_IDF +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED, &freq); +#else + rtc_cpu_freq_config_t config; + rtc_clk_cpu_freq_get_config(&config); + freq = config.freq_mhz * 1000000U; +#endif #elif defined(USE_ARDUINO) freq = ESP.getCpuFreqMHz() * 1000000; #endif diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 85bae3f52a..c35e5c2215 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -27,6 +27,7 @@ from .const import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -37,6 +38,7 @@ from .const import ( from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports from .gpio_esp32_c2 import esp32_c2_validate_gpio_pin, esp32_c2_validate_supports from .gpio_esp32_c3 import esp32_c3_validate_gpio_pin, esp32_c3_validate_supports +from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_supports from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports @@ -100,6 +102,10 @@ _esp32_validations = { pin_validation=esp32_c3_validate_gpio_pin, usage_validation=esp32_c3_validate_supports, ), + VARIANT_ESP32C5: ESP32ValidationFunctions( + pin_validation=esp32_c5_validate_gpio_pin, + usage_validation=esp32_c5_validate_supports, + ), VARIANT_ESP32C6: ESP32ValidationFunctions( pin_validation=esp32_c6_validate_gpio_pin, usage_validation=esp32_c6_validate_supports, diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py new file mode 100644 index 0000000000..ada426771c --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -0,0 +1,45 @@ +import logging + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +_ESP32C5_SPI_PSRAM_PINS = { + 16: "SPICS0", + 17: "SPIQ", + 18: "SPIWP", + 19: "VDD_SPI", + 20: "SPIHD", + 21: "SPICLK", + 22: "SPID", +} + +_ESP32C5_STRAPPING_PINS = {2, 7, 27, 28} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_c5_validate_gpio_pin(value): + if value < 0 or value > 28: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-28)") + if value in _ESP32C5_SPI_PSRAM_PINS: + raise cv.Invalid( + f"This pin cannot be used on ESP32-C5s and is already used by the SPI/PSRAM interface (function: {_ESP32C5_SPI_PSRAM_PINS[value]})" + ) + + return value + + +def esp32_c5_validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 28: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-28)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 0d413adb8a..ef95fd0b41 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -4,6 +4,7 @@ from esphome.components.esp32 import get_esp32_variant from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ) @@ -74,6 +75,7 @@ I2S_PORTS = { VARIANT_ESP32S2: 1, VARIANT_ESP32S3: 2, VARIANT_ESP32C3: 1, + VARIANT_ESP32P4: 3, } i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 1a4d645c8a..462cae73b6 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -8,6 +8,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, VARIANT_ESP32P4, @@ -89,6 +90,7 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C2: [UART0, UART1], + VARIANT_ESP32C5: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], @@ -207,6 +209,7 @@ CONFIG_SCHEMA = cv.All( esp32_s3_idf=USB_SERIAL_JTAG, esp32_c3_arduino=USB_CDC, esp32_c3_idf=USB_SERIAL_JTAG, + esp32_c5_idf=USB_SERIAL_JTAG, esp32_c6_arduino=USB_CDC, esp32_c6_idf=USB_SERIAL_JTAG, esp32_p4_idf=USB_SERIAL_JTAG, diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index 258d87fd25..e325e6198e 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID from .. import ( MODBUS_REGISTER_TYPE, @@ -36,6 +36,7 @@ CONFIG_SCHEMA = cv.All( .extend(ModbusItemBaseSchema) .extend( { + cv.Optional(CONF_ASSUMED_STATE, default=False): cv.boolean, cv.Optional(CONF_REGISTER_TYPE): cv.enum(MODBUS_REGISTER_TYPE), cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, @@ -62,7 +63,10 @@ async def to_code(config): paren = await cg.get_variable(config[CONF_MODBUS_CONTROLLER_ID]) cg.add(var.set_parent(paren)) cg.add(var.set_use_write_mutiple(config[CONF_USE_WRITE_MULTIPLE])) - cg.add(paren.add_sensor_item(var)) + assumed_state = config[CONF_ASSUMED_STATE] + cg.add(var.set_assumed_state(assumed_state)) + if not assumed_state: + cg.add(paren.add_sensor_item(var)) if CONF_WRITE_LAMBDA in config: template_ = await cg.process_lambda( config[CONF_WRITE_LAMBDA], diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index b729e2659f..21c4c1718d 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -19,6 +19,10 @@ void ModbusSwitch::setup() { } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } +void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } + +bool ModbusSwitch::assumed_state() { return this->assumed_state_; } + void ModbusSwitch::parse_and_publish(const std::vector &data) { bool value = false; switch (this->register_type) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index fe4b7c1ad5..0098076ef4 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -29,6 +29,7 @@ class ModbusSwitch : public Component, public switch_::Switch, public SensorItem void setup() override; void write_state(bool state) override; void dump_config() override; + void set_assumed_state(bool assumed_state); void set_state(bool state) { this->state = state; } void parse_and_publish(const std::vector &data) override; void set_parent(ModbusController *parent) { this->parent_ = parent; } @@ -40,10 +41,12 @@ class ModbusSwitch : public Component, public switch_::Switch, public SensorItem void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } protected: + bool assumed_state() override; ModbusController *parent_{nullptr}; bool use_write_multiple_{false}; optional publish_transform_func_{nullopt}; optional write_transform_func_{nullopt}; + bool assumed_state_{false}; }; } // namespace modbus_controller diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index b1702b5ade..f1b08a505a 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -1,6 +1,6 @@ -from esphome import pins +from esphome import automation, pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import i2c, key_provider import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -8,13 +8,16 @@ from esphome.const import ( CONF_INVERTED, CONF_MODE, CONF_NUMBER, + CONF_ON_KEY, CONF_OPEN_DRAIN, CONF_OUTPUT, CONF_PULLDOWN, CONF_PULLUP, + CONF_TRIGGER_ID, ) CONF_KEYPAD = "keypad" +CONF_KEYS = "keys" CONF_KEY_ROWS = "key_rows" CONF_KEY_COLUMNS = "key_columns" CONF_SLEEP_TIME = "sleep_time" @@ -22,22 +25,47 @@ CONF_SCAN_TIME = "scan_time" CONF_DEBOUNCE_TIME = "debounce_time" CONF_SX1509_ID = "sx1509_id" +AUTO_LOAD = ["key_provider"] DEPENDENCIES = ["i2c"] MULTI_CONF = True sx1509_ns = cg.esphome_ns.namespace("sx1509") -SX1509Component = sx1509_ns.class_("SX1509Component", cg.Component, i2c.I2CDevice) +SX1509Component = sx1509_ns.class_( + "SX1509Component", cg.Component, i2c.I2CDevice, key_provider.KeyProvider +) SX1509GPIOPin = sx1509_ns.class_("SX1509GPIOPin", cg.GPIOPin) +SX1509KeyTrigger = sx1509_ns.class_( + "SX1509KeyTrigger", automation.Trigger.template(cg.uint8) +) -KEYPAD_SCHEMA = cv.Schema( - { - cv.Required(CONF_KEY_ROWS): cv.int_range(min=1, max=8), - cv.Required(CONF_KEY_COLUMNS): cv.int_range(min=1, max=8), - cv.Optional(CONF_SLEEP_TIME): cv.int_range(min=128, max=8192), - cv.Optional(CONF_SCAN_TIME): cv.int_range(min=1, max=128), - cv.Optional(CONF_DEBOUNCE_TIME): cv.int_range(min=1, max=64), - } + +def check_keys(config): + if CONF_KEYS in config: + if len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS]: + raise cv.Invalid( + "The number of key codes must equal the number of rows * columns" + ) + return config + + +KEYPAD_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_KEY_ROWS): cv.int_range(min=2, max=8), + cv.Required(CONF_KEY_COLUMNS): cv.int_range(min=1, max=8), + cv.Optional(CONF_SLEEP_TIME): cv.int_range(min=128, max=8192), + cv.Optional(CONF_SCAN_TIME): cv.int_range(min=1, max=128), + cv.Optional(CONF_DEBOUNCE_TIME): cv.int_range(min=1, max=64), + cv.Optional(CONF_KEYS): cv.string, + cv.Optional(CONF_ON_KEY): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SX1509KeyTrigger), + } + ), + } + ), + check_keys, ) CONFIG_SCHEMA = ( @@ -56,17 +84,22 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_KEYPAD in config: - keypad = config[CONF_KEYPAD] - cg.add(var.set_rows_cols(keypad[CONF_KEY_ROWS], keypad[CONF_KEY_COLUMNS])) + if conf := config.get(CONF_KEYPAD): + cg.add(var.set_rows_cols(conf[CONF_KEY_ROWS], conf[CONF_KEY_COLUMNS])) if ( - CONF_SLEEP_TIME in keypad - and CONF_SCAN_TIME in keypad - and CONF_DEBOUNCE_TIME in keypad + CONF_SLEEP_TIME in conf + and CONF_SCAN_TIME in conf + and CONF_DEBOUNCE_TIME in conf ): - cg.add(var.set_sleep_time(keypad[CONF_SLEEP_TIME])) - cg.add(var.set_scan_time(keypad[CONF_SCAN_TIME])) - cg.add(var.set_debounce_time(keypad[CONF_DEBOUNCE_TIME])) + cg.add(var.set_sleep_time(conf[CONF_SLEEP_TIME])) + cg.add(var.set_scan_time(conf[CONF_SCAN_TIME])) + cg.add(var.set_debounce_time(conf[CONF_DEBOUNCE_TIME])) + if keys := conf.get(CONF_KEYS): + cg.add(var.set_keys(keys)) + for tconf in conf.get(CONF_ON_KEY, []): + trigger = cg.new_Pvariable(tconf[CONF_TRIGGER_ID]) + cg.add(var.register_key_trigger(trigger)) + await automation.build_automation(trigger, [(cg.uint8, "x")], tconf) def validate_mode(value): diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 855a90bacd..a4808c86e2 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -48,6 +48,30 @@ void SX1509Component::loop() { uint16_t key_data = this->read_key_data(); for (auto *binary_sensor : this->keypad_binary_sensors_) binary_sensor->process(key_data); + if (this->keys_.empty()) + return; + if (key_data == 0) { + this->last_key_ = 0; + return; + } + int row, col; + for (row = 0; row < 7; row++) { + if (key_data & (1 << row)) + break; + } + for (col = 8; col < 15; col++) { + if (key_data & (1 << col)) + break; + } + col -= 8; + uint8_t key = this->keys_[row * this->cols_ + col]; + if (key == this->last_key_) + return; + this->last_key_ = key; + ESP_LOGV(TAG, "row %d, col %d, key '%c'", row, col, key); + for (auto &trigger : this->key_triggers_) + trigger->trigger(key); + this->send_key_(key); } } @@ -230,9 +254,9 @@ void SX1509Component::setup_keypad_() { scan_time_bits &= 0b111; // Scan time is bits 2:0 temp_byte = sleep_time_ | scan_time_bits; this->write_byte(REG_KEY_CONFIG_1, temp_byte); - rows_ = (rows_ - 1) & 0b111; // 0 = off, 0b001 = 2 rows, 0b111 = 8 rows, etc. - cols_ = (cols_ - 1) & 0b111; // 0b000 = 1 column, ob111 = 8 columns, etc. - this->write_byte(REG_KEY_CONFIG_2, (rows_ << 3) | cols_); + temp_byte = ((this->rows_ - 1) & 0b111) << 3; // 0 = off, 0b001 = 2 rows, 0b111 = 8 rows, etc. + temp_byte |= (this->cols_ - 1) & 0b111; // 0b000 = 1 column, ob111 = 8 columns, etc. + this->write_byte(REG_KEY_CONFIG_2, temp_byte); } uint16_t SX1509Component::read_key_data() { diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 9e4f31aab0..c0e86aa8a1 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/i2c/i2c.h" +#include "esphome/components/key_provider/key_provider.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "sx1509_gpio_pin.h" @@ -27,7 +28,9 @@ class SX1509Processor { virtual void process(uint16_t data){}; }; -class SX1509Component : public Component, public i2c::I2CDevice { +class SX1509KeyTrigger : public Trigger {}; + +class SX1509Component : public Component, public i2c::I2CDevice, public key_provider::KeyProvider { public: SX1509Component() = default; @@ -47,12 +50,14 @@ class SX1509Component : public Component, public i2c::I2CDevice { this->cols_ = cols; this->has_keypad_ = true; }; + void set_keys(std::string keys) { this->keys_ = std::move(keys); }; void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; void register_keypad_binary_sensor(SX1509Processor *binary_sensor) { this->keypad_binary_sensors_.push_back(binary_sensor); } + void register_key_trigger(SX1509KeyTrigger *trig) { this->key_triggers_.push_back(trig); }; void setup_led_driver(uint8_t pin); protected: @@ -65,10 +70,13 @@ class SX1509Component : public Component, public i2c::I2CDevice { bool has_keypad_ = false; uint8_t rows_ = 0; uint8_t cols_ = 0; + std::string keys_; uint16_t sleep_time_ = 128; uint8_t scan_time_ = 1; uint8_t debounce_time_ = 1; + uint8_t last_key_ = 0; std::vector keypad_binary_sensors_; + std::vector key_triggers_; uint32_t last_loop_timestamp_ = 0; const uint32_t min_loop_period_ = 15; // ms diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index abedfab3a6..982007e47f 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -483,14 +483,16 @@ template class WiFiConfigureAction : public Action, publi // Enable WiFi global_wifi_component->enable(); // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this]() { - this->connecting_ = false; + this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { // If the timeout is reached, stop connecting and revert to the old AP global_wifi_component->disable(); global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); global_wifi_component->enable(); - // Callback to notify the user that the connection failed - this->error_trigger_->trigger(); + // Start a timeout for the fallback if the connection to the old AP fails + this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->connecting_ = false; + this->error_trigger_->trigger(); + }); }); } @@ -503,6 +505,7 @@ template class WiFiConfigureAction : public Action, publi if (global_wifi_component->is_connected()) { // The WiFi is connected, stop the timeout and reset the connecting flag this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout("wifi-fallback-timeout"); this->connecting_ = false; if (global_wifi_component->wifi_ssid() == this->new_sta_.get_ssid()) { // Callback to notify the user that the connection was successful diff --git a/esphome/components/wl_134/text_sensor.py b/esphome/components/wl_134/text_sensor.py index d10627ab64..1a10396bc6 100644 --- a/esphome/components/wl_134/text_sensor.py +++ b/esphome/components/wl_134/text_sensor.py @@ -1,11 +1,10 @@ import esphome.codegen as cg from esphome.components import text_sensor, uart import esphome.config_validation as cv -from esphome.const import ICON_FINGERPRINT +from esphome.const import CONF_RESET, ICON_FINGERPRINT CODEOWNERS = ["@hobbypunk90"] DEPENDENCIES = ["uart"] -CONF_RESET = "reset" wl134_ns = cg.esphome_ns.namespace("wl_134") Wl134Component = wl134_ns.class_( diff --git a/esphome/const.py b/esphome/const.py index eab979e88d..199064dc3a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -735,6 +735,7 @@ CONF_REFRESH = "refresh" CONF_RELABEL = "relabel" CONF_REPEAT = "repeat" CONF_REPOSITORY = "repository" +CONF_RESET = "reset" CONF_RESET_DURATION = "reset_duration" CONF_RESET_PIN = "reset_pin" CONF_RESIZE = "resize" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 770e091205..455b404e32 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -160,7 +160,8 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC #elif defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) + defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) #define USE_LOGGER_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG #endif diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index e7d6195915..e2d067390d 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -416,7 +416,9 @@ class LineComment(Statement): self.value = value def __str__(self): - parts = re.sub(r"\\\s*\n", r"\n", self.value, re.MULTILINE).split("\n") + parts = re.sub(r"\\\s*\n", r"\n", self.value, flags=re.MULTILINE).split( + "\n" + ) parts = [f"// {x}" for x in parts] return "\n".join(parts) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index a297885782..529a0815b8 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -601,10 +601,12 @@ class DownloadListRequestHandler(BaseHandler): loop = asyncio.get_running_loop() try: downloads_json = await loop.run_in_executor(None, self._get, configuration) - except vol.Invalid: + except vol.Invalid as exc: + _LOGGER.exception("Error while fetching downloads", exc_info=exc) self.send_error(404) return if downloads_json is None: + _LOGGER.error("Configuration %s not found", configuration) self.send_error(404) return self.set_status(200) @@ -618,14 +620,17 @@ class DownloadListRequestHandler(BaseHandler): if storage_json is None: return None - config = yaml_util.load_yaml(settings.rel_path(configuration)) + try: + config = yaml_util.load_yaml(settings.rel_path(configuration)) - if const.CONF_EXTERNAL_COMPONENTS in config: - from esphome.components.external_components import ( - do_external_components_pass, - ) + if const.CONF_EXTERNAL_COMPONENTS in config: + from esphome.components.external_components import ( + do_external_components_pass, + ) - do_external_components_pass(config) + do_external_components_pass(config) + except vol.Invalid: + _LOGGER.info("Could not parse `external_components`, skipping") from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS diff --git a/pyproject.toml b/pyproject.toml index e783799e58..4ee2d3a390 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==80.8.0", "wheel>=0.43,<0.46"] +requires = ["setuptools==80.9.0", "wheel>=0.43,<0.46"] build-backend = "setuptools.build_meta" [project] diff --git a/requirements.txt b/requirements.txt index af98606369..87319dbba0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ esphome-dashboard==20250514.0 aioesphomeapi==31.1.0 zeroconf==0.147.0 puremagic==1.29 -ruamel.yaml==0.18.10 # dashboard_import +ruamel.yaml==0.18.11 # dashboard_import esphome-glyphsets==0.2.0 pillow==10.4.0 cairosvg==2.8.2 diff --git a/requirements_test.txt b/requirements_test.txt index b1f3355fbd..ebbc933622 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,13 +1,14 @@ pylint==3.3.7 flake8==7.2.0 # also change in .pre-commit-config.yaml when updating ruff==0.11.11 # also change in .pre-commit-config.yaml when updating -pyupgrade==3.19.1 # also change in .pre-commit-config.yaml when updating +pyupgrade==3.20.0 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests pytest==8.3.5 pytest-cov==6.1.1 -pytest-mock==3.14.0 +pytest-mock==3.14.1 pytest-asyncio==0.26.0 +pytest-xdist==3.7.0 asyncmock==0.4.2 hypothesis==6.92.1 diff --git a/script/integration_test b/script/integration_test new file mode 100755 index 0000000000..d637cdd298 --- /dev/null +++ b/script/integration_test @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e + +script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "${script_dir}/.." + +set -x + +pytest -vvs --no-cov --tb=native -n 0 tests/integration/ diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-c3-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..84a9dd4bb4 --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO6 + sda_pin: GPIO7 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-s2-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..63c3bd6afd --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO16 + sda_pin: GPIO17 + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.rp2040-ard.yaml b/tests/components/bme68x_bsec2_i2c/test.rp2040-ard.yaml new file mode 100644 index 0000000000..ee2c29ca4e --- /dev/null +++ b/tests/components/bme68x_bsec2_i2c/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + scl_pin: GPIO5 + sda_pin: GPIO4 + +<<: !include common.yaml diff --git a/tests/components/sx1509/common.yaml b/tests/components/sx1509/common.yaml index a09d850649..a83217e579 100644 --- a/tests/components/sx1509/common.yaml +++ b/tests/components/sx1509/common.yaml @@ -6,6 +6,12 @@ i2c: sx1509: - id: sx1509_hub address: 0x3E + keypad: + key_rows: 2 + key_columns: 2 + keys: abcd + on_key: + - lambda: ESP_LOGD("test", "got key '%c'", x); binary_sensor: - platform: gpio @@ -13,6 +19,11 @@ binary_sensor: pin: sx1509: sx1509_hub number: 3 + - platform: sx1509 + sx1509_id: sx1509_hub + name: "keypadkey_0" + row: 0 + col: 0 switch: - platform: gpio diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 1ea9c73f32..cd02200d0b 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -53,6 +53,8 @@ async def dashboard() -> DashboardTestHelper: assert DASHBOARD.settings.on_ha_addon is True assert DASHBOARD.settings.using_auth is False task = asyncio.create_task(DASHBOARD.async_run()) + # Wait for initial device loading to complete + await DASHBOARD.entries.async_request_update_entries() client = AsyncHTTPClient() io_loop = IOLoop(make_current=False) yield DashboardTestHelper(io_loop, client, port) diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000000..26bd5a00ee --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,80 @@ +# ESPHome Integration Tests + +This directory contains end-to-end integration tests for ESPHome, focusing on testing the complete flow from YAML configuration to running devices with API connections. + +## Structure + +- `conftest.py` - Common fixtures and utilities +- `const.py` - Constants used throughout the integration tests +- `types.py` - Type definitions for fixtures and functions +- `fixtures/` - YAML configuration files for tests +- `test_*.py` - Individual test files + +## How it works + +### Automatic YAML Loading + +The `yaml_config` fixture automatically loads YAML configurations based on the test name: +- It looks for a file named after the test function (e.g., `test_host_mode_basic` → `fixtures/host_mode_basic.yaml`) +- The fixture file must exist or the test will fail with a clear error message +- The fixture automatically injects a dynamic port number into the API configuration + +### Key Fixtures + +- `run_compiled` - Combines write, compile, and run operations into a single context manager +- `api_client_connected` - Creates an API client that automatically connects using ReconnectLogic +- `reserved_tcp_port` - Reserves a TCP port by holding the socket open until ESPHome needs it +- `unused_tcp_port` - Provides the reserved port number for each test + +### Writing Tests + +The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures: + +```python +@pytest.mark.asyncio +async def test_my_feature( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Test your feature using the connected client + device_info = await client.device_info() + assert device_info is not None +``` + +### Creating YAML Fixtures + +Create a YAML file in the `fixtures/` directory with the same name as your test function (without the `test_` prefix): + +```yaml +# fixtures/my_feature.yaml +esphome: + name: my-test-device +host: +api: # Port will be automatically injected +logger: +# Add your components here +``` + +## Running Tests + +```bash +# Run all integration tests +script/integration_test + +# Run a specific test +pytest -vv tests/integration/test_host_mode_basic.py + +# Debug compilation errors or see ESPHome output +pytest -s tests/integration/test_host_mode_basic.py +``` + +## Implementation Details + +- Tests automatically wait for the API port to be available before connecting +- Process cleanup is handled automatically, with graceful shutdown using SIGINT +- Each test gets its own temporary directory and unique port +- Port allocation minimizes race conditions by holding the socket until just before ESPHome starts +- Output from ESPHome processes is displayed for debugging diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000000..0cf87d2169 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,3 @@ +"""ESPHome integration tests.""" + +from __future__ import annotations diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000000..35586519e4 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,426 @@ +"""Common fixtures for integration tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Generator +from contextlib import AbstractAsyncContextManager, asynccontextmanager +import logging +from pathlib import Path +import platform +import signal +import socket +import tempfile + +from aioesphomeapi import APIClient, APIConnectionError, ReconnectLogic +import pytest +import pytest_asyncio + +# Skip all integration tests on Windows +if platform.system() == "Windows": + pytest.skip( + "Integration tests are not supported on Windows", allow_module_level=True + ) + +from .const import ( + API_CONNECTION_TIMEOUT, + DEFAULT_API_PORT, + LOCALHOST, + PORT_POLL_INTERVAL, + PORT_WAIT_TIMEOUT, + SIGINT_TIMEOUT, + SIGTERM_TIMEOUT, +) +from .types import ( + APIClientConnectedFactory, + APIClientFactory, + CompileFunction, + ConfigWriter, + RunCompiledFunction, + RunFunction, +) + + +@pytest.fixture(scope="module", autouse=True) +def enable_aioesphomeapi_debug_logging(): + """Enable debug logging for aioesphomeapi to help diagnose connection issues.""" + # Get the aioesphomeapi logger + logger = logging.getLogger("aioesphomeapi") + # Save the original level + original_level = logger.level + # Set to DEBUG level + logger.setLevel(logging.DEBUG) + # Also ensure we have a handler that outputs to console + if not logger.handlers: + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + yield + # Restore original level + logger.setLevel(original_level) + + +@pytest.fixture +def integration_test_dir() -> Generator[Path]: + """Create a temporary directory for integration tests.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def reserved_tcp_port() -> Generator[tuple[int, socket.socket]]: + """Reserve an unused TCP port by holding the socket open.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", 0)) + port = s.getsockname()[1] + try: + yield port, s + finally: + s.close() + + +@pytest.fixture +def unused_tcp_port(reserved_tcp_port: tuple[int, socket.socket]) -> int: + """Get the reserved TCP port number.""" + return reserved_tcp_port[0] + + +@pytest_asyncio.fixture +async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> str: + """Load YAML configuration based on test name.""" + # Get the test function name + test_name: str = request.node.name + # Extract the base test name (remove test_ prefix and any parametrization) + base_name = test_name.replace("test_", "").partition("[")[0] + + # Load the fixture file + fixture_path = Path(__file__).parent / "fixtures" / f"{base_name}.yaml" + if not fixture_path.exists(): + raise FileNotFoundError(f"Fixture file not found: {fixture_path}") + + loop = asyncio.get_running_loop() + content = await loop.run_in_executor(None, fixture_path.read_text) + + # Replace the port in the config if it contains api section + if "api:" in content: + # Add port configuration after api: + content = content.replace("api:", f"api:\n port: {unused_tcp_port}") + + return content + + +@pytest_asyncio.fixture +async def write_yaml_config( + integration_test_dir: Path, request: pytest.FixtureRequest +) -> AsyncGenerator[ConfigWriter]: + """Write YAML configuration to a file.""" + # Get the test name for default filename + test_name = request.node.name + base_name = test_name.replace("test_", "").split("[")[0] + + async def _write_config(content: str, filename: str | None = None) -> Path: + if filename is None: + filename = f"{base_name}.yaml" + config_path = integration_test_dir / filename + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, config_path.write_text, content) + return config_path + + yield _write_config + + +async def _run_esphome_command( + command: str, + config_path: Path, + cwd: Path, +) -> asyncio.subprocess.Process: + """Run an ESPHome command with the given arguments.""" + return await asyncio.create_subprocess_exec( + "esphome", + command, + str(config_path), + cwd=cwd, + stdout=None, # Inherit stdout + stderr=None, # Inherit stderr + stdin=asyncio.subprocess.DEVNULL, + # Start in a new process group to isolate signal handling + start_new_session=True, + ) + + +@pytest_asyncio.fixture +async def compile_esphome( + integration_test_dir: Path, +) -> AsyncGenerator[CompileFunction]: + """Compile an ESPHome configuration.""" + + async def _compile(config_path: Path) -> None: + proc = await _run_esphome_command("compile", config_path, integration_test_dir) + await proc.wait() + if proc.returncode != 0: + raise RuntimeError( + f"Failed to compile {config_path}, return code: {proc.returncode}. " + f"Run with 'pytest -s' to see compilation output." + ) + + yield _compile + + +@pytest_asyncio.fixture +async def run_esphome_process( + integration_test_dir: Path, +) -> AsyncGenerator[RunFunction]: + """Run an ESPHome process and manage its lifecycle.""" + processes: list[asyncio.subprocess.Process] = [] + + async def _run(config_path: Path) -> asyncio.subprocess.Process: + process = await _run_esphome_command("run", config_path, integration_test_dir) + processes.append(process) + return process + + yield _run + + # Cleanup: terminate all "run" processes gracefully + for process in processes: + if process.returncode is None: + # Send SIGINT (Ctrl+C) for graceful shutdown of the running ESPHome instance + process.send_signal(signal.SIGINT) + try: + await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT) + except asyncio.TimeoutError: + # If SIGINT didn't work, try SIGTERM + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT) + except asyncio.TimeoutError: + # Last resort: SIGKILL + process.kill() + await process.wait() + + +@asynccontextmanager +async def create_api_client( + address: str = LOCALHOST, + port: int = DEFAULT_API_PORT, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", +) -> AsyncGenerator[APIClient]: + """Create an API client context manager.""" + client = APIClient( + address=address, + port=port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + ) + try: + yield client + finally: + await client.disconnect() + + +@pytest_asyncio.fixture +async def api_client_factory( + unused_tcp_port: int, +) -> AsyncGenerator[APIClientFactory]: + """Factory for creating API client context managers.""" + + def _create_client( + address: str = LOCALHOST, + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + ) -> AbstractAsyncContextManager[APIClient]: + return create_api_client( + address=address, + port=port if port is not None else unused_tcp_port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + ) + + yield _create_client + + +@asynccontextmanager +async def wait_and_connect_api_client( + address: str = LOCALHOST, + port: int = DEFAULT_API_PORT, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = API_CONNECTION_TIMEOUT, +) -> AsyncGenerator[APIClient]: + """Wait for API to be available and connect.""" + client = APIClient( + address=address, + port=port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + ) + + # Create a future to signal when connected + loop = asyncio.get_running_loop() + connected_future: asyncio.Future[None] = loop.create_future() + + async def on_connect() -> None: + """Called when successfully connected.""" + if not connected_future.done(): + connected_future.set_result(None) + + async def on_disconnect(expected_disconnect: bool) -> None: + """Called when disconnected.""" + if not connected_future.done() and not expected_disconnect: + connected_future.set_exception( + APIConnectionError("Disconnected before fully connected") + ) + + async def on_connect_error(err: Exception) -> None: + """Called when connection fails.""" + if not connected_future.done(): + connected_future.set_exception(err) + + # Create and start the reconnect logic + reconnect_logic = ReconnectLogic( + client=client, + on_connect=on_connect, + on_disconnect=on_disconnect, + zeroconf_instance=None, # Not using zeroconf for integration tests + name=f"{address}:{port}", + on_connect_error=on_connect_error, + ) + + try: + # Start the connection + await reconnect_logic.start() + + # Wait for connection with timeout + try: + await asyncio.wait_for(connected_future, timeout=timeout) + except asyncio.TimeoutError: + raise TimeoutError(f"Failed to connect to API after {timeout} seconds") + + yield client + finally: + # Stop reconnect logic and disconnect + await reconnect_logic.stop() + await client.disconnect() + + +@pytest_asyncio.fixture +async def api_client_connected( + unused_tcp_port: int, +) -> AsyncGenerator[APIClientConnectedFactory]: + """Factory for creating connected API client context managers.""" + + def _connect_client( + address: str = LOCALHOST, + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = API_CONNECTION_TIMEOUT, + ) -> AbstractAsyncContextManager[APIClient]: + return wait_and_connect_api_client( + address=address, + port=port if port is not None else unused_tcp_port, + password=password, + noise_psk=noise_psk, + client_info=client_info, + timeout=timeout, + ) + + yield _connect_client + + +async def wait_for_port_open( + host: str, port: int, timeout: float = PORT_WAIT_TIMEOUT +) -> None: + """Wait for a TCP port to be open and accepting connections.""" + loop = asyncio.get_running_loop() + start_time = loop.time() + + # Small yield to ensure the process has a chance to start + await asyncio.sleep(0) + + while loop.time() - start_time < timeout: + try: + # Try to connect to the port + _, writer = await asyncio.open_connection(host, port) + writer.close() + await writer.wait_closed() + return # Port is open + except (ConnectionRefusedError, OSError): + # Port not open yet, wait a bit and try again + await asyncio.sleep(PORT_POLL_INTERVAL) + + raise TimeoutError(f"Port {port} on {host} did not open within {timeout} seconds") + + +@asynccontextmanager +async def run_compiled_context( + yaml_content: str, + filename: str | None, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + run_esphome_process: RunFunction, + port: int, + port_socket: socket.socket | None = None, +) -> AsyncGenerator[asyncio.subprocess.Process]: + """Context manager to write, compile and run an ESPHome configuration.""" + # Write the YAML config + config_path = await write_yaml_config(yaml_content, filename) + + # Compile the configuration + await compile_esphome(config_path) + + # Close the port socket right before running to release the port + if port_socket is not None: + port_socket.close() + + # Run the ESPHome device + process = await run_esphome_process(config_path) + assert process.returncode is None, "Process died immediately" + + # Wait for the API server to start listening + await wait_for_port_open(LOCALHOST, port, timeout=PORT_WAIT_TIMEOUT) + + try: + yield process + finally: + # Process cleanup is handled by run_esphome_process fixture + pass + + +@pytest_asyncio.fixture +async def run_compiled( + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + run_esphome_process: RunFunction, + reserved_tcp_port: tuple[int, socket.socket], +) -> AsyncGenerator[RunCompiledFunction]: + """Write, compile and run an ESPHome configuration.""" + port, port_socket = reserved_tcp_port + + def _run_compiled( + yaml_content: str, filename: str | None = None + ) -> AbstractAsyncContextManager[asyncio.subprocess.Process]: + return run_compiled_context( + yaml_content, + filename, + write_yaml_config, + compile_esphome, + run_esphome_process, + port, + port_socket, + ) + + yield _run_compiled diff --git a/tests/integration/const.py b/tests/integration/const.py new file mode 100644 index 0000000000..6876bbd443 --- /dev/null +++ b/tests/integration/const.py @@ -0,0 +1,14 @@ +"""Constants for integration tests.""" + +# Network constants +DEFAULT_API_PORT = 6053 +LOCALHOST = "127.0.0.1" + +# Timeout constants +API_CONNECTION_TIMEOUT = 30.0 # seconds +PORT_WAIT_TIMEOUT = 30.0 # seconds +PORT_POLL_INTERVAL = 0.1 # seconds + +# Process shutdown timeouts +SIGINT_TIMEOUT = 5.0 # seconds +SIGTERM_TIMEOUT = 2.0 # seconds diff --git a/tests/integration/fixtures/host_mode_basic.yaml b/tests/integration/fixtures/host_mode_basic.yaml new file mode 100644 index 0000000000..9bcda57f4f --- /dev/null +++ b/tests/integration/fixtures/host_mode_basic.yaml @@ -0,0 +1,5 @@ +esphome: + name: host-test +host: +api: +logger: diff --git a/tests/integration/fixtures/host_mode_noise_encryption.yaml b/tests/integration/fixtures/host_mode_noise_encryption.yaml new file mode 100644 index 0000000000..83605e28a3 --- /dev/null +++ b/tests/integration/fixtures/host_mode_noise_encryption.yaml @@ -0,0 +1,7 @@ +esphome: + name: host-noise-test +host: +api: + encryption: + key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU= +logger: diff --git a/tests/integration/fixtures/host_mode_noise_encryption_wrong_key.yaml b/tests/integration/fixtures/host_mode_noise_encryption_wrong_key.yaml new file mode 100644 index 0000000000..83605e28a3 --- /dev/null +++ b/tests/integration/fixtures/host_mode_noise_encryption_wrong_key.yaml @@ -0,0 +1,7 @@ +esphome: + name: host-noise-test +host: +api: + encryption: + key: N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU= +logger: diff --git a/tests/integration/fixtures/host_mode_reconnect.yaml b/tests/integration/fixtures/host_mode_reconnect.yaml new file mode 100644 index 0000000000..f240e4b2fe --- /dev/null +++ b/tests/integration/fixtures/host_mode_reconnect.yaml @@ -0,0 +1,5 @@ +esphome: + name: host-reconnect-test +host: +api: +logger: diff --git a/tests/integration/fixtures/host_mode_with_sensor.yaml b/tests/integration/fixtures/host_mode_with_sensor.yaml new file mode 100644 index 0000000000..fecd0b435b --- /dev/null +++ b/tests/integration/fixtures/host_mode_with_sensor.yaml @@ -0,0 +1,12 @@ +esphome: + name: host-sensor-test +host: +api: +logger: +sensor: + - platform: template + name: Test Sensor + id: test_sensor + unit_of_measurement: °C + lambda: return 42.0; + update_interval: 0.1s diff --git a/tests/integration/test_host_mode_basic.py b/tests/integration/test_host_mode_basic.py new file mode 100644 index 0000000000..fd52979784 --- /dev/null +++ b/tests/integration/test_host_mode_basic.py @@ -0,0 +1,22 @@ +"""Basic integration test for Host mode.""" + +from __future__ import annotations + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_basic( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test basic Host mode functionality with API connection.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-test" diff --git a/tests/integration/test_host_mode_noise_encryption.py b/tests/integration/test_host_mode_noise_encryption.py new file mode 100644 index 0000000000..53873f2760 --- /dev/null +++ b/tests/integration/test_host_mode_noise_encryption.py @@ -0,0 +1,53 @@ +"""Integration test for Host mode with noise encryption.""" + +from __future__ import annotations + +from aioesphomeapi import InvalidEncryptionKeyAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The API key for noise encryption +NOISE_KEY = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" + + +@pytest.mark.asyncio +async def test_host_mode_noise_encryption( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test Host mode with noise encryption enabled.""" + # Write, compile and run the ESPHome device, then connect to API + # The API client should handle noise encryption automatically + async with ( + run_compiled(yaml_config), + api_client_connected(noise_psk=NOISE_KEY) as client, + ): + # If we can get device info, the encryption is working + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-noise-test" + + # List entities to ensure the encrypted connection is fully functional + entities = await client.list_entities_services() + assert entities is not None + + +@pytest.mark.asyncio +async def test_host_mode_noise_encryption_wrong_key( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that connection fails with wrong encryption key.""" + # Write, compile and run the ESPHome device + async with run_compiled(yaml_config): + # Try to connect with wrong key - should fail with InvalidEncryptionKeyAPIError + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected( + noise_psk="wrong_key_that_should_not_work", + timeout=5, # Shorter timeout for expected failure + ) as client: + # This should not be reached + await client.device_info() diff --git a/tests/integration/test_host_mode_reconnect.py b/tests/integration/test_host_mode_reconnect.py new file mode 100644 index 0000000000..8f69193559 --- /dev/null +++ b/tests/integration/test_host_mode_reconnect.py @@ -0,0 +1,28 @@ +"""Integration test for Host mode reconnection.""" + +from __future__ import annotations + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_reconnect( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test reconnecting to a Host mode device.""" + # Write, compile and run the ESPHome device + async with run_compiled(yaml_config): + # First connection + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info is not None + + # Reconnect with a new client + async with api_client_connected() as client2: + device_info2 = await client2.device_info() + assert device_info2 is not None + assert device_info2.name == device_info.name diff --git a/tests/integration/test_host_mode_sensor.py b/tests/integration/test_host_mode_sensor.py new file mode 100644 index 0000000000..f0c938da1c --- /dev/null +++ b/tests/integration/test_host_mode_sensor.py @@ -0,0 +1,49 @@ +"""Integration test for Host mode with sensor.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_host_mode_with_sensor( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test Host mode with a sensor component.""" + # Write, compile and run the ESPHome device, then connect to API + async with run_compiled(yaml_config), api_client_connected() as client: + # Subscribe to state changes + states: dict[int, EntityState] = {} + sensor_future: asyncio.Future[EntityState] = asyncio.Future() + + def on_state(state: EntityState) -> None: + states[state.key] = state + # If this is our sensor with value 42.0, resolve the future + if ( + hasattr(state, "state") + and state.state == 42.0 + and not sensor_future.done() + ): + sensor_future.set_result(state) + + client.subscribe_states(on_state) + + # Wait for sensor with specific value (42.0) with timeout + try: + test_sensor_state = await asyncio.wait_for(sensor_future, timeout=5.0) + except asyncio.TimeoutError: + pytest.fail( + f"Sensor with value 42.0 not received within 5 seconds. " + f"Received states: {list(states.values())}" + ) + + # Verify the sensor state + assert test_sensor_state.state == 42.0 + assert len(states) > 0, "No states received" diff --git a/tests/integration/types.py b/tests/integration/types.py new file mode 100644 index 0000000000..ef1af2add8 --- /dev/null +++ b/tests/integration/types.py @@ -0,0 +1,46 @@ +"""Type definitions for integration tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextlib import AbstractAsyncContextManager +from pathlib import Path +from typing import Protocol + +from aioesphomeapi import APIClient + +ConfigWriter = Callable[[str, str | None], Awaitable[Path]] +CompileFunction = Callable[[Path], Awaitable[None]] +RunFunction = Callable[[Path], Awaitable[asyncio.subprocess.Process]] +RunCompiledFunction = Callable[ + [str, str | None], AbstractAsyncContextManager[asyncio.subprocess.Process] +] +WaitFunction = Callable[[APIClient, float], Awaitable[bool]] + + +class APIClientFactory(Protocol): + """Protocol for API client factory.""" + + def __call__( # noqa: E704 + self, + address: str = "localhost", + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + ) -> AbstractAsyncContextManager[APIClient]: ... + + +class APIClientConnectedFactory(Protocol): + """Protocol for connected API client factory.""" + + def __call__( # noqa: E704 + self, + address: str = "localhost", + port: int | None = None, + password: str = "", + noise_psk: str | None = None, + client_info: str = "integration-test", + timeout: float = 30, + ) -> AbstractAsyncContextManager[APIClient]: ...