Merge branch 'dev' into api-sint32-short-varint

This commit is contained in:
J. Nick Koston
2026-03-29 12:24:04 -10:00
committed by GitHub
18 changed files with 678 additions and 126 deletions
+172 -2
View File
@@ -239,6 +239,123 @@ This document provides essential context for AI models interacting with this pro
var = await switch.new_switch(config)
```
* **Automations (Triggers, Actions, Conditions):**
Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
* **Triggers -- Callback method (preferred):**
Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema.
**Python:**
```python
from esphome import automation
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
for conf in config.get(CONF_ON_STATE, []):
await automation.build_callback_automation(
var, "add_on_state_callback", [(bool, "x")], conf
)
```
`build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder<Ts...>`).
For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`:
```python
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
)
```
**C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
```cpp
class MyComponent : public Component {
public:
// Must be a template -- accepts both std::function and pointer-sized forwarder structs
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
protected:
// Use CallbackManager when callbacks are always registered (e.g. core components)
CallbackManager<void(bool)> state_callback_;
// Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
// (nullptr vs empty std::vector) per instance when no callbacks are added
// LazyCallbackManager<void(bool)> state_callback_;
};
```
* **Triggers -- Trigger class method:**
Use `build_automation()` with a `Trigger<Ts...>` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
**Python:**
```python
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
CONFIG_SCHEMA = cv.Schema({
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
})
async def to_code(config):
for conf in config.get(CONF_ON_TURN_ON, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
```
**C++:**
```cpp
class TurnOnTrigger : public Trigger<> {
public:
explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
parent->add_on_state_callback([this](bool state) {
if (state && !this->last_on_)
this->trigger();
this->last_on_ = state;
});
}
protected:
bool last_on_;
};
```
* **Actions:**
```cpp
template<typename... Ts> class MyAction : public Action<Ts...> {
public:
explicit MyAction(MyComponent *parent) : parent_(parent) {}
void play(const Ts &...) override { this->parent_->do_something(); }
protected:
MyComponent *parent_;
};
```
Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
* **Conditions:**
```cpp
template<typename... Ts> class MyCondition : public Condition<Ts...> {
public:
explicit MyCondition(MyComponent *parent) : parent_(parent) {}
bool check(const Ts &...) override { return this->parent_->is_active(); }
protected:
MyComponent *parent_;
};
```
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
* **Configuration Validation:**
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
@@ -274,10 +391,39 @@ This document provides essential context for AI models interacting with this pro
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
```
tests/
├── test_build_components/ # Base test configurations
└── components/[component]/ # Component-specific tests
├── test_build_components/
└── common/ # Shared bus packages (uart, i2c, spi, etc.)
│ ├── uart/ # UART at default baud rate
│ ├── uart_115200/ # UART at 115200 baud
│ ├── i2c/ # I2C bus
│ └── spi/ # SPI bus
└── components/[component]/
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml
├── test.esp8266-ard.yaml
└── test.rp2040-ard.yaml
```
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
```yaml
# test.esp32-idf.yaml — use packages for buses
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
```
```yaml
# common.yaml — component config only, NO bus definitions
my_component:
id: my_instance
sensor:
- platform: my_component
name: My Sensor
```
Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
```bash
./script/test_component_grouping.py -e config --all
@@ -417,6 +563,30 @@ This document provides essential context for AI models interacting with this pro
Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`).
**Callback Managers:**
ESPHome provides two callback manager types in `esphome/core/helpers.h` for the observer pattern. Both support `std::function`, lambdas, and lightweight forwarder structs via their templatized `add()` method.
| Type | Idle overhead (32-bit) | When to use |
|------|----------------------|-------------|
| `CallbackManager<void(Ts...)>` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered |
| `LazyCallbackManager<void(Ts...)>` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) |
`LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
**Important:** Registration methods that add to a callback manager **must always be templatized** to accept both `std::function` and pointer-sized forwarder structs (used by `build_callback_automation`). Never use `std::function` in the method signature:
```cpp
// Bad -- forces heap allocation for forwarder structs
void add_on_state_callback(std::function<void(bool)> &&callback) {
this->state_callback_.add(std::move(callback));
}
// Good -- accepts any callable without forcing std::function wrapping
template<typename F> void add_on_state_callback(F &&callback) {
this->state_callback_.add(std::forward<F>(callback));
}
```
* **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals.
**Bad Pattern (Module-Level Globals):**
@@ -32,13 +32,6 @@ void BinarySensor::publish_initial_state(bool new_state) {
this->invalidate_state();
this->publish_state(new_state);
}
void BinarySensor::send_state_internal(bool new_state) {
// copy the new state to the visible property for backwards compatibility, before any callbacks
this->state = new_state;
// Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed
this->set_new_state(new_state);
}
bool BinarySensor::set_new_state(const optional<bool> &new_state) {
if (StatefulEntityBase::set_new_state(new_state)) {
// weirdly, this file could be compiled even without USE_BINARY_SENSOR defined
@@ -32,7 +32,10 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi
*/
class BinarySensor : public StatefulEntityBase<bool> {
public:
explicit BinarySensor(){};
explicit BinarySensor() = default;
const bool &get_state() const override { return this->state; }
void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; }
/** Publish a new state to the front-end.
*
@@ -54,16 +57,24 @@ class BinarySensor : public StatefulEntityBase<bool> {
// ========== INTERNAL METHODS ==========
// (In most use cases you won't need these)
void send_state_internal(bool new_state);
void send_state_internal(bool new_state) {
// Fast path: skip virtual dispatch when state hasn't changed
if (this->flags_.has_state && this->state == new_state)
return;
this->set_new_state(new_state);
}
/// Return whether this binary sensor has outputted a state.
virtual bool is_status_binary_sensor() const;
// For backward compatibility, provide an accessible property
/// The current state of this binary sensor. Also used as the backing storage for StatefulEntityBase.
bool state{};
protected:
bool get_trigger_on_initial_state() const override { return this->trigger_on_initial_state_; }
void set_state_value(const bool &value) override { this->state = value; }
bool trigger_on_initial_state_{true};
#ifdef USE_BINARY_SENSOR_FILTER
Filter *filter_list_{nullptr};
#endif
@@ -73,7 +84,7 @@ class BinarySensor : public StatefulEntityBase<bool> {
class BinarySensorInitiallyOff : public BinarySensor {
public:
bool has_state() const override { return true; }
BinarySensorInitiallyOff() { this->set_has_state(true); }
};
} // namespace esphome::binary_sensor
+51 -11
View File
@@ -1,5 +1,6 @@
import logging
from pathlib import Path
import re
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -18,8 +19,9 @@ from esphome.const import (
PLATFORM_ESP8266,
ThreadModel,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
from esphome.helpers import copy_file_if_changed
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS
from .const import (
@@ -40,12 +42,42 @@ from .const import (
)
from .gpio import PinInitialState, add_pin_initial_states_array
CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float"
# Heuristically matches scanf/sscanf calls with float format specifiers.
# Standard scanf float conversions: %f %F %e %E %g %G %a %A
# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length)
# Also matches non-standard patterns like %.2f as a heuristic — these are
# invalid in scanf but users may write them by analogy with printf.
# Uses [^;]*? to stay within a single statement, preventing false positives
# from e.g. sscanf(buf, "%d", &x); printf("%f", val);
_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]")
CODEOWNERS = ["@esphome/core"]
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["preferences"]
IS_TARGET_PLATFORM = True
def lambdas_use_scanf_float(config: ConfigType) -> bool:
"""Check if any lambda in the config uses scanf with a float format specifier.
Comments are stripped before matching to avoid false positives from
commented-out code. The cost of a false positive is only ~8KB flash.
"""
stack: list = [config]
while stack:
obj = stack.pop()
if isinstance(obj, Lambda):
src = obj.comment_remover(obj.value)
if _SCANF_FLOAT_RE.search(src):
return True
elif isinstance(obj, dict):
stack.extend(obj.values())
elif isinstance(obj, list):
stack.extend(obj)
return False
def set_core_data(config):
CORE.data[KEY_ESP8266] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266
@@ -181,6 +213,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_SERIAL): cv.boolean,
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
}
),
set_core_data,
@@ -201,16 +234,23 @@ async def to_code(config):
cg.add_define("ESPHOME_VARIANT", "ESP8266")
cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option(
"extra_scripts",
[
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:remove_float_scanf.py",
"post:post_build.py",
],
)
enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT)
if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config):
enable_scanf_float = True
_LOGGER.warning(
"Lambda uses scanf with a float format specifier; "
"enabling scanf float support (~8KB flash)"
)
extra_scripts = [
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
+4 -2
View File
@@ -381,7 +381,7 @@ async def filter_out_filter_to_code(config, filter_id):
if not isinstance(config, list):
config = [config]
template_ = [await cg.templatable(x, [], float) for x in config]
return cg.new_Pvariable(filter_id, template_)
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(template_)), template_)
QUANTILE_SCHEMA = cv.All(
@@ -650,7 +650,9 @@ async def throttle_with_priority_filter_to_code(config, filter_id):
if not isinstance(config[CONF_VALUE], list):
config[CONF_VALUE] = [config[CONF_VALUE]]
template_ = [await cg.templatable(x, [], float) for x in config[CONF_VALUE]]
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_)
return cg.new_Pvariable(
filter_id, cg.TemplateArguments(len(template_)), config[CONF_TIMEOUT], template_
)
HEARTBEAT_SCHEMA = cv.Schema(
+11 -27
View File
@@ -222,16 +222,14 @@ MultiplyFilter::MultiplyFilter(TemplatableValue<float> multiplier) : multiplier_
optional<float> MultiplyFilter::new_value(float value) { return value * this->multiplier_.value(); }
// ValueListFilter (base class)
ValueListFilter::ValueListFilter(std::initializer_list<TemplatableValue<float>> values) : values_(values) {}
bool ValueListFilter::value_matches_any_(float sensor_value) {
int8_t accuracy = this->parent_->get_accuracy_decimals();
// ValueListFilter helper (non-template, shared by all ValueListFilter<N> instantiations)
bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue<float> *values, size_t count) {
int8_t accuracy = parent->get_accuracy_decimals();
float accuracy_mult = pow10_int(accuracy);
float rounded_sensor = roundf(accuracy_mult * sensor_value);
for (auto &filter_value : this->values_) {
float fv = filter_value.value();
for (size_t i = 0; i < count; i++) {
float fv = values[i].value();
// Handle NaN comparison
if (std::isnan(fv)) {
@@ -248,16 +246,6 @@ bool ValueListFilter::value_matches_any_(float sensor_value) {
return false;
}
// FilterOutValueFilter
FilterOutValueFilter::FilterOutValueFilter(std::initializer_list<TemplatableValue<float>> values_to_filter_out)
: ValueListFilter(values_to_filter_out) {}
optional<float> FilterOutValueFilter::new_value(float value) {
if (this->value_matches_any_(value))
return {}; // Filter out
return value; // Pass through
}
// ThrottleFilter
ThrottleFilter::ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {}
optional<float> ThrottleFilter::new_value(float value) {
@@ -269,17 +257,13 @@ optional<float> ThrottleFilter::new_value(float value) {
return {};
}
// ThrottleWithPriorityFilter
ThrottleWithPriorityFilter::ThrottleWithPriorityFilter(
uint32_t min_time_between_inputs, std::initializer_list<TemplatableValue<float>> prioritized_values)
: ValueListFilter(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {}
optional<float> ThrottleWithPriorityFilter::new_value(float value) {
// ThrottleWithPriorityFilter helper (non-template, keeps App access in .cpp)
optional<float> throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue<float> *values,
size_t count, uint32_t &last_input, uint32_t min_time_between_inputs) {
const uint32_t now = App.get_loop_component_start_time();
// Allow value through if: no previous input, time expired, or is prioritized
if (this->last_input_ == 0 || now - this->last_input_ >= min_time_between_inputs_ ||
this->value_matches_any_(value)) {
this->last_input_ = now;
if (last_input == 0 || now - last_input >= min_time_between_inputs ||
value_list_matches_any(parent, value, values, count)) {
last_input = now;
return value;
}
return {};
+37 -14
View File
@@ -3,6 +3,7 @@
#include "esphome/core/defines.h"
#ifdef USE_SENSOR_FILTER
#include <array>
#include <utility>
#include <vector>
#include "esphome/core/automation.h"
@@ -328,28 +329,42 @@ class MultiplyFilter : public Filter {
TemplatableValue<float> multiplier_;
};
/** Base class for filters that compare sensor values against a list of configured values.
/// Non-template helper for value matching (implementation in filter.cpp)
bool value_list_matches_any(Sensor *parent, float sensor_value, const TemplatableValue<float> *values, size_t count);
/** Base class for filters that compare sensor values against a fixed list of configured values.
*
* This base class provides common functionality for filters that need to check if a sensor
* value matches any value in a configured list, with proper handling of NaN values and
* accuracy-based rounding for comparisons.
* Templated on N (the number of values) so the list is stored inline in a std::array,
* avoiding heap allocation and the overhead of FixedVector.
*
* @tparam N Number of values in the filter list, set by code generation to match
* the exact number of values configured in YAML.
*/
class ValueListFilter : public Filter {
template<size_t N> class ValueListFilter : public Filter {
protected:
explicit ValueListFilter(std::initializer_list<TemplatableValue<float>> values);
explicit ValueListFilter(std::initializer_list<TemplatableValue<float>> values) {
init_array_from(this->values_, values);
}
/// Check if sensor value matches any configured value (with accuracy rounding)
bool value_matches_any_(float sensor_value);
bool value_matches_any_(float sensor_value) {
return value_list_matches_any(this->parent_, sensor_value, this->values_.data(), N);
}
FixedVector<TemplatableValue<float>> values_;
std::array<TemplatableValue<float>, N> values_{};
};
/// A simple filter that only forwards the filter chain if it doesn't receive `value_to_filter_out`.
class FilterOutValueFilter : public ValueListFilter {
template<size_t N> class FilterOutValueFilter : public ValueListFilter<N> {
public:
explicit FilterOutValueFilter(std::initializer_list<TemplatableValue<float>> values_to_filter_out);
explicit FilterOutValueFilter(std::initializer_list<TemplatableValue<float>> values_to_filter_out)
: ValueListFilter<N>(values_to_filter_out) {}
optional<float> new_value(float value) override;
optional<float> new_value(float value) override {
if (this->value_matches_any_(value))
return {}; // Filter out
return value; // Pass through
}
};
class ThrottleFilter : public Filter {
@@ -363,13 +378,21 @@ class ThrottleFilter : public Filter {
uint32_t min_time_between_inputs_;
};
/// Non-template helper for ThrottleWithPriorityFilter (implementation in filter.cpp)
optional<float> throttle_with_priority_new_value(Sensor *parent, float value, const TemplatableValue<float> *values,
size_t count, uint32_t &last_input, uint32_t min_time_between_inputs);
/// Same as 'throttle' but will immediately publish values contained in `value_to_prioritize`.
class ThrottleWithPriorityFilter : public ValueListFilter {
template<size_t N> class ThrottleWithPriorityFilter : public ValueListFilter<N> {
public:
explicit ThrottleWithPriorityFilter(uint32_t min_time_between_inputs,
std::initializer_list<TemplatableValue<float>> prioritized_values);
std::initializer_list<TemplatableValue<float>> prioritized_values)
: ValueListFilter<N>(prioritized_values), min_time_between_inputs_(min_time_between_inputs) {}
optional<float> new_value(float value) override;
optional<float> new_value(float value) override {
return throttle_with_priority_new_value(this->parent_, value, this->values_.data(), N, this->last_input_,
this->min_time_between_inputs_);
}
protected:
uint32_t last_input_{0};
@@ -1,6 +1,5 @@
#include "version_text_sensor.h"
#include "esphome/core/application.h"
#include "esphome/core/build_info_data.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -36,7 +35,9 @@ void VersionTextSensor::setup() {
if (!this->hide_timestamp_) {
size_t len = strlen(version_str);
ESPHOME_strncat_P(version_str, BUILT_STR, sizeof(version_str) - len - 1);
ESPHOME_strncat_P(version_str, ESPHOME_BUILD_TIME_STR, sizeof(version_str) - strlen(version_str) - 1);
char build_time_buf[Application::BUILD_TIME_STR_SIZE];
App.get_build_time_string(build_time_buf);
strncat(version_str, build_time_buf, sizeof(version_str) - strlen(version_str) - 1);
}
// The closing parenthesis is part of the config-hash suffix and must
@@ -135,7 +135,7 @@ class WebServerBase {
uint16_t get_port() const { return port_; }
protected:
int initialized_{0};
uint8_t initialized_{0};
uint16_t port_{80};
AsyncWebServer *server_{nullptr};
std::vector<AsyncWebHandler *> handlers_;
+16 -8
View File
@@ -84,6 +84,8 @@ void store_component_error_message(const Component *component, const char *messa
static constexpr uint16_t WARN_IF_BLOCKING_INCREMENT_MS =
10U; ///< How long the blocking time must be larger to warn again
// Threshold in ms (computed from centiseconds constant in component.h)
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
float Component::get_setup_priority() const { return setup_priority::DATA; }
@@ -268,15 +270,18 @@ void Component::call() {
break;
}
}
const LogString *Component::get_component_log_str() const {
return component_source_lookup(this->component_source_index_);
}
bool Component::should_warn_of_blocking(uint32_t blocking_time) {
if (blocking_time > this->warn_if_blocking_over_) {
// Prevent overflow when adding increment - if we're about to overflow, just max out
if (blocking_time + WARN_IF_BLOCKING_INCREMENT_MS < blocking_time ||
blocking_time + WARN_IF_BLOCKING_INCREMENT_MS > std::numeric_limits<uint16_t>::max()) {
this->warn_if_blocking_over_ = std::numeric_limits<uint16_t>::max();
} else {
this->warn_if_blocking_over_ = static_cast<uint16_t>(blocking_time + WARN_IF_BLOCKING_INCREMENT_MS);
}
// Convert centisecond threshold to milliseconds for comparison
uint32_t threshold_ms = static_cast<uint32_t>(this->warn_if_blocking_over_) * 10U;
if (blocking_time > threshold_ms) {
// Set new threshold: blocking_time + increment, converted back to centiseconds
uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS;
uint32_t new_cs = new_threshold_ms / 10U;
// Saturate at uint8_t max (255 = 2550ms)
this->warn_if_blocking_over_ = static_cast<uint8_t>(new_cs > 255U ? 255U : new_cs);
return true;
}
return false;
@@ -537,4 +542,7 @@ void clear_setup_priority_overrides() {
}
#endif
// Weak default for component_source_lookup - overridden by generated code
__attribute__((weak)) const LogString *component_source_lookup(uint8_t) { return LOG_STR("<unknown>"); }
} // namespace esphome
+24 -13
View File
@@ -11,6 +11,10 @@
#include "esphome/core/log.h"
#include "esphome/core/optional.h"
// Forward declarations for friend access from codegen-generated setup()
void setup(); // NOLINT(readability-redundant-declaration) - may be declared in Arduino.h
void original_setup(); // NOLINT(readability-redundant-declaration)
namespace esphome {
// Forward declaration for LogString
@@ -79,11 +83,14 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
// Component loop override flag uses bit 5 (set at registration time)
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
// Remove before 2026.8.0
enum class RetryResult { DONE, RETRY };
inline constexpr uint16_t WARN_IF_BLOCKING_OVER_MS = 50U;
inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds (1cs = 10ms)
/// Lookup component source name by index (1-based). Generated by Python codegen.
/// Weak default returns "<unknown>" so builds without codegen still link.
const LogString *component_source_lookup(uint8_t index);
class Component {
public:
@@ -275,23 +282,25 @@ class Component {
bool has_overridden_loop() const { return (this->component_state_ & COMPONENT_HAS_LOOP) != 0; }
/** Set where this component was loaded from for some debug messages.
*
* This is set by the ESPHome core, and should not be called manually.
*/
void set_component_source(const LogString *source) { component_source_ = source; }
/** Get the integration where this component was declared as a LogString for logging.
*
* Returns LOG_STR("<unknown>") if source not set
*/
const LogString *get_component_log_str() const {
return this->component_source_ == nullptr ? LOG_STR("<unknown>") : this->component_source_;
}
const LogString *get_component_log_str() const;
bool should_warn_of_blocking(uint32_t blocking_time);
protected:
friend class Application;
friend void ::setup();
friend void ::original_setup();
/** Set where this component was loaded from for some debug messages.
*
* This is set by the ESPHome core during setup, and should not be called manually.
* @param index 1-based index into the component source lookup table (0 = not set)
*/
void set_component_source_(uint8_t index) { this->component_source_index_ = index; }
virtual void call_setup();
void call_dump_config_();
@@ -509,9 +518,9 @@ class Component {
void status_clear_warning_slow_path_();
void status_clear_error_slow_path_();
// Ordered for optimal packing on 32-bit systems
const LogString *component_source_{nullptr};
uint16_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_MS}; ///< Warn if blocked for this many ms (max 65.5s)
// Ordered for optimal packing on 32-bit systems (8 bytes total with vtable)
uint8_t component_source_index_{0}; ///< Index into component source PROGMEM lookup table (0 = not set)
uint8_t warn_if_blocking_over_{WARN_IF_BLOCKING_OVER_CS}; ///< Warn threshold in centiseconds (max 2550ms)
/// State of this component - each bit has a purpose:
/// Bits 0-2: Component state (0x00=CONSTRUCTION, 0x01=SETUP, 0x02=LOOP, 0x03=FAILED, 0x04=LOOP_DONE)
/// Bit 3: STATUS_LED_WARNING
@@ -588,6 +597,8 @@ class WarnIfComponentBlockingGuard {
this->record_runtime_stats_();
#endif
#ifndef USE_BENCHMARK
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
warn_blocking(this->component_, blocking_time);
}
+61 -27
View File
@@ -296,15 +296,36 @@ void log_entity_device_class(const char *tag, const char *prefix, const EntityBa
#define LOG_ENTITY_UNIT_OF_MEASUREMENT(tag, prefix, obj) log_entity_unit_of_measurement(tag, prefix, obj)
void log_entity_unit_of_measurement(const char *tag, const char *prefix, const EntityBase &obj);
/**
* An entity that has a state.
* @tparam T The type of the state
/** Base class for entities that track a typed state value with change-detection and callbacks.
*
* This class does not store the state value — subclasses own their storage. Whether a state
* has been set is tracked by EntityBase::has_state().
*
* Subclasses must implement:
* - get_state(): return a const reference to the current value
* - set_state_value(): store a new value (called only when the state actually changes)
* - get_trigger_on_initial_state(): return whether callbacks should fire on the first state
*
* Subclasses may override set_new_state() to add behavior (logging, notifications) after calling
* the base implementation. Since set_new_state() is virtual, callers like invalidate_state()
* dispatch through the vtable to the subclass override in the .cpp, avoiding template code
* bloat at inline call sites. Subclasses may also add a fast-path dedup check before calling
* set_new_state() to skip virtual dispatch entirely when the state hasn't changed.
*
* Callback behavior:
* - full_state_callbacks_: fired on every change, receives optional<T> previous and current
* - state_callbacks_: fired only when the new state has a value, and either this is not the
* first state (had_state) or trigger_on_initial_state is set
*
* @tparam T The type of the state value
*/
template<typename T> class StatefulEntityBase : public EntityBase {
public:
virtual bool has_state() const { return this->state_.has_value(); }
virtual const T &get_state() const { return this->state_.value(); } // NOLINT(bugprone-unchecked-optional-access)
virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); }
/// Return the current state value. Only valid when has_state() is true.
virtual const T &get_state() const = 0;
/// Return the current state if available, otherwise return the provided default.
T get_state_default(T default_value) const { return this->has_state() ? this->get_state() : default_value; }
/// Clear the state — sets has_state() to false and fires callbacks with nullopt.
void invalidate_state() { this->set_new_state({}); }
template<typename F> void add_full_state_callback(F &&callback) {
@@ -314,33 +335,46 @@ template<typename T> class StatefulEntityBase : public EntityBase {
this->state_callbacks_.add(std::forward<F>(callback));
}
void set_trigger_on_initial_state(bool trigger_on_initial_state) {
this->trigger_on_initial_state_ = trigger_on_initial_state;
}
protected:
optional<T> state_{};
/**
* Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous.
/// Subclasses return whether callbacks should fire on the very first state.
virtual bool get_trigger_on_initial_state() const = 0;
/** Apply a new state, de-duplicating and firing callbacks as needed.
*
* @param new_state The new state.
* @return True if the state was changed, false if it was the same as before.
* Pass nullopt to invalidate (clear) the state. Pass a value to set it.
* Returns true if the state actually changed, false if it was the same.
* Subclasses may override to add logging/notifications after calling the base.
*/
virtual bool set_new_state(const optional<T> &new_state) {
if (this->state_ != new_state) {
// call the full state callbacks with the previous and new state
this->full_state_callbacks_.call(this->state_, new_state);
// trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or
// the previous state was valid
auto had_state = this->has_state();
this->state_ = new_state;
if (new_state.has_value() && (this->trigger_on_initial_state_ || had_state))
this->state_callbacks_.call(new_state.value());
return true;
// Access flags_ directly to avoid function call overhead in this hot path
bool had_state = this->flags_.has_state;
// Use pointer to avoid requiring T to be default-constructible
const T *current = had_state ? &this->get_state() : nullptr;
if (new_state.has_value()) {
if (current != nullptr && *current == new_state.value())
return false; // same value, no change
} else if (!had_state) {
return false; // already invalidated, no change
}
return false;
// Capture old_state before set_state_value — current pointer aliases subclass storage
bool has_full_cbs = !this->full_state_callbacks_.empty();
optional<T> old_state;
if (has_full_cbs)
old_state = current != nullptr ? optional<T>(*current) : nullopt;
// Update storage before firing callbacks so callback code can inspect current state
this->flags_.has_state = new_state.has_value();
if (new_state.has_value()) {
this->set_state_value(new_state.value());
}
if (has_full_cbs)
this->full_state_callbacks_.call(old_state, new_state);
// had_state first: on every change except the first, skips the virtual call
if (new_state.has_value() && (had_state || this->get_trigger_on_initial_state()))
this->state_callbacks_.call(new_state.value());
return true;
}
bool trigger_on_initial_state_{true};
/// Subclasses implement this to store the actual value into their own storage.
virtual void set_state_value(const T &value) = 0;
LazyCallbackManager<void(optional<T> previous, optional<T> current)> full_state_callbacks_;
LazyCallbackManager<void(T)> state_callbacks_;
};
+18
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <array>
#include <cassert>
#include <cmath>
#include <cstdarg>
#include <cstdint>
@@ -497,6 +498,23 @@ template<typename T, size_t MAX_CAPACITY = std::numeric_limits<uint16_t>::max()>
index_type capacity_{0};
};
/// Initialize a std::array from an initializer_list. Uses memcpy for trivially copyable types (optimal codegen),
/// falls back to element-wise copy for non-trivially copyable types (e.g. TemplatableValue).
/// N is set by code generation; assert catches mismatches in debug/integration tests.
template<typename T, size_t N> inline void init_array_from(std::array<T, N> &dest, std::initializer_list<T> src) {
#ifdef ESPHOME_DEBUG
assert(src.size() == N);
#endif
if constexpr (std::is_trivially_copyable_v<T>) {
__builtin_memcpy(dest.data(), src.begin(), N * sizeof(T));
} else {
size_t i = 0;
for (const auto &v : src) {
dest[i++] = v;
}
}
}
/// Fixed-capacity vector - allocates once at runtime, never reallocates
/// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append)
/// when size is known at initialization but not at compile time
+120 -3
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass, field
import logging
from esphome.const import (
@@ -7,15 +8,130 @@ from esphome.const import (
CONF_UPDATE_INTERVAL,
KEY_PAST_SAFE_MODE,
)
from esphome.core import CORE, ID, coroutine
from esphome.core import CORE, ID, CoroPriority, coroutine, coroutine_with_priority
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import LogStringLiteral, add, add_define, get_variable
from esphome.cpp_generator import (
RawStatement,
add,
add_define,
add_global,
get_variable,
)
from esphome.cpp_types import App
from esphome.helpers import cpp_string_escape
from esphome.types import ConfigFragmentType, ConfigType
from esphome.util import Registry, RegistryEntry
_LOGGER = logging.getLogger(__name__)
_COMPONENT_SOURCE_DOMAIN = "component_source_pool"
# Maximum unique component source names (8-bit index, 0 = not set)
_MAX_COMPONENT_SOURCES = 0xFF # 255
@dataclass
class ComponentSourcePool:
"""Pool of component source names for PROGMEM lookup table.
Source names are registered during to_code() and assigned 1-based indices.
Index 0 means "not set" (returns LOG_STR("<unknown>")). At render time,
the pool generates a C++ PROGMEM table + lookup function.
"""
sources: dict[str, int] = field(default_factory=dict)
table_registered: bool = False
def _get_source_pool() -> ComponentSourcePool:
"""Get or create the component source pool from CORE.data."""
if _COMPONENT_SOURCE_DOMAIN not in CORE.data:
CORE.data[_COMPONENT_SOURCE_DOMAIN] = ComponentSourcePool()
return CORE.data[_COMPONENT_SOURCE_DOMAIN]
def _ensure_source_table_registered() -> None:
"""Schedule the table generation job (once)."""
pool = _get_source_pool()
if pool.table_registered:
return
pool.table_registered = True
CORE.add_job(_generate_component_source_table)
def register_component_source(name: str) -> int:
"""Register a component source name and return its 1-based index.
Deduplicates: multiple components from the same source share one index.
"""
if not name:
return 0
pool = _get_source_pool()
if name in pool.sources:
return pool.sources[name]
idx = len(pool.sources) + 1
if idx > _MAX_COMPONENT_SOURCES:
_LOGGER.warning(
"Too many unique component source names (max %d), '%s' will show as '<unknown>'",
_MAX_COMPONENT_SOURCES,
name,
)
return 0
pool.sources[name] = idx
_ensure_source_table_registered()
return idx
def _generate_source_table_code(
table_var: str,
lookup_fn: str,
strings: dict[str, int],
) -> str:
"""Generate C++ PROGMEM table + LogString* lookup for component sources.
Same pattern as entity_helpers._generate_category_code but returns
const LogString* instead of const char* (needed for LOG_STR_ARG).
"""
if not strings:
return ""
sorted_strings = sorted(strings.items(), key=lambda x: x[1])
count = len(sorted_strings)
# Emit individual PROGMEM char arrays so string data lives in flash on ESP8266
lines: list[str] = []
var_names: list[str] = []
for i, (s, _) in enumerate(sorted_strings):
var_name = f"{table_var}_STR_{i}"
var_names.append(var_name)
lines.append(
f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};"
)
entries = ", ".join(var_names)
lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{")
lines.append(f' if (index == 0 || index > {count}) return LOG_STR("<unknown>");')
lines.append(" return reinterpret_cast<const LogString *>(")
lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));")
lines.append("}")
return "\n".join(lines) + "\n"
@coroutine_with_priority(CoroPriority.FINAL)
async def _generate_component_source_table() -> None:
"""Generate the component source lookup table as a FINAL-priority job.
Runs after all component to_code() calls have registered their sources.
"""
pool = _get_source_pool()
if code := _generate_source_table_code(
"COMP_SRC_TABLE", "component_source_lookup", pool.sources
):
add_global(
RawStatement(f"namespace esphome {{\n{code}}} // namespace esphome")
)
async def gpio_pin_expression(conf):
"""Generate an expression for the given pin option.
@@ -77,7 +193,8 @@ async def register_component(var, config):
"Error while finding name of component, please report this", exc_info=e
)
if name is not None:
add(var.set_component_source(LogStringLiteral(name)))
idx = register_component_source(name)
add(var.set_component_source_(idx))
add(App.register_component_(var))
@@ -14,3 +14,6 @@ esphome:
assert(x == 95);
x = clamp_at_most(x, 40);
assert(x == 40);
- lambda: |-
float value = 0.0f;
sscanf("3.14", "%f", &value);
+9 -2
View File
@@ -73,9 +73,16 @@ async def test_uart_mock_ld2410(
],
)
# Signal when we see recovery frame values
# Signal when we see ALL recovery frame values to avoid race where some
# arrive after the waiter fires but before we index into the lists
recovery_received = collector.add_waiter(
lambda: pytest.approx(50.0) in collector.sensor_states["moving_distance"]
lambda: (
pytest.approx(50.0) in collector.sensor_states["moving_distance"]
and pytest.approx(75.0) in collector.sensor_states["still_distance"]
and pytest.approx(100.0) in collector.sensor_states["moving_energy"]
and pytest.approx(80.0) in collector.sensor_states["still_energy"]
and pytest.approx(127.0) in collector.sensor_states["detection_distance"]
)
)
async with (
@@ -0,0 +1,62 @@
"""Tests for ESP8266 component."""
import pytest
from esphome.components.esp8266 import lambdas_use_scanf_float
from esphome.core import Lambda
from esphome.types import ConfigType
@pytest.mark.parametrize(
("src", "expected"),
[
# Basic float formats
('sscanf(buf, "%f", &v)', True),
('sscanf(buf, "%F", &v)', True),
('sscanf(buf, "%e", &v)', True),
('sscanf(buf, "%E", &v)', True),
('sscanf(buf, "%g", &v)', True),
('sscanf(buf, "%G", &v)', True),
('sscanf(buf, "%a", &v)', True),
('sscanf(buf, "%A", &v)', True),
# With modifiers
('sscanf(buf, "%lf", &v)', True),
('sscanf(buf, "%Lf", &v)', True),
('sscanf(buf, "%8lf", &v)', True),
('sscanf(buf, "%*f")', True),
('sscanf(buf, "%.2f", &v)', True),
# Mixed formats
('sscanf(buf, "%d,%f", &a, &b)', True),
# fscanf and std::sscanf
('fscanf(fp, "%f", &v)', True),
('std::sscanf(buf, "%f", &v)', True),
# Multi-line
('sscanf(buf,\n"%f", &v)', True),
# No float format
('sscanf(buf, "%d", &v)', False),
('sscanf(buf, "%s", s)', False),
# printf not scanf
('printf("%f", val)', False),
# %f in a different statement after scanf
('sscanf(buf, "%d", &x); printf("%f", val);', False),
# scanf %f in comment only
('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False),
('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False),
],
)
def test_lambdas_use_scanf_float(src: str, expected: bool) -> None:
"""Test scanf float detection in lambda source."""
config: ConfigType = {"test": [Lambda(src)]}
assert lambdas_use_scanf_float(config) is expected
def test_lambdas_use_scanf_float_no_lambdas() -> None:
"""Test with config containing no lambdas."""
config: ConfigType = {"key": "value", "list": [1, 2]}
assert lambdas_use_scanf_float(config) is False
def test_lambdas_use_scanf_float_nested() -> None:
"""Test detection in deeply nested config."""
config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}}
assert lambdas_use_scanf_float(config) is True
+70 -2
View File
@@ -1,8 +1,10 @@
import logging
from unittest.mock import Mock
import pytest
from esphome import const, cpp_helpers as ch
from esphome.cpp_helpers import ComponentSourcePool, register_component_source
@pytest.mark.asyncio
@@ -23,7 +25,7 @@ async def test_register_component(monkeypatch):
app_mock = Mock(register_component_=Mock(return_value=var))
monkeypatch.setattr(ch, "App", app_mock)
core_mock = Mock(component_ids=["foo.bar"])
core_mock = Mock(component_ids=["foo.bar"], data={})
monkeypatch.setattr(ch, "CORE", core_mock)
add_mock = Mock()
@@ -59,7 +61,7 @@ async def test_register_component__with_setup_priority(monkeypatch):
app_mock = Mock(register_component_=Mock(return_value=var))
monkeypatch.setattr(ch, "App", app_mock)
core_mock = Mock(component_ids=["foo.bar"])
core_mock = Mock(component_ids=["foo.bar"], data={})
monkeypatch.setattr(ch, "CORE", core_mock)
add_mock = Mock()
@@ -78,3 +80,69 @@ async def test_register_component__with_setup_priority(monkeypatch):
assert add_mock.call_count == 4
app_mock.register_component_.assert_called_with(var)
assert core_mock.component_ids == []
def test_register_component_source_empty_name(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ch, "CORE", Mock(data={}))
assert register_component_source("") == 0
def test_register_component_source_deduplicates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(ch, "CORE", Mock(data={}))
idx1 = register_component_source("wifi")
idx2 = register_component_source("api")
idx3 = register_component_source("wifi")
assert idx1 == 1
assert idx2 == 2
assert idx3 == 1 # deduplicated
def test_generate_source_table_code_empty() -> None:
from esphome.cpp_helpers import _generate_source_table_code
assert _generate_source_table_code("TBL", "lookup", {}) == ""
def test_generate_source_table_code_non_empty() -> None:
from esphome.cpp_helpers import _generate_source_table_code
code = _generate_source_table_code("TBL", "lookup", {"wifi": 1, "api": 2})
assert "PROGMEM" in code
assert "wifi" in code
assert "api" in code
assert "lookup" in code
assert "index == 0" in code
assert "progmem_read_ptr" in code
assert "index > 2" in code
@pytest.mark.asyncio
async def test_generate_component_source_table_empty_pool(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test that _generate_component_source_table does nothing with an empty pool."""
from esphome.cpp_helpers import _generate_component_source_table
monkeypatch.setattr(ch, "CORE", Mock(data={}))
add_global_mock = Mock()
monkeypatch.setattr(ch, "add_global", add_global_mock)
await _generate_component_source_table()
add_global_mock.assert_not_called()
def test_register_component_source_overflow_warns(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
# Pre-fill pool to max
pool = ComponentSourcePool(
sources={f"comp_{i}": i + 1 for i in range(0xFF)},
table_registered=True,
)
monkeypatch.setattr(ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool}))
with caplog.at_level(logging.WARNING):
idx = register_component_source("overflow_component")
assert idx == 0
assert "Too many unique component source names" in caplog.text
assert "overflow_component" in caplog.text