diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 0b871af30cd..22e3d9320b0 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -250,6 +250,42 @@ def add_metadata( ) +KEY_EXTERNAL_WRITERS = "external_writers" + + +def _get_external_writer_list() -> list[tuple[ID, str]]: + """Get the raw external writer list. Each entry is (display id, owner).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_EXTERNAL_WRITERS, []) + + +def register_external_writer(display_id: ID, owner: str) -> None: + """Declare that ``owner`` draws into the display from outside its lambda or pages. + + Call from the owner's CONFIG_SCHEMA. The ID may still be unresolved; the ID pass + resolves it in place before final validation. + """ + _get_external_writer_list().append((display_id, owner)) + + +def get_external_writers(display_id: ID) -> list[str]: + """Get the owners registered for a display. Only valid once IDs are resolved.""" + return [ + owner + for id_, owner in _get_external_writer_list() + if id_.id is not None and id_.id == display_id.id + ] + + +def has_external_writer(display_id: ID) -> bool: + """Tell a driver's final validation that something else draws into the display. + + LVGL anywhere in the config also counts, as the drivers assumed before. + """ + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + return LVGL_DOMAIN in full_config.get() or bool(get_external_writers(display_id)) + + async def register_display(var, config): await cg.register_component(var, config) await setup_display_core_(var, config) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 487e8e5ef40..06aa53acb69 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -40,7 +40,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from . import models @@ -198,15 +197,11 @@ def _final_validate(config) -> None: "epaper_spi", require_miso=False, require_mosi=True )(config) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: - # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 02978ae0f7a..87cb3345b08 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -131,6 +131,7 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { void ESP32Preferences::open() { // Runs from app_main() before the logger is initialized; any logging here // must be deferred. See s_open_err and make_preference() below. + // Components that need NVS check nvs_handle instead of initializing it again. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 11290a95f41..1379584120b 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #ifndef CONFIG_BT_CONTROLLER_DISABLED #include @@ -28,7 +29,6 @@ extern "C" { #include #include #include -#include #ifdef USE_ARDUINO // Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c). @@ -77,8 +77,9 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; void ESP32BLE::setup() { global_ble = this; - if (!ble_pre_setup_()) { - ESP_LOGE(TAG, "BLE could not be prepared for configuration"); + // Preferences opens NVS in app_main(); a zero handle means it could not be opened, even after an erase + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); this->mark_failed(); return; } @@ -185,15 +186,6 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { } #endif -bool ESP32BLE::ble_pre_setup_() { - esp_err_t err = nvs_flash_init(); - if (err != ESP_OK) { - ESP_LOGE(TAG, "nvs_flash_init failed: %d", err); - return false; - } - return true; -} - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_init_() { if (this->advertising_ != nullptr) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 950ae6e0bc1..ef326e1532d 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -185,7 +185,6 @@ class ESP32BLE final : public Component { } this->ble_events_.get_and_reset_dropped_count(); } - bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); #endif diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 7481d2c612c..430393f2d81 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include "esphome/core/hal.h" diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 650a765b6a5..0dec0a74c5d 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #ifndef CONFIG_BT_CONTROLLER_DISABLED diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8d3148c2fc7..cd1c16e8f5d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #ifdef USE_OTA diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index ae299370cd7..f8f76b2d578 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -47,6 +47,7 @@ CanMode = esp32_can_ns.enum("CanMode") CAN_MODES = { "NORMAL": CanMode.CAN_MODE_NORMAL, "LISTENONLY": CanMode.CAN_MODE_LISTEN_ONLY, + "NO_ACK": CanMode.CAN_MODE_NO_ACK, } # Currently the driver only supports a subset of the bit rates defined in canbus diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index d84b58c5b0c..ee677a08005 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -79,10 +79,13 @@ bool ESP32Can::setup_internal() { } // Select TWAI mode based on configuration - twai_mode_t twai_mode = (this->mode_ == CAN_MODE_LISTEN_ONLY) ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL; - + twai_mode_t twai_mode = TWAI_MODE_NORMAL; if (this->mode_ == CAN_MODE_LISTEN_ONLY) { + twai_mode = TWAI_MODE_LISTEN_ONLY; ESP_LOGI(TAG, "CAN bus configured in LISTEN_ONLY mode (passive, no ACKs)"); + } else if (this->mode_ == CAN_MODE_NO_ACK) { + twai_mode = TWAI_MODE_NO_ACK; + ESP_LOGI(TAG, "CAN bus configured in NO_ACK mode (transmit without requiring ACK)"); } twai_general_config_t g_config = diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f9549bea346..176a3b57f5e 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -12,6 +12,7 @@ namespace esphome::esp32_can { enum CanMode : uint8_t { CAN_MODE_NORMAL = 0, CAN_MODE_LISTEN_ONLY = 1, + CAN_MODE_NO_ACK = 2, }; class ESP32Can final : public canbus::Canbus { diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 2583839ca81..42ef79d31d2 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,8 +126,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = await climate_ir.new_climate_ir(config) - cg.add(var.set_protocol(config[CONF_PROTOCOL])) + tag = heatpumpir_ns.ProtocolTag.template(config[CONF_PROTOCOL]) + var = await climate_ir.new_climate_ir(config, tag()) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index b4597fe4abf..a23d69e3b22 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -5,7 +5,6 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) #include -#include #include #include #include "esphome/components/remote_base/remote_base.h" @@ -45,71 +44,76 @@ class IRSenderESPHome : public IRSender { ESPHOME_LOG_TAG(TAG, "heatpumpir.climate"); -const std::map> PROTOCOL_CONSTRUCTOR_MAP = { - {PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BALLU, []() { return new BalluHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_MCA, []() { return new CarrierMCAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_NQV, []() { return new CarrierNQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC417, []() { return new DaikinHeatpumpARC417IR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC480, []() { return new DaikinHeatpumpARC480A14IR(); }}, // NOLINT - {PROTOCOL_DAIKIN, []() { return new DaikinHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ELECTROLUXYAL, []() { return new ElectroluxYALHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUEGO, []() { return new FuegoHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUJITSU_AWYZ, []() { return new FujitsuHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREE, []() { return new GreeGenericHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAA, []() { return new GreeYAAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAN, []() { return new GreeYANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAC, []() { return new GreeYACHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYT, []() { return new GreeYTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAP, []() { return new GreeYAPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HISENSE_AUD, []() { return new HisenseHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HITACHI, []() { return new HitachiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HYUNDAI, []() { return new HyundaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_IVT, []() { return new IVTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MIDEA, []() { return new MideaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FA, []() { return new MitsubishiFAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FD, []() { return new MitsubishiFDHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FE, []() { return new MitsubishiFEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_FDTC, []() { return new MitsubishiHeavyFDTCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZJ, []() { return new MitsubishiHeavyZJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZM, []() { return new MitsubishiHeavyZMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZMP, []() { return new MitsubishiHeavyZMPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_KJ, []() { return new MitsubishiKJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSC, []() { return new MitsubishiMSCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSY, []() { return new MitsubishiMSYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_SEZ, []() { return new MitsubishiSEZKDXXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_CKP, []() { return new PanasonicCKPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_DKE, []() { return new PanasonicDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_EKE, []() { return new PanasonicEKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_JKE, []() { return new PanasonicJKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_LKE, []() { return new PanasonicLKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_NKE, []() { return new PanasonicNKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV, []() { return new SamsungAQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_FJM, []() { return new SamsungFJMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SHARP, []() { return new SharpHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA_DAISEIKAI, []() { return new ToshibaDaiseikaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA, []() { return new ToshibaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHLT01, []() { return new ZHLT01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_NIBE, []() { return new NibeHeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_1, []() { return new Qlima1HeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_2, []() { return new Qlima2HeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV12MSAN, []() { return new SamsungAQV12MSANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHJG01, []() { return new ZHJG01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_AIRWAY, []() { return new AIRWAYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BGH_AUD, []() { return new BGHHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_ALTDKE, []() { return new PanasonicAltDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PHILCO_PHS32, []() { return new PhilcoPHS32HeatpumpIR(); }}, // NOLINT - {PROTOCOL_VAILLANTVAI8, []() { return new VaillantHeatpumpIR(); }}, // NOLINT - {PROTOCOL_R51M, []() { return new R51MHeatpumpIR(); }}, // NOLINT -}; +template struct ProtocolClass; + +template HeatpumpIR *create_heatpump_ir() { + return new typename ProtocolClass

::type(); // NOLINT +} + +// Codegen calls only the configured protocols, so the linker drops the other classes +#define HEATPUMPIR_PROTOCOL(protocol, cls) \ + template<> struct ProtocolClass { using type = cls; }; \ + template HeatpumpIR *create_heatpump_ir(); + +HEATPUMPIR_PROTOCOL(PROTOCOL_AUX, AUXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BALLU, BalluHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_MCA, CarrierMCAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_NQV, CarrierNQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC417, DaikinHeatpumpARC417IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC480, DaikinHeatpumpARC480A14IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN, DaikinHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ELECTROLUXYAL, ElectroluxYALHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUEGO, FuegoHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUJITSU_AWYZ, FujitsuHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREE, GreeGenericHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAA, GreeYAAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAN, GreeYANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAC, GreeYACHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYT, GreeYTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAP, GreeYAPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HISENSE_AUD, HisenseHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HITACHI, HitachiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HYUNDAI, HyundaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_IVT, IVTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MIDEA, MideaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FA, MitsubishiFAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FD, MitsubishiFDHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FE, MitsubishiFEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_FDTC, MitsubishiHeavyFDTCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZJ, MitsubishiHeavyZJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZM, MitsubishiHeavyZMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZMP, MitsubishiHeavyZMPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_KJ, MitsubishiKJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSC, MitsubishiMSCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSY, MitsubishiMSYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_SEZ, MitsubishiSEZKDXXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_CKP, PanasonicCKPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_DKE, PanasonicDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_EKE, PanasonicEKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_JKE, PanasonicJKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_LKE, PanasonicLKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_NKE, PanasonicNKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV, SamsungAQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_FJM, SamsungFJMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SHARP, SharpHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA_DAISEIKAI, ToshibaDaiseikaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA, ToshibaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHLT01, ZHLT01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_NIBE, NibeHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_1, Qlima1HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_2, Qlima2HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV12MSAN, SamsungAQV12MSANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHJG01, ZHJG01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_AIRWAY, AIRWAYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BGH_AUD, BGHHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_ALTDKE, PanasonicAltDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PHILCO_PHS32, PhilcoPHS32HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_VAILLANTVAI8, VaillantHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_R51M, R51MHeatpumpIR) + +#undef HEATPUMPIR_PROTOCOL void HeatpumpIRClimate::setup() { - auto protocol_constructor = PROTOCOL_CONSTRUCTOR_MAP.find(protocol_); - if (protocol_constructor == PROTOCOL_CONSTRUCTOR_MAP.end()) { - ESP_LOGE(TAG, "Invalid protocol"); - return; - } - this->heatpump_ir_ = protocol_constructor->second(); climate_ir::ClimateIR::setup(); if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index d00fa91d05a..dfa5c40366a 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -89,25 +89,21 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 5, }; +/// Creates the HeatpumpIR library class for a protocol; instantiated in heatpumpir.cpp. +template HeatpumpIR *create_heatpump_ir(); + +/// Selects the protocol at compile time so the enum and the library object always match. +template struct ProtocolTag {}; + // Temperature const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: - HeatpumpIRClimate() - : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, - climate::CLIMATE_FAN_AUTO}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} + template + explicit HeatpumpIRClimate(ProtocolTag

/*tag*/) : HeatpumpIRClimate(P, create_heatpump_ir

()) {} void setup() override; - void set_protocol(Protocol protocol) { - this->protocol_ = protocol; - if (this->has_fan_byte_presets_()) { - this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; - } - } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -119,6 +115,18 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { void set_min_temperature(float temperature) { this->min_temperature_ = temperature; } protected: + HeatpumpIRClimate(Protocol protocol, HeatpumpIR *heatpump_ir) + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}), + heatpump_ir_(heatpump_ir), + protocol_(protocol) { + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; + } + } HeatpumpIR *heatpump_ir_; void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 76fbadd1968..d9c80ebf43d 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -37,7 +37,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] @@ -348,11 +347,8 @@ def _final_validate(config: ConfigType) -> None: config ) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: diff --git a/esphome/components/socket/tcp_client_link.h b/esphome/components/socket/tcp_client_link.h index f77ee050555..517a83375df 100644 --- a/esphome/components/socket/tcp_client_link.h +++ b/esphome/components/socket/tcp_client_link.h @@ -61,6 +61,8 @@ class TcpClientLink { void close(); bool connected() const { return this->connected_; } + /// A connect attempt is in flight. + bool connecting() const { return this->sock_ != nullptr && !this->connected_; } bool ready() const { return this->sock_ != nullptr && this->sock_->ready(); } /// Shared retry clock, also usable for a listen socket. void note_attempt() { this->last_attempt_ms_ = App.get_loop_component_start_time(); } diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 68c0d005b57..29bbdc2b0b5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -5,7 +5,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_check.h" -#include "nvs_flash.h" #include "zigbee_attribute_esp32.h" #include "zigbee_esp32.h" #include "esphome/core/application.h" diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b5e2ec17e8a..261d8b50e36 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1120,8 +1120,12 @@ def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> An res = parse_yaml(fname, f_handle, track_document_range=track_document_range) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err - # Top-level !include returns a deferred IncludeFile; resolve it so - # callers always receive the final content. + return res + + +def _resolve_include(res: Any) -> Any: + # A document that is just `!include` parses to a deferred IncludeFile; resolve it so + # every parse_yaml caller, including the VS Code and ACE loaders, gets the final content. if isinstance(res, IncludeFile): res = res.load() if res.should_load() else {} return res @@ -1147,7 +1151,7 @@ def parse_yaml( # tracked mode; reject the combination instead of half-applying it. raise ValueError("track_document_range=False requires the default yaml_loader") try: - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomeLoader, file_name, file_handle, @@ -1159,13 +1163,15 @@ def parse_yaml( # readable exceptions # Rewind the stream so we can try again file_handle.seek(0, 0) - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomePurePythonLoader, file_name, file_handle, yaml_loader, track_document_range=track_document_range, ) + # Resolved after the fallback so a failed first parse never loads the include twice + return _resolve_include(res) def _load_yaml_internal_with_type( diff --git a/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml new file mode 100644 index 00000000000..73d993fb290 --- /dev/null +++ b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: epaper_spi + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: diff --git a/tests/component_tests/display/config/it8951_owner_auto_id.yaml b/tests/component_tests/display/config/it8951_owner_auto_id.yaml new file mode 100644 index 00000000000..ed6b0fd8994 --- /dev/null +++ b/tests/component_tests/display/config/it8951_owner_auto_id.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + model: m5stack-m5paper + +fake_display_writer: diff --git a/tests/component_tests/display/config/no_owner.yaml b/tests/component_tests/display/config/no_owner.yaml new file mode 100644 index 00000000000..4a4714779a2 --- /dev/null +++ b/tests/component_tests/display/config/no_owner.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 diff --git a/tests/component_tests/display/config/owner_by_id.yaml b/tests/component_tests/display/config/owner_by_id.yaml new file mode 100644 index 00000000000..e3be24deffe --- /dev/null +++ b/tests/component_tests/display/config/owner_by_id.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: + display_id: epaper_display diff --git a/tests/component_tests/display/external_components/fake_display_writer/__init__.py b/tests/component_tests/display/external_components/fake_display_writer/__init__.py new file mode 100644 index 00000000000..5e1388578fa --- /dev/null +++ b/tests/component_tests/display/external_components/fake_display_writer/__init__.py @@ -0,0 +1,20 @@ +"""External component that draws into a display from outside its lambda or pages.""" + +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["display"] +DOMAIN = "fake_display_writer" + + +def _register_writer(config: ConfigType) -> ConfigType: + display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display)}), + _register_writer, +) diff --git a/tests/component_tests/display/test_external_writer.py b/tests/component_tests/display/test_external_writer.py new file mode 100644 index 00000000000..c51bf15b579 --- /dev/null +++ b/tests/component_tests/display/test_external_writer.py @@ -0,0 +1,98 @@ +"""Tests for the display external writer registry.""" + +from collections.abc import Callable, Generator +from pathlib import Path +import sys + +import pytest + +from esphome import loader +from esphome.components.display import ( + Display, + get_external_writers, + has_external_writer, + register_external_writer, +) +from esphome.core import ID +from esphome.final_validate import full_config + +TEST_CARD = "show_test_card()" +UPDATE_NEVER = "set_update_interval(4294967295UL)" + + +@pytest.fixture +def unload_fake_writer() -> Generator[None]: + """Drop the fake external component so later tests start clean.""" + yield + loader._COMPONENT_CACHE.pop("fake_display_writer", None) + sys.modules.pop("esphome.components.fake_display_writer", None) + loader.clear_component_meta_finders() + + +def test_unresolved_id_matches_after_resolution() -> None: + """An auto-picked ID registered during schema validation matches once resolved.""" + pending = ID(None, is_declaration=False, type=Display) + register_external_writer(pending, "owner") + declared = ID("epd", is_declaration=True, type=Display) + + assert get_external_writers(declared) == [] + assert has_external_writer(declared) is False + + pending.id = "epd" + assert get_external_writers(declared) == ["owner"] + assert has_external_writer(declared) is True + assert get_external_writers(ID("other", is_declaration=True, type=Display)) == [] + + +def test_all_owners_returned() -> None: + register_external_writer(ID("epd"), "first") + register_external_writer(ID("epd"), "second") + register_external_writer(ID("other"), "third") + + assert get_external_writers(ID("epd")) == ["first", "second"] + + +def test_lvgl_counts_as_external_writer() -> None: + assert has_external_writer(ID("epd")) is False + full_config.get()["lvgl"] = [] + assert has_external_writer(ID("epd")) is True + + +def test_drivers_show_test_card_without_owner( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("no_owner.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" in main_cpp + assert UPDATE_NEVER not in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +@pytest.mark.parametrize( + "config_file", ["it8951_owner_auto_id.yaml", "epaper_spi_owner_auto_id.yaml"] +) +def test_owned_display_has_no_test_card( + config_file: str, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The owner picks the display ID automatically, so it resolves after the schema runs.""" + main_cpp = generate_main(component_config_path(config_file)) + + assert TEST_CARD not in main_cpp + assert UPDATE_NEVER in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +def test_only_owned_display_loses_test_card( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("owner_by_id.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" not in main_cpp + assert f"epaper_display->{UPDATE_NEVER}" in main_cpp + assert f"it8951_display->{UPDATE_NEVER}" not in main_cpp diff --git a/tests/components/bmi270/test.esp8266-ard.yaml b/tests/components/bmi270/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7163710d74b --- /dev/null +++ b/tests/components/bmi270/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bmi270: !include common.yaml diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index a26c5d35a34..01b208995df 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -18,7 +18,7 @@ canbus: tx_pin: ${tx_pin} can_id: 4 bit_rate: 50kbps - mode: NORMAL + mode: NO_ACK on_frame: - can_id: 500 then: diff --git a/tests/components/socket/test_tcp_client_link_host.cpp b/tests/components/socket/test_tcp_client_link_host.cpp index 98be8190b00..a5be9225e52 100644 --- a/tests/components/socket/test_tcp_client_link_host.cpp +++ b/tests/components/socket/test_tcp_client_link_host.cpp @@ -102,6 +102,19 @@ TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) { EXPECT_TRUE(this->link_.in_backoff()); } +TEST_F(TcpClientLinkClock, ConnectingUntilThePendingAttemptEnds) { + this->link_.begin("link_test"); + EXPECT_FALSE(this->link_.connecting()); + this->set_pending_socket(); + this->link_.note_attempt(); + this->link_.poll(); + EXPECT_TRUE(this->link_.connecting()); + set_loop_time(1000 + 10000); + this->link_.poll(); + EXPECT_FALSE(this->link_.connecting()); + EXPECT_FALSE(this->link_.connected()); +} + TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) { this->link_.set_reconnect_interval(20000); this->link_.begin("link_test"); @@ -177,6 +190,7 @@ TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) { TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) { LinkPeer p; ASSERT_TRUE(p.link_.connected()); + EXPECT_FALSE(p.link_.connecting()); EXPECT_EQ(p.link_.queue(reinterpret_cast("ping"), 4), 4u); EXPECT_TRUE(p.link_.flush_tx()); char buf[8]; diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c3276823ecc..655fac6bf26 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -954,6 +954,23 @@ def test_yaml_merge_chain_include_resolves() -> None: assert config["base"]["existing"] == "value" +def test_parse_yaml_custom_loader_resolves_root_include() -> None: + """A document that is only `!include` is resolved for a custom loader too.""" + parent = Path("/fake/main.yaml") + loader_calls: list[Path] = [] + + def custom_loader(fname: Path) -> dict[str, int]: + loader_calls.append(fname) + return {"x": 1} + + config = yaml_util.parse_yaml( + parent, io.StringIO("!include inner.yaml\n"), custom_loader + ) + + assert config == {"x": 1} + assert loader_calls == [Path("/fake/inner.yaml")] + + def test_yaml_merge_chain_include_depth_exceeded() -> None: """Chain includes in merge keys exceeding depth limit raise a clear error.""" parent = Path("/fake/main.yaml")