diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 965e23870d..ab7a750388 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e2bfe09ce..f4729f211c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.6 + rev: v0.15.8 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index afe4cdb871..8d297d7b07 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -92,6 +92,7 @@ esphome/components/bmp3xx_i2c/* @latonita esphome/components/bmp3xx_spi/* @latonita esphome/components/bmp581_base/* @danielkent-net @kahrendt esphome/components/bmp581_i2c/* @danielkent-net @kahrendt +esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid esphome/components/bthome_mithermometer/* @nagyrobi diff --git a/esphome/automation.py b/esphome/automation.py index 17966dc782..7b1d6ceca1 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -137,6 +137,9 @@ UpdateComponentAction = cg.esphome_ns.class_("UpdateComponentAction", Action) SuspendComponentAction = cg.esphome_ns.class_("SuspendComponentAction", Action) ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action) Automation = cg.esphome_ns.class_("Automation") +TriggerForwarder = cg.esphome_ns.class_("TriggerForwarder") +TriggerOnTrueForwarder = cg.esphome_ns.class_("TriggerOnTrueForwarder") +TriggerOnFalseForwarder = cg.esphome_ns.class_("TriggerOnFalseForwarder") LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition) StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition) @@ -661,3 +664,44 @@ async def build_automation( actions = await build_action_list(config[CONF_THEN], templ, args) cg.add(obj.add_actions(actions)) return obj + + +async def build_callback_automation( + parent: MockObj, + callback_method: str, + args: TemplateArgsType, + config: ConfigType, + forwarder: MockObj | MockObjClass | None = None, +) -> None: + """Build an Automation and register it as a callback on the parent. + + Eliminates the need for a Trigger wrapper object by registering the + automation's trigger() directly as a callback on the parent component. + + Uses template forwarder structs so the compiler deduplicates the operator() + body across all call sites with the same signature. The forwarder must be + pointer-sized (single Automation* field) to fit inline in Callback::ctx_ + and avoid heap allocation. + + :param parent: The component object (e.g., button, sensor). + :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). + :param args: Automation template args as list of (type, name) tuples. + :param config: The automation config dict. + :param forwarder: Optional forwarder type to use instead of the default + TriggerForwarder. Pass any struct type whose aggregate init takes + a single Automation pointer (e.g., TriggerOnTrueForwarder). + """ + arg_types = [arg[0] for arg in args] + templ = cg.TemplateArguments(*arg_types) + obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ) + actions = await build_action_list(config[CONF_THEN], templ, args) + cg.add(obj.add_actions(actions)) + # Use template forwarder structs for deduplication. The compiler generates + # one operator() per forwarder type; different automation pointers are just + # data in the struct. + if forwarder is None: + forwarder = TriggerForwarder.template(templ) + # RawExpression for aggregate init — both forwarder and obj are codegen + # MockObjs (not user input), and there's no Expression type for positional + # aggregate initialization (StructInitializer uses named fields). + cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}"))) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 37cccc01be..4705f1675d 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -120,10 +120,6 @@ BinarySensorInitiallyOff = binary_sensor_ns.class_( BinarySensorPtr = BinarySensor.operator("ptr") # Triggers -PressTrigger = binary_sensor_ns.class_("PressTrigger", automation.Trigger.template()) -ReleaseTrigger = binary_sensor_ns.class_( - "ReleaseTrigger", automation.Trigger.template() -) ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.template()) DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() @@ -132,13 +128,6 @@ MultiClickTrigger = binary_sensor_ns.class_( "MultiClickTrigger", automation.Trigger.template(), cg.Component ) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -StateTrigger = binary_sensor_ns.class_( - "StateTrigger", automation.Trigger.template(bool) -) -StateChangeTrigger = binary_sensor_ns.class_( - "StateChangeTrigger", - automation.Trigger.template(cg.optional.template(bool), cg.optional.template(bool)), -) BinarySensorPublishAction = binary_sensor_ns.class_( "BinarySensorPublishAction", automation.Action @@ -458,16 +447,8 @@ _BINARY_SENSOR_SCHEMA = ( ): cv.boolean, cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, - cv.Optional(CONF_ON_PRESS): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PressTrigger), - } - ), - cv.Optional(CONF_ON_RELEASE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ReleaseTrigger), - } - ), + cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), + cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( automation.validate_automation( { @@ -509,16 +490,8 @@ _BINARY_SENSOR_SCHEMA = ( ): cv.positive_time_period_milliseconds, } ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger), - } - ), - cv.Optional(CONF_ON_STATE_CHANGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateChangeTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_STATE_CHANGE): automation.validate_automation({}), } ) ) @@ -556,13 +529,14 @@ def binary_sensor_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_binary_sensor_automations(var, config): - for conf in config.get(CONF_ON_PRESS, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - - for conf in config.get(CONF_ON_RELEASE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + for conf_key, forwarder in ( + (CONF_ON_PRESS, automation.TriggerOnTrueForwarder), + (CONF_ON_RELEASE, automation.TriggerOnFalseForwarder), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, "add_on_state_callback", [], conf, forwarder=forwarder + ) for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( @@ -593,13 +567,14 @@ async def _build_binary_sensor_automations(var, config): await automation.build_automation(trigger, [], conf) for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(bool, "x")], conf + ) for conf in config.get(CONF_ON_STATE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_full_state_callback", [ (cg.optional.template(bool), "x_previous"), (cg.optional.template(bool), "x"), diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 89a92de31d..c9d250545b 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -469,14 +469,18 @@ bool BMP581Component::read_temperature_and_pressure_(float &temperature, float & } bool BMP581Component::reset_() { + // - activates interface (only relevant for SPI mode) // - writes reset command to the command register // - waits for sensor to complete reset + // - activates interface (only relevant for SPI mode) // - returns the Power-On-Reboot interrupt status, which is asserted if successful + // activates communication interface (SPI only) + this->activate_interface(); + // writes reset command to BMP's command register if (!this->bmp_write_byte(BMP581_COMMAND, RESET_COMMAND)) { ESP_LOGE(TAG, "Failed to write reset command"); - return false; } @@ -484,6 +488,9 @@ bool BMP581Component::reset_() { // - round up to 3 ms delay(3); + // reactivates communication interface after reset (SPI only) + this->activate_interface(); + // read interrupt status register if (!this->bmp_read_byte(BMP581_INT_STATUS, &this->int_status_.reg)) { ESP_LOGE(TAG, "Failed to read interrupt status register"); @@ -491,7 +498,7 @@ bool BMP581Component::reset_() { return false; } - // Power-On-Reboot bit is asserted if sensor successfully reset + // power-On-Reboot bit is asserted if sensor successfully reset return this->int_status_.bit.por; } diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index d99c420272..c3920512e0 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -87,6 +87,9 @@ class BMP581Component : public PollingComponent { virtual bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; virtual bool bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + // Interface activation function. Only used for SPI interface; no-op for I2C. + virtual void activate_interface() {} + sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; diff --git a/esphome/components/bmp581_spi/__init__.py b/esphome/components/bmp581_spi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/bmp581_spi/bmp581_spi.cpp b/esphome/components/bmp581_spi/bmp581_spi.cpp new file mode 100644 index 0000000000..01435880f0 --- /dev/null +++ b/esphome/components/bmp581_spi/bmp581_spi.cpp @@ -0,0 +1,73 @@ +#include +#include + +#include "bmp581_spi.h" +#include "esphome/components/bmp581_base/bmp581_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::bmp581_spi { + +static const char *const TAG = "bmp581_spi"; + +// OR (|) register with BMP_SPI_READ for read +inline constexpr uint8_t BMP_SPI_READ = 0x80; + +// AND (&) register with BMP_SPI_WRITE for write +inline constexpr uint8_t BMP_SPI_WRITE = 0x7F; + +void BMP581SPIComponent::dump_config() { + BMP581Component::dump_config(); + LOG_SPI_DEVICE(this); +} + +void BMP581SPIComponent::setup() { + this->spi_setup(); + BMP581Component::setup(); +} + +void BMP581SPIComponent::activate_interface() { + // - forces the device into SPI mode using a dummy read + uint8_t dummy_read = 0; + this->bmp_read_byte(bmp581_base::BMP581_CHIP_ID, &dummy_read); +} + +// In SPI mode, only 7 bits of the register addresses are used; the MSB of register address is not used +// and replaced by a read/write bit (RW = ‘0’ for write and RW = ‘1’ for read). +// Example: address 0xF7 is accessed by using SPI register address 0x77. For write access, the byte +// 0x77 is transferred, for read access, the byte 0xF7 is transferred. +// The expressions BMP_SPI_READ (| with register) and BMP_SPI_WRITE (& with register) +// are defined for readability. +// https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bmp581-ds004.pdf + +bool BMP581SPIComponent::bmp_read_byte(uint8_t a_register, uint8_t *data) { + this->enable(); + this->transfer_byte(a_register | BMP_SPI_READ); + *data = this->transfer_byte(0); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_write_byte(uint8_t a_register, uint8_t data) { + this->enable(); + this->transfer_byte(a_register & BMP_SPI_WRITE); + this->transfer_byte(data); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register | BMP_SPI_READ); + this->read_array(data, len); + this->disable(); + return true; +} + +bool BMP581SPIComponent::bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register & BMP_SPI_WRITE); + this->write_array(data, len); + this->disable(); + return true; +} +} // namespace esphome::bmp581_spi diff --git a/esphome/components/bmp581_spi/bmp581_spi.h b/esphome/components/bmp581_spi/bmp581_spi.h new file mode 100644 index 0000000000..57f75588d5 --- /dev/null +++ b/esphome/components/bmp581_spi/bmp581_spi.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/bmp581_base/bmp581_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::bmp581_spi { + +// BMP581 is technically compatible with SPI Mode0 and Mode3. Default to Mode3. +class BMP581SPIComponent : public esphome::bmp581_base::BMP581Component, + public spi::SPIDevice { + public: + void setup() override; + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; + bool bmp_write_byte(uint8_t a_register, uint8_t data) override; + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + bool bmp_write_bytes(uint8_t a_register, uint8_t *data, size_t len) override; + void dump_config() override; + + protected: + void activate_interface() override; +}; + +} // namespace esphome::bmp581_spi diff --git a/esphome/components/bmp581_spi/sensor.py b/esphome/components/bmp581_spi/sensor.py new file mode 100644 index 0000000000..75f60b2460 --- /dev/null +++ b/esphome/components/bmp581_spi/sensor.py @@ -0,0 +1,48 @@ +import logging + +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.spi import CONF_SPI_MODE +import esphome.config_validation as cv + +from ..bmp581_base import CONFIG_SCHEMA_BASE, to_code_base + +AUTO_LOAD = ["bmp581_base"] +CODEOWNERS = ["@kahrendt", "@danielkent-net"] +DEPENDENCIES = ["spi"] + +_LOGGER = logging.getLogger(__name__) + +VALID_SPI_MODES = { + 0: "MODE0", + "0": "MODE0", + "MODE0": "MODE0", + 3: "MODE3", + "3": "MODE3", + "MODE3": "MODE3", +} + +bmp581_ns = cg.esphome_ns.namespace("bmp581_spi") +BMP581SPIComponent = bmp581_ns.class_( + "BMP581SPIComponent", cg.PollingComponent, spi.SPIDevice +) + + +def check_spi_mode(config): + spi_mode = config.get(CONF_SPI_MODE) + if spi_mode not in VALID_SPI_MODES: + raise cv.Invalid("BMP581 only supports SPI mode 3") + return config + + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend(spi.spi_device_schema(default_mode="mode3")).extend( + {cv.GenerateID(): cv.declare_id(BMP581SPIComponent)} + ), + check_spi_mode, +) + + +async def to_code(config): + var = await to_code_base(config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 12d9ebaba6..f279b6ffe3 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_ID, CONF_MQTT_ID, CONF_ON_PRESS, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_IDENTIFY, @@ -41,10 +40,6 @@ ButtonPtr = Button.operator("ptr") PressAction = button_ns.class_("PressAction", automation.Action) -ButtonPressTrigger = button_ns.class_( - "ButtonPressTrigger", automation.Trigger.template() -) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -55,11 +50,7 @@ _BUTTON_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_ON_PRESS): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ButtonPressTrigger), - } - ), + cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), } ) ) @@ -91,8 +82,9 @@ def button_schema( @setup_entity("button") async def setup_button_core_(var, config): for conf in config.get(CONF_ON_PRESS, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automation( + var, "add_on_press_callback", [], conf + ) setup_device_class(config) diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 5cbe9a5daf..32cac0961c 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -367,7 +367,7 @@ optional Climate::restore_state_() { return recovered; } -void Climate::save_state_() { +void Climate::save_state_(const ClimateTraits &traits) { #if (defined(USE_ESP32) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \ !defined(CLANG_TIDY) #pragma GCC diagnostic ignored "-Wclass-memaccess" @@ -382,7 +382,6 @@ void Climate::save_state_() { #endif state.mode = this->mode; - auto traits = this->get_traits(); if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { state.target_temperature_low = this->target_temperature_low; @@ -480,7 +479,7 @@ void Climate::publish_state() { ControllerRegistry::notify_climate_update(this); #endif // Save state - this->save_state_(); + this->save_state_(traits); } ClimateTraits Climate::get_traits() { diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index e2cb743c0a..0251365dd8 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -335,7 +335,8 @@ class Climate : public EntityBase { /** Internal method to save the state of the climate device to recover memory. This is automatically * called from publish_state() */ - void save_state_(); + void save_state_(const ClimateTraits &traits); + void save_state_() { this->save_state_(this->traits()); } void dump_traits_(const char *tag); diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 300902b8ca..527bb4ebba 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -10,7 +10,6 @@ from esphome.const import ( CONF_ID, CONF_MQTT_ID, CONF_ON_EVENT, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_BUTTON, DEVICE_CLASS_DOORBELL, @@ -41,8 +40,6 @@ EventPtr = Event.operator("ptr") TriggerEventAction = event_ns.class_("TriggerEventAction", automation.Action) -EventTrigger = event_ns.class_("EventTrigger", automation.Trigger.template()) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") _EVENT_SCHEMA = ( @@ -53,11 +50,7 @@ _EVENT_SCHEMA = ( cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.GenerateID(): cv.declare_id(Event), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_ON_EVENT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(EventTrigger), - } - ), + cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), } ) ) @@ -92,8 +85,9 @@ def event_schema( @setup_entity("event") async def setup_event_core_(var, config, *, event_types: list[str]): for conf in config.get(CONF_ON_EVENT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.StringRef, "event_type")], conf) + await automation.build_callback_automation( + var, "add_on_event_callback", [(cg.StringRef, "event_type")], conf + ) cg.add(var.set_event_types(event_types)) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 4090ca57c2..5925afb472 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( CONF_ID, CONF_INITIAL_STATE, CONF_MQTT_ID, + CONF_NAME, CONF_ON_STATE, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, @@ -41,6 +42,8 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType from .automation import LIGHT_STATE_SCHEMA from .effects import ( @@ -70,9 +73,19 @@ IS_PLATFORM_COMPONENT = True DOMAIN = "light" +@dataclass +class EffectRef: + """A pending effect name reference from a light action to validate.""" + + light_id: ID + effect_name: str + component_path: list[str | int] # path_context when the action was validated + + @dataclass class LightData: gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr + effect_refs: list[EffectRef] = field(default_factory=list) def _get_data() -> LightData: @@ -115,6 +128,68 @@ def _get_or_create_gamma_table(gamma_correct): return fwd_arr +def find_effect_index(effects: list, effect_name: str) -> int | None: + """Find the 1-based index of an effect by name (case-insensitive). + + Returns the 1-based index if found, or None if not found. + """ + effect_name_lower = effect_name.lower() + for i, effect_conf in enumerate(effects): + key = next(iter(effect_conf)) + if effect_conf[key][CONF_NAME].lower() == effect_name_lower: + return i + 1 + return None + + +def available_effects_str(effects: list) -> str: + """Return a comma-separated string of available effect names.""" + available = [ + effect_conf[next(iter(effect_conf))][CONF_NAME] for effect_conf in effects + ] + return ", ".join(f"'{name}'" for name in available) if available else "none" + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate all recorded effect name references against their target lights. + + This runs once per light platform instance. If no light platform is configured, + this never runs — but the ID validator will catch the missing light ID separately. + """ + data = _get_data() + if not data.effect_refs: + return config + + # Drain the list so we only validate once even though + # FINAL_VALIDATE_SCHEMA runs for each light platform instance. + refs = data.effect_refs + data.effect_refs = [] + + fconf = fv.full_config.get() + + for ref in refs: + try: + light_path = fconf.get_path_for_id(ref.light_id)[:-1] + light_config = fconf.get_config_for_path(light_path) + except KeyError: + # Light ID not found — ID validation will have already reported this + continue + + effects = light_config.get(CONF_EFFECTS, []) + + if find_effect_index(effects, ref.effect_name) is None: + raise cv.FinalExternalInvalid( + f"Effect '{ref.effect_name}' not found for light " + f"'{ref.light_id}'. " + f"Available effects: {available_effects_str(effects)}", + path=[cv.ROOT_CONFIG_PATH] + ref.component_path, + ) + + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + LightRestoreMode = light_ns.enum("LightRestoreMode") RESTORE_MODES = { "RESTORE_DEFAULT_OFF": LightRestoreMode.LIGHT_RESTORE_DEFAULT_OFF, diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 55273003b9..16e7d72f6b 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -1,5 +1,6 @@ from esphome import automation import esphome.codegen as cg +from esphome.config import path_context import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -17,7 +18,6 @@ from esphome.const import ( CONF_LIMIT_MODE, CONF_MAX_BRIGHTNESS, CONF_MIN_BRIGHTNESS, - CONF_NAME, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_RED, @@ -26,7 +26,7 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WHITE, ) -from esphome.core import CORE, Lambda +from esphome.core import CORE, EsphomeError, Lambda from esphome.cpp_generator import LambdaExpression from esphome.types import ConfigType @@ -98,6 +98,31 @@ LIGHT_CONTROL_ACTION_SCHEMA = LIGHT_STATE_SCHEMA.extend( } ) + +def _record_effect_ref(config: ConfigType) -> ConfigType: + """Record a static effect name reference for later cross-component validation.""" + if CONF_EFFECT not in config: + return config + effect = config[CONF_EFFECT] + if isinstance(effect, Lambda): + return config # Lambda effects resolved at runtime + if effect.lower() == "none": + return config # "None" is always valid + + from . import EffectRef, _get_data + + _get_data().effect_refs.append( + EffectRef( + light_id=config[CONF_ID], + effect_name=effect, + component_path=path_context.get(), + ) + ) + return config + + +LIGHT_CONTROL_ACTION_SCHEMA.add_extra(_record_effect_ref) + LIGHT_TURN_OFF_ACTION_SCHEMA = automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(LightState), @@ -122,18 +147,24 @@ def _resolve_effect_index(config: ConfigType) -> int: Effect index 0 means "None" (no effect). Effects are 1-indexed matching the C++ convention in LightState. """ + from . import available_effects_str, find_effect_index + original_name = config[CONF_EFFECT] - effect_name = original_name.lower() - if effect_name == "none": + if original_name.lower() == "none": return 0 light_id = config[CONF_ID] light_path = CORE.config.get_path_for_id(light_id)[:-1] light_config = CORE.config.get_config_for_path(light_path) - for i, effect_conf in enumerate(light_config.get(CONF_EFFECTS, [])): - key = next(iter(effect_conf)) - if effect_conf[key][CONF_NAME].lower() == effect_name: - return i + 1 - raise ValueError(f"Effect '{original_name}' not found in light '{light_id}'") + effects = light_config.get(CONF_EFFECTS, []) + index = find_effect_index(effects, original_name) + if index is not None: + return index + # Should never reach here — effect names are validated during config + # validation in FINAL_VALIDATE_SCHEMA. This is a safety net. + raise EsphomeError( + f"Effect '{original_name}' not found for light '{light_id}'. " + f"Available effects: {available_effects_str(effects)}" + ) @automation.register_action( diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index cd6543bfb8..23b69c36c6 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -243,6 +243,16 @@ void Logger::dump_config() { #endif #ifdef USE_ZEPHYR dump_crash_(); +#endif + // Warn users that VERBOSE/VERY_VERBOSE logging impacts performance. + // Only the compiled log level matters — all log calls up to this level + // are in the binary and will be formatted (vsnprintf) and block UART. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + ESP_LOGW(TAG, "VERY_VERBOSE logging is active — significant performance impact, short-term debugging only\n" + " May cause connection instability. Set log level to DEBUG or lower for long-term use."); +#elif ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGI(TAG, "VERBOSE logging is active — performance impact, short-term debugging only\n" + " Set log level to DEBUG or lower for long-term use."); #endif } diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index ac17e14312..612bfbc968 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -90,7 +90,7 @@ bool Nextion::check_connect_() { #endif // NEXTION_PROTOCOL_LOG ESP_LOGW(TAG, "Not connected"); - comok_sent_ = 0; + this->comok_sent_ = 0; return false; } diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index 2adf314a2e..4ddbfbee6a 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -106,7 +106,7 @@ void Nextion::set_component_pressed_foreground_color(const char *component, uint } void Nextion::set_component_pressed_foreground_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", " %s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_("set_component_pressed_foreground_color", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_foreground_color(const char *component, Color color) { @@ -134,7 +134,7 @@ void Nextion::set_component_pressed_font_color(const char *component, uint16_t c } void Nextion::set_component_pressed_font_color(const char *component, const char *color) { - this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", " %s.pco2=%s", component, color); + this->add_no_result_to_queue_with_printf_("set_component_pressed_font_color", "%s.pco2=%s", component, color); } void Nextion::set_component_pressed_font_color(const char *component, Color color) { diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 6c454ab745..f59b708002 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -22,9 +22,9 @@ static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { uint32_t range_size = this->tft_size_ - range_start; ESP_LOGV(TAG, "Heap: %" PRIu32, EspClass::getFreeHeap()); - uint32_t range_end = ((upload_first_chunk_sent_ or this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; + uint32_t range_end = ((this->upload_first_chunk_sent_ || this->tft_size_ < 4096) ? this->tft_size_ : 4096) - 1; ESP_LOGD(TAG, "Range start: %" PRIu32, range_start); - if (range_size <= 0 or range_end <= range_start) { + if (range_size <= 0 || range_end <= range_start) { ESP_LOGE(TAG, "Invalid range end: %" PRIu32 ", size: %" PRIu32, range_end, range_size); return -1; } @@ -34,7 +34,7 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { ESP_LOGV(TAG, "Range: %s", range_header); http_client.addHeader("Range", range_header); int code = http_client.GET(); - if (code != HTTP_CODE_OK and code != HTTP_CODE_PARTIAL_CONTENT) { + if (code != HTTP_CODE_OK && code != HTTP_CODE_PARTIAL_CONTENT) { ESP_LOGW(TAG, "HTTP failed: %s", HTTPClient::errorToString(code).c_str()); return -1; } @@ -80,12 +80,12 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); - upload_first_chunk_sent_ = true; + this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { ESP_LOGW(TAG, "No response from display during upload"); allocator.deallocate(buffer, 4096); @@ -112,7 +112,7 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { allocator.deallocate(buffer, 4096); buffer = nullptr; return range_end + 1; - } else if (recv_string[0] != 0x05 and recv_string[0] != 0x08) { // 0x05 == "ok" + } else if (recv_string[0] != 0x05 && recv_string[0] != 0x08) { // 0x05 == "ok" char hex_buf[format_hex_pretty_size(NEXTION_MAX_RESPONSE_LOG_BYTES)]; ESP_LOGE( TAG, "Invalid response: [%s]", @@ -214,7 +214,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { ++tries; } - if (code != 200 and code != 206) { + if (code != 200 && code != 206) { ESP_LOGE(TAG, "HTTP request failed with status %d", code); return this->upload_end_(false); } diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 0570ac0b1e..90f9fe1835 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -155,9 +155,6 @@ Number = number_ns.class_("Number", cg.EntityBase) NumberPtr = Number.operator("ptr") # Triggers -NumberStateTrigger = number_ns.class_( - "NumberStateTrigger", automation.Trigger.template(cg.float_) -) ValueRangeTrigger = number_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) @@ -198,11 +195,7 @@ _NUMBER_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTNumberComponent), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(NumberStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValueRangeTrigger), @@ -248,8 +241,9 @@ def number_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_number_automations(var, config): for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(float, "x")], conf + ) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 9f3c1484b0..19d03a0afc 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -238,12 +238,6 @@ Sensor = sensor_ns.class_("Sensor", cg.EntityBase) SensorPtr = Sensor.operator("ptr") # Triggers -SensorStateTrigger = sensor_ns.class_( - "SensorStateTrigger", automation.Trigger.template(cg.float_) -) -SensorRawStateTrigger = sensor_ns.class_( - "SensorRawStateTrigger", automation.Trigger.template(cg.float_) -) ValueRangeTrigger = sensor_ns.class_( "ValueRangeTrigger", automation.Trigger.template(cg.float_), cg.Component ) @@ -316,18 +310,8 @@ _SENSOR_SCHEMA = ( cv.Any(None, cv.positive_time_period_milliseconds), ), cv.Optional(CONF_FILTERS): validate_filters, - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SensorStateTrigger), - } - ), - cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - SensorRawStateTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), + cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_VALUE_RANGE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValueRangeTrigger), @@ -897,12 +881,14 @@ async def build_filters(config): @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_sensor_automations(var, config): - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) - for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(float, "x")], conf) + for conf_key, callback in ( + (CONF_ON_VALUE, "add_on_state_callback"), + (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, callback, [(float, "x")], conf + ) for conf in config.get(CONF_ON_VALUE_RANGE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await cg.register_component(trigger, conf) diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index bbafc54bd1..c4dd4856e3 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -15,7 +15,6 @@ from esphome.const import ( CONF_ON_TURN_ON, CONF_RESTORE_MODE, CONF_STATE, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_OUTLET, @@ -61,17 +60,6 @@ TurnOnAction = switch_ns.class_("TurnOnAction", automation.Action) SwitchPublishAction = switch_ns.class_("SwitchPublishAction", automation.Action) SwitchCondition = switch_ns.class_("SwitchCondition", Condition) -SwitchStateTrigger = switch_ns.class_( - "SwitchStateTrigger", automation.Trigger.template(bool) -) -SwitchTurnOnTrigger = switch_ns.class_( - "SwitchTurnOnTrigger", automation.Trigger.template() -) -SwitchTurnOffTrigger = switch_ns.class_( - "SwitchTurnOffTrigger", automation.Trigger.template() -) - - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True) @@ -86,21 +74,9 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum( RESTORE_MODES, upper=True, space="_" ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchStateTrigger), - } - ), - cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchTurnOnTrigger), - } - ), - cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SwitchTurnOffTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), + cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, } ) @@ -147,15 +123,15 @@ def switch_schema( @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_switch_automations(var, config): - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(bool, "x")], conf) - for conf in config.get(CONF_ON_TURN_ON, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_TURN_OFF, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + for conf_key, args, forwarder in ( + (CONF_ON_STATE, [(bool, "x")], None), + (CONF_ON_TURN_ON, [], automation.TriggerOnTrueForwarder), + (CONF_ON_TURN_OFF, [], automation.TriggerOnFalseForwarder), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, "add_on_state_callback", args, conf, forwarder=forwarder + ) @setup_entity("switch") diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 97f394ecf7..51eedf9a95 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -14,7 +14,6 @@ from esphome.const import ( CONF_ON_VALUE, CONF_STATE, CONF_TO, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_DATE, DEVICE_CLASS_EMPTY, @@ -42,12 +41,6 @@ text_sensor_ns = cg.esphome_ns.namespace("text_sensor") TextSensor = text_sensor_ns.class_("TextSensor", cg.EntityBase) TextSensorPtr = TextSensor.operator("ptr") -TextSensorStateTrigger = text_sensor_ns.class_( - "TextSensorStateTrigger", automation.Trigger.template(cg.std_string) -) -TextSensorStateRawTrigger = text_sensor_ns.class_( - "TextSensorStateRawTrigger", automation.Trigger.template(cg.std_string) -) TextSensorPublishAction = text_sensor_ns.class_( "TextSensorPublishAction", automation.Action ) @@ -150,20 +143,8 @@ _TEXT_SENSOR_SCHEMA = ( cv.GenerateID(): cv.declare_id(TextSensor), cv.Optional(CONF_DEVICE_CLASS): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - TextSensorStateTrigger - ), - } - ), - cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - TextSensorStateRawTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), + cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), } ) ) @@ -203,13 +184,14 @@ async def build_filters(config): @coroutine_with_priority(CoroPriority.AUTOMATION) async def _build_text_sensor_automations(var, config): - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) - - for conf in config.get(CONF_ON_RAW_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + for conf_key, callback in ( + (CONF_ON_VALUE, "add_on_state_callback"), + (CONF_ON_RAW_VALUE, "add_on_raw_state_callback"), + ): + for conf in config.get(conf_key, []): + await automation.build_callback_automation( + var, callback, [(cg.std_string, "x")], conf + ) @setup_entity("text_sensor") diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 1b80adc82e..d8b3db9667 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -989,9 +989,11 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } // When scanning while connected (roaming), return to home channel between // each scanned channel to maintain the connection (helps with BLE/WiFi coexistence) +#ifdef CONFIG_SOC_WIFI_SUPPORTED if (this->roaming_state_ == RoamingState::SCANNING) { config.coex_background_scan = true; } +#endif esp_err_t err = esp_wifi_scan_start(&config, false); if (err != ESP_OK) { diff --git a/esphome/core/automation.h b/esphome/core/automation.h index ca4a2c8b6b..fc2cad99be 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -470,7 +470,9 @@ template class ActionList { template class Automation { public: - explicit Automation(Trigger *trigger) : trigger_(trigger) { this->trigger_->set_automation_parent(this); } + /// Default constructor for use with TriggerForwarder (no Trigger object needed). + Automation() = default; + explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } void add_actions(const std::initializer_list *> &actions) { this->actions_.add_actions(actions); } @@ -487,8 +489,44 @@ template class Automation { int num_running() { return this->actions_.num_running(); } protected: - Trigger *trigger_; ActionList actions_; }; +/// Callback forwarder that triggers an Automation directly. +/// One operator() instantiation per Automation signature, shared across all call sites. +/// Must stay pointer-sized to fit inline in Callback::ctx_ without heap allocation. +template struct TriggerForwarder { + Automation *automation; + void operator()(const Ts &...args) const { this->automation->trigger(args...); } +}; + +/// Callback forwarder that triggers an Automation<> only when the bool arg is true. +/// Must stay pointer-sized to fit inline in Callback::ctx_ without heap allocation. +struct TriggerOnTrueForwarder { + Automation<> *automation; + void operator()(bool state) const { + if (state) + this->automation->trigger(); + } +}; + +/// Callback forwarder that triggers an Automation<> only when the bool arg is false. +/// Must stay pointer-sized to fit inline in Callback::ctx_ without heap allocation. +struct TriggerOnFalseForwarder { + Automation<> *automation; + void operator()(bool state) const { + if (!state) + this->automation->trigger(); + } +}; + +// Ensure forwarders fit in Callback::ctx_ (pointer-sized inline storage). +// If these fail, the forwarder would heap-allocate in Callback::create(). +static_assert(sizeof(TriggerForwarder<>) <= sizeof(void *)); +static_assert(sizeof(TriggerOnTrueForwarder) <= sizeof(void *)); +static_assert(sizeof(TriggerOnFalseForwarder) <= sizeof(void *)); +static_assert(std::is_trivially_copyable_v>); +static_assert(std::is_trivially_copyable_v); +static_assert(std::is_trivially_copyable_v); + } // namespace esphome diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp index 255efa86ba..dd69de47d4 100644 --- a/esphome/core/controller_registry.cpp +++ b/esphome/core/controller_registry.cpp @@ -10,24 +10,24 @@ StaticVector ControllerRegistry::controll void ControllerRegistry::register_controller(Controller *controller) { controllers.push_back(controller); } -void ControllerRegistry::notify(void *obj, DispatchFunc dispatch) { - for (auto *controller : controllers) { - dispatch(controller, obj); - } -} - -// Macro for standard registry notification dispatch - calls on__update() -// Each wrapper passes a small trampoline lambda that calls the correct virtual method. +// Each notify method directly iterates controllers and calls the virtual method. +// This avoids the overhead of a shared noinline dispatch loop with function pointer +// indirection. The loop is tiny (~20 bytes per entity type) so the flash cost of +// duplicating it is negligible compared to eliminating two levels of indirection +// (noinline call + function pointer) from every state publish. // NOLINTBEGIN(bugprone-macro-parentheses) #define CONTROLLER_REGISTRY_NOTIFY(entity_type, entity_name) \ void ControllerRegistry::notify_##entity_name##_update(entity_type *obj) { \ - notify(obj, [](Controller *c, void *o) { c->on_##entity_name##_update(static_cast(o)); }); \ + for (auto *controller : controllers) { \ + controller->on_##entity_name##_update(obj); \ + } \ } -// Macro for entities where controller method has no "_update" suffix (Event, Update) #define CONTROLLER_REGISTRY_NOTIFY_NO_UPDATE_SUFFIX(entity_type, entity_name) \ void ControllerRegistry::notify_##entity_name(entity_type *obj) { \ - notify(obj, [](Controller *c, void *o) { c->on_##entity_name(static_cast(o)); }); \ + for (auto *controller : controllers) { \ + controller->on_##entity_name(obj); \ + } \ } // NOLINTEND(bugprone-macro-parentheses) diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index 15e3b4ba83..89b3069bcb 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -146,8 +146,8 @@ class UpdateEntity; * entities call ControllerRegistry::notify_*_update() which iterates the small list * of registered controllers (typically 2: API and WebServer). * - * Controllers read state directly from entities using existing accessors (obj->state, etc.) - * rather than receiving it as callback parameters that were being ignored anyway. + * Each notify method directly iterates controllers and calls the virtual method, + * avoiding function pointer indirection for minimal dispatch overhead. * * Memory savings: 32 bytes per entity (2 controllers × 16 bytes std::function overhead) * Typical config (25 entities): ~780 bytes saved @@ -247,21 +247,6 @@ class ControllerRegistry { #endif protected: - /** Type-erased dispatch function pointer. - * - * Each notify method passes a small trampoline that calls the - * correct virtual method on Controller. The shared notify() loop - * iterates controllers once, calling the trampoline for each. - */ - using DispatchFunc = void (*)(Controller *, void *); - - /** Shared dispatch loop - iterates controllers and calls dispatch for each. - * - * Marked noinline to ensure only one copy of the loop exists in flash, - * rather than being duplicated into each notify_*_update wrapper. - */ - static void __attribute__((noinline)) notify(void *obj, DispatchFunc dispatch); - static StaticVector controllers; }; diff --git a/esphome/writer.py b/esphome/writer.py index 4aac16ffd4..06a2230118 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -476,6 +476,16 @@ def clean_all(configuration: list[str]): data_dirs.append(Path(env_data_dir)) if env_build_path := os.environ.get("ESPHOME_BUILD_PATH"): data_dirs.append(Path(env_build_path)) + if not data_dirs: + # No config files or known data dirs, check current directory + cwd_esphome = Path.cwd() / ".esphome" + if cwd_esphome.is_dir(): + data_dirs.append(cwd_esphome) + else: + _LOGGER.warning( + "No configuration files specified and no .esphome directory found in current directory. " + "Pass YAML files or a configuration directory to clean build artifacts." + ) # Clean build dir for dir in data_dirs: diff --git a/requirements.txt b/requirements.txt index ce735f398a..c74dd265c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.5 +cryptography==46.0.6 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 diff --git a/requirements_test.txt b/requirements_test.txt index 1440b20333..3b277e214d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.7 # also change in .pre-commit-config.yaml when updating +ruff==0.15.8 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/tests/benchmarks/components/climate/__init__.py b/tests/benchmarks/components/climate/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/climate/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/climate/bench_climate.cpp b/tests/benchmarks/components/climate/bench_climate.cpp new file mode 100644 index 0000000000..316a72b2b6 --- /dev/null +++ b/tests/benchmarks/components/climate/bench_climate.cpp @@ -0,0 +1,142 @@ +#include + +#include "esphome/components/climate/climate.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Climate for benchmarking — control() is a no-op. +class BenchClimate : public climate::Climate { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + climate::ClimateTraits traits() override { return this->traits_; } + + climate::ClimateTraits traits_; + + protected: + void control(const climate::ClimateCall & /*call*/) override {} +}; + +// Helper to create a typical HVAC climate device for benchmarks. +// Note: setup() is not called (no preferences backend), so save_state_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_hvac_climate(BenchClimate &climate) { + climate.configure("test_climate"); + climate.traits_.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + climate.traits_.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + climate.traits_.set_supported_swing_modes({ + climate::CLIMATE_SWING_OFF, + climate::CLIMATE_SWING_BOTH, + climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, + }); + climate.traits_.set_supported_presets({ + climate::CLIMATE_PRESET_NONE, + climate::CLIMATE_PRESET_HOME, + climate::CLIMATE_PRESET_AWAY, + }); + climate.traits_.set_visual_min_temperature(16.0f); + climate.traits_.set_visual_max_temperature(30.0f); + climate.traits_.set_visual_target_temperature_step(0.5f); + climate.traits_.set_visual_current_temperature_step(0.1f); + climate.traits_.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE | climate::CLIMATE_SUPPORTS_ACTION); +} + +// --- Climate::publish_state() with temperature update --- +// Measures the publish path for a thermostat reporting state — +// the hot path during HVAC operation. + +static void ClimatePublish_State(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + climate.action = climate::CLIMATE_ACTION_HEATING; + climate.target_temperature = 22.0f; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + climate.current_temperature = 20.0f + static_cast(i % 100) / 10.0f; + climate.publish_state(); + } + benchmark::DoNotOptimize(climate.current_temperature); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimatePublish_State); + +// --- Climate::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void ClimatePublish_WithCallback(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + climate.target_temperature = 22.0f; + + uint64_t callback_count = 0; + climate.add_on_state_callback([&callback_count](climate::Climate & /*c*/) { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + climate.current_temperature = 20.0f + static_cast(i % 100) / 10.0f; + climate.publish_state(); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimatePublish_WithCallback); + +// --- ClimateCall::perform() set target temperature --- +// The most common climate call — adjusting the thermostat setpoint. + +static void ClimateCall_SetTemperature(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + climate.mode = climate::CLIMATE_MODE_HEAT; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float temp = 18.0f + static_cast(i % 25) * 0.5f; + climate.make_call().set_target_temperature(temp).perform(); + } + benchmark::DoNotOptimize(climate.target_temperature); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimateCall_SetTemperature); + +// --- ClimateCall::perform() mode change with fan --- +// Exercises the validation path with multiple fields set. + +static void ClimateCall_ModeChange(benchmark::State &state) { + BenchClimate climate; + setup_hvac_climate(climate); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + auto mode = (i % 2 == 0) ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_COOL; + auto fan = (i % 2 == 0) ? climate::CLIMATE_FAN_HIGH : climate::CLIMATE_FAN_LOW; + climate.make_call().set_mode(mode).set_fan_mode(fan).set_target_temperature(22.0f).perform(); + } + benchmark::DoNotOptimize(climate.mode); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ClimateCall_ModeChange); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/climate/benchmark.yaml b/tests/benchmarks/components/climate/benchmark.yaml new file mode 100644 index 0000000000..8e79ed0ae7 --- /dev/null +++ b/tests/benchmarks/components/climate/benchmark.yaml @@ -0,0 +1 @@ +climate: diff --git a/tests/benchmarks/components/cover/__init__.py b/tests/benchmarks/components/cover/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/cover/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/cover/bench_cover_publish.cpp b/tests/benchmarks/components/cover/bench_cover_publish.cpp new file mode 100644 index 0000000000..794d967edb --- /dev/null +++ b/tests/benchmarks/components/cover/bench_cover_publish.cpp @@ -0,0 +1,107 @@ +#include + +#include "esphome/components/cover/cover.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Cover for benchmarking — control() is a no-op. +class BenchCover : public cover::Cover { + public: + cover::CoverTraits get_traits() override { return this->traits_; } + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + cover::CoverTraits traits_; + + protected: + void control(const cover::CoverCall & /*call*/) override {} +}; + +// --- Cover::publish_state() with position updates --- +// Measures the publish path for a garage door reporting position +// during open/close — the hot path during movement. + +static void CoverPublish_Position(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + cover.traits_.set_supports_tilt(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + cover.position = static_cast(i % 101) / 100.0f; + cover.current_operation = (i % 2 == 0) ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING; + cover.publish_state(false); + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverPublish_Position); + +// --- Cover::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void CoverPublish_WithCallback(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + uint64_t callback_count = 0; + cover.add_on_state_callback([&callback_count]() { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + cover.position = static_cast(i % 101) / 100.0f; + cover.publish_state(false); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverPublish_WithCallback); + +// --- CoverCall::perform() open/close cycle --- +// Measures the full call path: validation + control delegation. + +static void CoverCall_OpenClose(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + if (i % 2 == 0) { + cover.make_call().set_command_open().perform(); + } else { + cover.make_call().set_command_close().perform(); + } + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverCall_OpenClose); + +// --- CoverCall::perform() set position --- +// Measures the position-setting call path. + +static void CoverCall_SetPosition(benchmark::State &state) { + BenchCover cover; + cover.configure("test_cover"); + cover.traits_.set_supports_position(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float pos = static_cast(i % 101) / 100.0f; + cover.make_call().set_position(pos).perform(); + } + benchmark::DoNotOptimize(cover.position); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CoverCall_SetPosition); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/cover/benchmark.yaml b/tests/benchmarks/components/cover/benchmark.yaml new file mode 100644 index 0000000000..477724be5a --- /dev/null +++ b/tests/benchmarks/components/cover/benchmark.yaml @@ -0,0 +1 @@ +cover: diff --git a/tests/benchmarks/components/fan/__init__.py b/tests/benchmarks/components/fan/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/fan/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/fan/bench_fan.cpp b/tests/benchmarks/components/fan/bench_fan.cpp new file mode 100644 index 0000000000..c7966c7886 --- /dev/null +++ b/tests/benchmarks/components/fan/bench_fan.cpp @@ -0,0 +1,122 @@ +#include + +#include "esphome/components/fan/fan.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Fan for benchmarking — control() is a no-op. +class BenchFan : public fan::Fan { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + fan::FanTraits get_traits() override { return this->traits_; } + + fan::FanTraits traits_; + + protected: + void control(const fan::FanCall & /*call*/) override {} +}; + +// Helper to create a typical fan device for benchmarks. +// Note: setup() is not called (no preferences backend), so save_state_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_fan(BenchFan &fan) { + fan.configure("test_fan"); + fan.traits_.set_oscillation(true); + fan.traits_.set_speed(true); + fan.traits_.set_supported_speed_count(6); + fan.traits_.set_direction(true); + fan.set_restore_mode(fan::FanRestoreMode::NO_RESTORE); + fan.traits_.set_supported_preset_modes({ + "auto", + "sleep", + "nature", + "turbo", + }); +} + +// --- Fan::publish_state() with speed update --- +// Measures the publish path for a fan reporting state — +// the hot path during fan operation. + +static void FanPublish_State(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + fan.state = true; + fan.direction = fan::FanDirection::FORWARD; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + fan.speed = (i % 6) + 1; + fan.publish_state(); + } + benchmark::DoNotOptimize(fan.speed); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanPublish_State); + +// --- Fan::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void FanPublish_WithCallback(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + fan.state = true; + + uint64_t callback_count = 0; + fan.add_on_state_callback([&callback_count]() { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + fan.speed = (i % 6) + 1; + fan.publish_state(); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanPublish_WithCallback); + +// --- FanCall::perform() set speed --- +// The most common fan call — adjusting the speed level. + +static void FanCall_SetSpeed(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + fan.state = true; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + int speed = (i % 6) + 1; + fan.make_call().set_speed(speed).perform(); + } + benchmark::DoNotOptimize(fan.speed); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanCall_SetSpeed); + +// --- FanCall::perform() with multiple fields --- +// Exercises the validation path with state, speed, oscillation, and direction. + +static void FanCall_MultiField(benchmark::State &state) { + BenchFan fan; + setup_fan(fan); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + auto dir = (i % 2 == 0) ? fan::FanDirection::FORWARD : fan::FanDirection::REVERSE; + int speed = (i % 6) + 1; + fan.make_call().set_state(true).set_speed(speed).set_oscillating(i % 2 == 0).set_direction(dir).perform(); + } + benchmark::DoNotOptimize(fan.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(FanCall_MultiField); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/fan/benchmark.yaml b/tests/benchmarks/components/fan/benchmark.yaml new file mode 100644 index 0000000000..e9d59c12b2 --- /dev/null +++ b/tests/benchmarks/components/fan/benchmark.yaml @@ -0,0 +1 @@ +fan: diff --git a/tests/benchmarks/components/light/__init__.py b/tests/benchmarks/components/light/__init__.py new file mode 100644 index 0000000000..233a3c246e --- /dev/null +++ b/tests/benchmarks/components/light/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components.light import generate_gamma_table +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Light benchmarks need USE_LIGHT_GAMMA_LUT defined and a gamma table + # with external linkage that the benchmark .cpp can reference. + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config): + await original_to_code(config) + cg.add_define("USE_LIGHT_GAMMA_LUT") + # Use the light component's own generate_gamma_table() so the + # benchmark stays in sync with any formula changes. + forward = generate_gamma_table(2.8) + values = ", ".join(f"0x{int(v):04X}" for v in forward) + # Use extern-visible (non-static) array so the benchmark .cpp + # can reference it via extern declaration. + cg.add_global( + cg.RawStatement( + f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};" + ) + ) + + to_code.priority = original_to_code.priority + manifest.to_code = to_code diff --git a/tests/benchmarks/components/light/bench_light_call.cpp b/tests/benchmarks/components/light/bench_light_call.cpp new file mode 100644 index 0000000000..c1ef0c425e --- /dev/null +++ b/tests/benchmarks/components/light/bench_light_call.cpp @@ -0,0 +1,253 @@ +#include + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" + +// Gamma 2.8 forward LUT generated by the light component's Python codegen +// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table()) +extern const uint16_t bench_gamma_2_8_fwd[256]; + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal LightOutput for benchmarking — no real hardware interaction. +class BenchLightOutput : public light::LightOutput { + public: + light::LightTraits get_traits() override { return this->traits_; } + void write_state(light::LightState * /*state*/) override {} + + light::LightTraits traits_; +}; + +// Test subclass to access protected configure_entity_() for benchmark setup. +class TestLightState : public light::LightState { + public: + using LightState::LightState; + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } +}; + +// Helper to create a configured RGBWW light state for benchmarks. +// Note: setup() is not called (no preferences backend), so save_remote_values_() +// is effectively a no-op. This benchmarks the call/validation path, not persistence. +static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) { + output.traits_.set_supported_color_modes({light::ColorMode::RGB_COLD_WARM_WHITE}); + output.traits_.set_min_mireds(153.0f); + output.traits_.set_max_mireds(500.0f); + light.configure("test_light"); + light.set_default_transition_length(0); + light.set_gamma_correct(2.8f); + light.set_gamma_table(bench_gamma_2_8_fwd); + light.set_restore_mode(light::LIGHT_ALWAYS_OFF); +} + +// --- LightCall::perform() with instant RGB color change (Home Assistant API path) --- +// Measures the full call path: validation, set_immediately_, publish, and save. +// HA sends color_mode explicitly since API 1.6. + +static void LightCall_RGBInstant(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + // Turn on first so subsequent calls are color changes + light.make_call().set_state(true).set_brightness(1.0f).set_color_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float v = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_red(v) + .set_green(1.0f - v) + .set_blue(v * 0.5f) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_RGBInstant); + +// --- LightCall::perform() turn on/off cycle (Home Assistant API path) --- +// HA sends color_mode explicitly since API 1.6, skipping compute_color_mode_(). + +static void LightCall_ToggleOnOff(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.make_call() + .set_state(i % 2 == 0) + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ToggleOnOff); + +// --- LightCall::perform() turn on/off via MQTT --- +// MQTT never sends color_mode, so compute_color_mode_() runs every call. + +static void LightCall_ToggleOnOff_MQTT(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.make_call().set_state(i % 2 == 0).set_transition_length(0).perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ToggleOnOff_MQTT); + +// --- LightCall::perform() with color temperature via MQTT --- +// Exercises the transform_parameters_() path that converts color_temperature +// to cold/warm white fractions. MQTT never sends color_mode, so this also +// hits compute_color_mode_() every call. Modern HA avoids this path entirely +// by converting color temp to CW/WW client-side. + +static void LightCall_ColorTemperature_MQTT(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + // Sweep through color temperature range + float ct = 153.0f + static_cast(i % 348); + light.make_call().set_color_temperature(ct).set_transition_length(0).perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ColorTemperature_MQTT); + +// --- LightCall::perform() with 1s transition (Home Assistant API path) --- +// Exercises start_transition_() which allocates a LightTransformer. +// This is the default HA path when transition_length > 0. + +static void LightCall_Transition(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float v = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_red(v) + .set_green(1.0f - v) + .set_blue(v * 0.5f) + .set_transition_length(1000) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_Transition); + +// --- LightCall::perform() with cold/warm white (Home Assistant API path) --- +// Mirrors what modern HA sends: explicit color_mode with direct cold_white +// and warm_white values. HA converts color temp to CW/WW client-side for +// CWWW lights (API >= 1.6), so this is the primary HA path. + +static void LightCall_ColdWarmWhite(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float frac = static_cast(i % 256) / 255.0f; + light.make_call() + .set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE) + .set_cold_white(1.0f - frac) + .set_warm_white(frac) + .set_transition_length(0) + .perform(); + } + benchmark::DoNotOptimize(light.remote_values); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightCall_ColdWarmWhite); + +// --- LightState::publish_state() with a remote values listener --- +// Measures listener notification overhead. + +static void LightPublish_WithListener(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + struct TestListener : public light::LightRemoteValuesListener { + void on_light_remote_values_update() override { count_++; } + uint64_t count_{0}; + } listener; + light.add_remote_values_listener(&listener); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.publish_state(); + } + benchmark::DoNotOptimize(listener.count_); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightPublish_WithListener); + +// --- current_values_as_rgbww output conversion with gamma LUT --- +// Measures the output conversion path that real light drivers call +// from write_state() to get hardware PWM values, including gamma +// table lookups via the LUT generated by Python codegen. + +static void LightOutput_RGBWW(benchmark::State &state) { + BenchLightOutput output; + TestLightState light(&output); + setup_rgbww_light(output, light); + + light.make_call() + .set_state(true) + .set_brightness(0.8f) + .set_color_brightness(0.6f) + .set_red(1.0f) + .set_green(0.5f) + .set_blue(0.2f) + .set_cold_white(0.7f) + .set_warm_white(0.3f) + .set_transition_length(0) + .perform(); + + float r, g, b, cw, ww; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + light.current_values_as_rgbww(&r, &g, &b, &cw, &ww); + } + benchmark::DoNotOptimize(r); + benchmark::DoNotOptimize(cw); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(LightOutput_RGBWW); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/light/benchmark.yaml b/tests/benchmarks/components/light/benchmark.yaml new file mode 100644 index 0000000000..2b7c938581 --- /dev/null +++ b/tests/benchmarks/components/light/benchmark.yaml @@ -0,0 +1 @@ +light: diff --git a/tests/component_tests/font/.gitattributes b/tests/component_tests/font/.gitattributes new file mode 100644 index 0000000000..4df6726184 --- /dev/null +++ b/tests/component_tests/font/.gitattributes @@ -0,0 +1,2 @@ +*.pcf -text +*.ttf -text diff --git a/tests/component_tests/font/NotoSans-Regular.ttf b/tests/component_tests/font/NotoSans-Regular.ttf new file mode 100644 index 0000000000..a1b8994ede Binary files /dev/null and b/tests/component_tests/font/NotoSans-Regular.ttf differ diff --git a/tests/component_tests/font/__init__.py b/tests/component_tests/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/font/test_font.py b/tests/component_tests/font/test_font.py new file mode 100644 index 0000000000..55e27ae84c --- /dev/null +++ b/tests/component_tests/font/test_font.py @@ -0,0 +1,337 @@ +"""Tests for the font component. + +Focuses on verifying that long multi-byte (Chinese/CJK) glyph strings +are correctly processed through the font configuration pipeline. +""" + +import functools +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.font import ( + CONF_BPP, + CONF_EXTRAS, + CONF_GLYPHSETS, + CONF_IGNORE_MISSING_GLYPHS, + CONF_RAW_GLYPH_ID, + FONT_CACHE, + flatten, + glyph_comparator, + to_code, + validate_font_config, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_GLYPHS, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_SIZE, + CONF_TYPE, +) + +FONT_DIR = Path(__file__).parent +FONT_PATH = FONT_DIR / "NotoSans-Regular.ttf" + +# 200 unique CJK Unified Ideograph characters (U+4E00..U+4EC7) +CHINESE_200 = "".join(chr(cp) for cp in range(0x4E00, 0x4EC8)) + + +def _file_conf() -> dict: + return {CONF_PATH: str(FONT_PATH), CONF_TYPE: "local"} + + +def _make_config( + glyphs: list[str], + *, + ignore_missing: bool = False, + size: int = 20, + bpp: int = 1, + extras: list | None = None, + glyphsets: list | None = None, +) -> dict: + """Build a config dict matching what FONT_SCHEMA produces.""" + return { + CONF_FILE: _file_conf(), + CONF_GLYPHS: glyphs, + CONF_GLYPHSETS: glyphsets or [], + CONF_IGNORE_MISSING_GLYPHS: ignore_missing, + CONF_SIZE: size, + CONF_BPP: bpp, + CONF_EXTRAS: extras or [], + } + + +@pytest.fixture(autouse=True) +def _load_font(): + """Load the test font into FONT_CACHE and clean up afterwards.""" + fc = _file_conf() + FONT_CACHE[fc] = FONT_PATH + yield + FONT_CACHE.store.clear() + + +# ---------- flatten / glyph_comparator helpers ---------- + + +def test_flatten_splits_chinese_string_into_chars(): + """A single string of 200 Chinese characters must become 200 individual chars.""" + result = flatten([CHINESE_200]) + assert len(result) == 200 + assert all(len(c) == 1 for c in result) + assert result[0] == "\u4e00" + assert result[-1] == "\u4ec7" + + +def test_flatten_multiple_chinese_strings(): + """Multiple glyph strings are concatenated then split correctly.""" + s1 = CHINESE_200[:100] + s2 = CHINESE_200[100:] + result = flatten([list(s1), list(s2)]) + assert len(result) == 200 + + +def test_glyph_comparator_orders_chinese_by_utf8(): + """glyph_comparator must order CJK characters by their UTF-8 byte sequence.""" + chars = list(CHINESE_200[:10]) + sorted_chars = sorted(chars, key=functools.cmp_to_key(glyph_comparator)) + # CJK block is contiguous and UTF-8 order matches codepoint order here + assert sorted_chars == chars + + +def test_glyph_comparator_mixed_ascii_and_chinese(): + """ASCII characters sort before CJK characters (lower UTF-8 bytes).""" + assert glyph_comparator("A", "\u4e00") == -1 + assert glyph_comparator("\u4e00", "A") == 1 + assert glyph_comparator("\u4e00", "\u4e00") == 0 + + +# ---------- validate_font_config ---------- + + +def test_long_chinese_glyphs_raises_missing_error(): + """200 Chinese chars not present in NotoSans must raise Invalid with the correct count.""" + config = _make_config([CHINESE_200]) + with pytest.raises(cv.Invalid, match=r"missing 200 glyphs"): + validate_font_config(config) + + +def test_long_chinese_glyphs_error_mentions_overflow(): + """When more than 10 glyphs are missing the error should mention the remainder.""" + config = _make_config([CHINESE_200]) + with pytest.raises(cv.Invalid, match=r"and 190 more"): + validate_font_config(config) + + +def test_duplicate_chinese_glyphs_detected(): + """Duplicate CJK characters within a single glyph string must be caught.""" + duped = "\u4e00\u4e01\u4e00" # first char repeated + config = _make_config([duped]) + with pytest.raises(cv.Invalid, match="duplicate"): + validate_font_config(config) + + +def test_duplicate_chinese_across_strings(): + """Duplicates across separate glyph strings are also caught.""" + config = _make_config(["\u4e00\u4e01", "\u4e01\u4e02"]) + with pytest.raises(cv.Invalid, match="duplicate"): + validate_font_config(config) + + +def test_no_false_duplicates_in_200_unique_chinese(): + """200 unique CJK characters must not trigger the duplicate check.""" + config = _make_config([CHINESE_200]) + # Should not raise duplicate error — it should reach the missing-glyph check instead + with pytest.raises(cv.Invalid, match="missing"): + validate_font_config(config) + + +def test_valid_latin_glyphs_pass_validation(): + """Latin characters present in NotoSans-Regular pass validation without error.""" + config = _make_config(["ABCabc123"]) + result = validate_font_config(config) + assert result is not None + assert result[CONF_SIZE] == 20 + + +def test_long_latin_glyphs_pass_validation(): + """A long string of supported Latin glyphs passes validation.""" + # 95 printable ASCII characters that NotoSans supports + latin = "".join(chr(cp) for cp in range(0x21, 0x7F)) + config = _make_config([latin]) + result = validate_font_config(config) + assert result is not None + + +def test_mixed_latin_and_chinese_glyphs_error(): + """Mixing valid Latin and invalid Chinese chars reports missing Chinese glyphs.""" + chinese_10 = CHINESE_200[:10] + config = _make_config(["ABC", chinese_10]) + with pytest.raises(cv.Invalid, match=r"missing 10 glyphs"): + validate_font_config(config) + + +def test_single_chinese_char_glyph(): + """A single Chinese character is correctly handled as one glyph.""" + config = _make_config(["\u4e00"]) + with pytest.raises(cv.Invalid, match=r"missing 1 glyph[^s]"): + validate_font_config(config) + + +def test_chinese_glyphs_as_individual_list_items(): + """Chinese chars provided as separate list items are handled the same as a single string.""" + chars_as_list = list(CHINESE_200[:50]) + config = _make_config(chars_as_list) + with pytest.raises(cv.Invalid, match=r"missing 50 glyphs"): + validate_font_config(config) + + +# ---------- YAML parsing ---------- + + +def test_yaml_long_latin_glyphs_parsed_and_validated(tmp_path): + """200 Latin Extended chars on a single YAML line are parsed intact and pass validation.""" + from esphome.yaml_util import load_yaml + + latin_long = "".join(chr(cp) for cp in range(0x100, 0x1C8)) + yaml_file = tmp_path / "font_test.yaml" + yaml_file.write_text( + f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{latin_long}"\n', + encoding="utf-8", + ) + + parsed = load_yaml(yaml_file) + raw_glyphs = parsed["font"][0]["glyphs"] + + # YAML must preserve every Unicode character on the single line + assert raw_glyphs == latin_long + assert len(raw_glyphs) == 200 + + # Feed through validate_font_config to confirm all glyphs are accepted + config = _make_config([raw_glyphs]) + result = validate_font_config(config) + assert result is not None + + +@pytest.mark.parametrize( + "glyphs_str", + [ + " ABC", # space at start + "AB CD", # space in middle + "ABC ", # space at end + ], + ids=["start", "middle", "end"], +) +def test_yaml_space_in_glyphs_preserved(tmp_path, glyphs_str): + """A space character in a glyphs string must survive YAML round-trip and validation.""" + from esphome.yaml_util import load_yaml + + yaml_file = tmp_path / "font_test.yaml" + yaml_file.write_text( + f'font:\n - file: "NotoSans-Regular.ttf"\n glyphs: "{glyphs_str}"\n', + encoding="utf-8", + ) + + parsed = load_yaml(yaml_file) + raw_glyphs = parsed["font"][0]["glyphs"] + + assert raw_glyphs == glyphs_str + assert " " in raw_glyphs + + # Space and ASCII letters are all in NotoSans — validation must pass + config = _make_config([raw_glyphs]) + result = validate_font_config(config) + assert result is not None + + +# ---------- to_code generation ---------- + + +# 200 unique Latin Extended characters (U+0100..U+01C7), all present in NotoSans +LATIN_LONG = "".join(chr(cp) for cp in range(0x100, 0x1C8)) + + +@pytest.fixture +def mock_cg(): + """Mock all cg codegen functions used by to_code.""" + with ( + patch("esphome.components.font.cg.add_define") as mock_define, + patch("esphome.components.font.cg.progmem_array") as mock_progmem, + patch("esphome.components.font.cg.static_const_array") as mock_static, + patch("esphome.components.font.cg.new_Pvariable") as mock_new_pvar, + ): + mock_progmem.return_value = MagicMock() + mock_static.return_value = MagicMock() + yield { + "add_define": mock_define, + "progmem_array": mock_progmem, + "static_const_array": mock_static, + "new_Pvariable": mock_new_pvar, + } + + +@pytest.mark.asyncio +async def test_to_code_long_latin_generates_all_glyphs(mock_cg): + """to_code must generate glyph data for every character in a long Latin string.""" + glyph_count = len(LATIN_LONG) # 200 + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + # USE_FONT define must be emitted + mock_cg["add_define"].assert_any_call("USE_FONT") + + # progmem_array receives the combined bitmap data (non-empty) + mock_cg["progmem_array"].assert_called_once() + bitmap_data = mock_cg["progmem_array"].call_args.args[1] + assert len(bitmap_data) > 0 + + # static_const_array receives one entry per unique glyph + mock_cg["static_const_array"].assert_called_once() + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + assert len(glyph_initializer) == glyph_count + + # new_Pvariable is called with the correct glyph count + mock_cg["new_Pvariable"].assert_called_once() + pvar_args = mock_cg["new_Pvariable"].call_args.args + assert pvar_args[2] == glyph_count # len(glyph_initializer) + assert pvar_args[8] == 1 # bpp + + +@pytest.mark.asyncio +async def test_to_code_glyph_entries_contain_expected_fields(mock_cg): + """Each glyph initializer entry must have 7 fields: codepoint, data ptr, advance, offset_x, offset_y, w, h.""" + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + for entry in glyph_initializer: + assert len(entry) == 7, f"Glyph entry should have 7 fields, got {len(entry)}" + codepoint = entry[0] + assert isinstance(codepoint, int) + assert 0x100 <= codepoint <= 0x1C7 + + +@pytest.mark.asyncio +async def test_to_code_glyphs_sorted_by_utf8(mock_cg): + """Glyphs in the initializer must be sorted by UTF-8 byte order.""" + config = _make_config([LATIN_LONG]) + config[CONF_ID] = MagicMock() + config[CONF_RAW_DATA_ID] = MagicMock() + config[CONF_RAW_GLYPH_ID] = MagicMock() + + await to_code(config) + + glyph_initializer = mock_cg["static_const_array"].call_args.args[1] + codepoints = [entry[0] for entry in glyph_initializer] + assert codepoints == sorted(codepoints) diff --git a/tests/component_tests/light/__init__.py b/tests/component_tests/light/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/light/test_effect_validation.py b/tests/component_tests/light/test_effect_validation.py new file mode 100644 index 0000000000..579e92c62a --- /dev/null +++ b/tests/component_tests/light/test_effect_validation.py @@ -0,0 +1,280 @@ +"""Tests for light effect name validation.""" + +from __future__ import annotations + +from collections.abc import Generator +from contextvars import Token + +import pytest + +from esphome import config_validation as cv +from esphome.components.light import ( + EffectRef, + _final_validate, + _get_data, + available_effects_str, + find_effect_index, +) +from esphome.components.light.automation import _record_effect_ref +from esphome.config import Config, path_context +from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME +from esphome.core import ID, Lambda +import esphome.final_validate as fv +from esphome.types import ConfigType + + +def _make_effects(*names: str) -> list[dict[str, dict[str, str]]]: + """Create a list of effect config dicts from names.""" + return [{f"effect_{i}": {CONF_NAME: name}} for i, name in enumerate(names)] + + +# --- find_effect_index --- + + +def test_find_effect_index_found() -> None: + effects = _make_effects("Fast Pulse", "Slow Pulse") + assert find_effect_index(effects, "Fast Pulse") == 1 + assert find_effect_index(effects, "Slow Pulse") == 2 + + +def test_find_effect_index_case_insensitive() -> None: + effects = _make_effects("Fast Pulse") + assert find_effect_index(effects, "fast pulse") == 1 + assert find_effect_index(effects, "FAST PULSE") == 1 + + +def test_find_effect_index_not_found() -> None: + effects = _make_effects("Fast Pulse", "Slow Pulse") + assert find_effect_index(effects, "Missing") is None + + +def test_find_effect_index_empty() -> None: + assert find_effect_index([], "anything") is None + + +# --- available_effects_str --- + + +def test_available_effects_str_multiple() -> None: + effects = _make_effects("Fast Pulse", "Slow Pulse") + assert available_effects_str(effects) == "'Fast Pulse', 'Slow Pulse'" + + +def test_available_effects_str_single() -> None: + effects = _make_effects("Fast Pulse") + assert available_effects_str(effects) == "'Fast Pulse'" + + +def test_available_effects_str_empty() -> None: + assert available_effects_str([]) == "none" + + +# --- _final_validate --- + + +def _setup_final_validate( + effect_refs: list[EffectRef], + light_configs: list[ConfigType], + declare_ids: list[tuple[ID, list[str | int]]], +) -> Token: + """Set up CORE.data and fv.full_config for _final_validate tests.""" + data = _get_data() + data.effect_refs = effect_refs + + full_conf = Config() + full_conf["light"] = light_configs + for id_, path in declare_ids: + full_conf.declare_ids.append((id_, path)) + + return fv.full_config.set(full_conf) + + +def test_final_validate_valid_effect() -> None: + """Valid effect name should not raise.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Fast Pulse", component_path=["esphome"] + ), + ], + light_configs=[ + {CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse", "Slow Pulse")} + ], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_invalid_effect_raises() -> None: + """Invalid effect name should raise FinalExternalInvalid.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Nonexistent", component_path=["esphome"] + ), + ], + light_configs=[ + {CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse", "Slow Pulse")} + ], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="Nonexistent"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_lists_available_effects() -> None: + """Error message should list available effects.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Missing", component_path=["esphome"] + ), + ], + light_configs=[ + {CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse", "Slow Pulse")} + ], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="'Fast Pulse', 'Slow Pulse'"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_no_effects_on_light() -> None: + """Light with no effects should report 'none' as available.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Missing", component_path=["esphome"] + ), + ], + light_configs=[{CONF_ID: light_id}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + with pytest.raises(cv.FinalExternalInvalid, match="Available effects: none"): + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_no_refs_is_noop() -> None: + """No stored refs should pass without error.""" + data = _get_data() + data.effect_refs = [] + _final_validate({}) + + +def test_final_validate_unknown_light_id_skipped() -> None: + """Refs to unknown light IDs should be silently skipped.""" + data = _get_data() + data.effect_refs = [ + EffectRef( + light_id=ID("nonexistent", is_declaration=True), + effect_name="Missing", + component_path=["esphome"], + ) + ] + + full_conf = Config() + token = fv.full_config.set(full_conf) + try: + _final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_final_validate_drains_refs() -> None: + """Refs should be drained after validation to avoid redundant runs.""" + light_id = ID("led1", is_declaration=True) + token = _setup_final_validate( + effect_refs=[ + EffectRef( + light_id=light_id, effect_name="Fast Pulse", component_path=["esphome"] + ), + ], + light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}], + declare_ids=[(light_id, ["light", 0, CONF_ID])], + ) + try: + _final_validate({}) + assert _get_data().effect_refs == [] + finally: + fv.full_config.reset(token) + + +# --- _record_effect_ref --- + + +@pytest.fixture +def _path_ctx() -> Generator[None]: + """Set path_context for _record_effect_ref tests.""" + token = path_context.set(["esphome"]) + yield + path_context.reset(token) + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_static() -> None: + """Static effect name should be recorded.""" + light_id = ID("led1", is_declaration=True) + config: ConfigType = {CONF_ID: light_id, CONF_EFFECT: "Fast Pulse"} + result = _record_effect_ref(config) + assert result is config + data = _get_data() + assert len(data.effect_refs) == 1 + assert data.effect_refs[0].effect_name == "Fast Pulse" + assert data.effect_refs[0].light_id is light_id + assert data.effect_refs[0].component_path == ["esphome"] + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_skips_lambda() -> None: + """Lambda effect should not be recorded.""" + config: ConfigType = { + CONF_ID: ID("led1", is_declaration=True), + CONF_EFFECT: Lambda("return effect;"), + } + _record_effect_ref(config) + assert _get_data().effect_refs == [] + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_skips_none() -> None: + """Effect 'None' should not be recorded.""" + config: ConfigType = { + CONF_ID: ID("led1", is_declaration=True), + CONF_EFFECT: "None", + } + _record_effect_ref(config) + assert _get_data().effect_refs == [] + + +@pytest.mark.usefixtures("_path_ctx") +def test_record_effect_ref_skips_none_case_insensitive() -> None: + """Effect 'none' (lowercase) should not be recorded.""" + config: ConfigType = { + CONF_ID: ID("led1", is_declaration=True), + CONF_EFFECT: "none", + } + _record_effect_ref(config) + assert _get_data().effect_refs == [] + + +def test_record_effect_ref_skips_no_effect_key() -> None: + """Config without effect key should be a no-op.""" + config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)} + _record_effect_ref(config) + assert _get_data().effect_refs == [] diff --git a/tests/components/bmp581_spi/common.yaml b/tests/components/bmp581_spi/common.yaml new file mode 100644 index 0000000000..f22074f867 --- /dev/null +++ b/tests/components/bmp581_spi/common.yaml @@ -0,0 +1,9 @@ +sensor: + - platform: bmp581_spi + cs_pin: ${cs_pin} + temperature: + name: BMP581 Temperature + iir_filter: 2x + pressure: + name: BMP581 Pressure + oversampling: 128x diff --git a/tests/components/bmp581_spi/test.esp32-idf.yaml b/tests/components/bmp581_spi/test.esp32-idf.yaml new file mode 100644 index 0000000000..a3352cf880 --- /dev/null +++ b/tests/components/bmp581_spi/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/bmp581_spi/test.esp8266-ard.yaml b/tests/components/bmp581_spi/test.esp8266-ard.yaml new file mode 100644 index 0000000000..595f31046a --- /dev/null +++ b/tests/components/bmp581_spi/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO15 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bmp581_spi/test.rp2040-ard.yaml b/tests/components/bmp581_spi/test.rp2040-ard.yaml new file mode 100644 index 0000000000..79ea6ce90b --- /dev/null +++ b/tests/components/bmp581_spi/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + cs_pin: GPIO5 + +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/font/.gitattributes b/tests/components/font/.gitattributes index 63ab00e9f2..4df6726184 100644 --- a/tests/components/font/.gitattributes +++ b/tests/components/font/.gitattributes @@ -1 +1,2 @@ -*.pcf -text +*.pcf -text +*.ttf -text diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 61fef8201d..37779f23e6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -5,7 +5,13 @@ from unittest.mock import patch import pytest -from esphome.automation import has_non_synchronous_actions +from esphome.automation import ( + TriggerForwarder, + TriggerOnFalseForwarder, + TriggerOnTrueForwarder, + has_non_synchronous_actions, +) +from esphome.cpp_generator import MockObj, RawExpression from esphome.util import RegistryEntry @@ -175,3 +181,76 @@ def test_has_non_synchronous_actions_dict_input( """Direct dict input (single action).""" assert has_non_synchronous_actions({"delay": "1s"}) is True assert has_non_synchronous_actions({"logger.log": "hello"}) is False + + +def _build_forwarder( + automation_name: str, + args: list[tuple[str, str]], + forwarder: MockObj | None = None, +) -> str: + """Build a trigger forwarder expression the same way build_callback_automation does. + + Mirrors the forwarder selection logic in automation.build_callback_automation. + """ + import esphome.codegen as cg + + obj = MockObj(automation_name, "->") + if forwarder is None: + arg_types = [RawExpression(t) for t, _ in args] + templ = ( + cg.TemplateArguments(*arg_types) if arg_types else cg.TemplateArguments() + ) + forwarder = TriggerForwarder.template(templ) + return f"{forwarder}{{{obj}}}" + + +def test_trigger_forwarder_no_args() -> None: + """Button on_press: TriggerForwarder<> with no args.""" + result = _build_forwarder("auto_1", []) + assert result == "TriggerForwarder<>{auto_1}" + + +def test_trigger_forwarder_single_float_arg() -> None: + """Sensor on_value: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("float", "x")]) + assert result == "TriggerForwarder{auto_1}" + + +def test_trigger_forwarder_single_bool_arg() -> None: + """Switch on_state: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("bool", "x")]) + assert result == "TriggerForwarder{auto_1}" + + +def test_trigger_forwarder_on_true() -> None: + """Binary_sensor on_press / switch on_turn_on: TriggerOnTrueForwarder.""" + result = _build_forwarder("auto_1", [], forwarder=TriggerOnTrueForwarder) + assert result == "TriggerOnTrueForwarder{auto_1}" + + +def test_trigger_forwarder_on_false() -> None: + """Binary_sensor on_release / switch on_turn_off: TriggerOnFalseForwarder.""" + result = _build_forwarder("auto_1", [], forwarder=TriggerOnFalseForwarder) + assert result == "TriggerOnFalseForwarder{auto_1}" + + +def test_trigger_forwarder_multiple_args() -> None: + """Binary_sensor on_state_change: TriggerForwarder with two args.""" + result = _build_forwarder( + "auto_1", + [("optional", "x_previous"), ("optional", "x")], + ) + assert result == "TriggerForwarder, optional>{auto_1}" + + +def test_trigger_forwarder_string_arg() -> None: + """Text_sensor on_value: TriggerForwarder.""" + result = _build_forwarder("auto_1", [("std::string", "x")]) + assert result == "TriggerForwarder{auto_1}" + + +def test_trigger_forwarder_custom_type() -> None: + """Custom forwarder type passed directly.""" + custom = MockObj("MyForwarder", "") + result = _build_forwarder("auto_1", [], forwarder=custom) + assert result == "MyForwarder{auto_1}" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 6ace38a7d7..940a394c08 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -990,6 +990,47 @@ def test_clean_all_ignores_empty_env_vars( assert marker.exists() +@patch("esphome.writer.CORE") +def test_clean_all_no_args_with_esphome_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with no args cleans .esphome in cwd.""" + esphome_dir = tmp_path / ".esphome" + esphome_dir.mkdir() + (esphome_dir / "dummy.txt").write_text("x") + + from esphome.writer import clean_all + + with ( + caplog.at_level("INFO"), + patch("esphome.writer.Path.cwd", return_value=tmp_path), + ): + clean_all([]) + + assert esphome_dir.exists() + assert not (esphome_dir / "dummy.txt").exists() + + +@patch("esphome.writer.CORE") +def test_clean_all_no_args_no_esphome_dir( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with no args and no .esphome dir warns.""" + from esphome.writer import clean_all + + with ( + caplog.at_level("WARNING"), + patch("esphome.writer.Path.cwd", return_value=tmp_path), + ): + clean_all([]) + + assert "No configuration files specified" in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all( mock_core: MagicMock,