Merge branch 'dev' into precompute-tag-forced-varint-fields

This commit is contained in:
J. Nick Koston
2026-03-23 15:21:23 -10:00
committed by GitHub
11 changed files with 164 additions and 123 deletions
@@ -23,9 +23,8 @@ static const LogString *gpio_mode_to_string(bool use_interrupt) {
void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) {
bool new_state = arg->isr_pin_.digital_read();
if (new_state != arg->last_state_) {
if (new_state != arg->state_) {
arg->state_ = new_state;
arg->last_state_ = new_state;
arg->changed_ = true;
// Wake up the component from its disabled loop state
if (arg->component_ != nullptr) {
@@ -34,28 +33,27 @@ void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) {
}
}
void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, gpio::InterruptType type, Component *component) {
void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) {
pin->setup();
this->isr_pin_ = pin->to_isr();
this->component_ = component;
// Read initial state
this->last_state_ = pin->digital_read();
this->state_ = this->last_state_;
this->state_ = pin->digital_read();
// Attach interrupt - from this point on, any changes will be caught by the interrupt
pin->attach_interrupt(&GPIOBinarySensorStore::gpio_intr, this, type);
pin->attach_interrupt(&GPIOBinarySensorStore::gpio_intr, this, this->interrupt_type_);
}
void GPIOBinarySensor::setup() {
if (this->use_interrupt_ && !this->pin_->is_internal()) {
if (this->store_.use_interrupt_ && !this->pin_->is_internal()) {
ESP_LOGD(TAG, "GPIO is not internal, falling back to polling mode");
this->use_interrupt_ = false;
this->store_.use_interrupt_ = false;
}
if (this->use_interrupt_) {
if (this->store_.use_interrupt_) {
auto *internal_pin = static_cast<InternalGPIOPin *>(this->pin_);
this->store_.setup(internal_pin, this->interrupt_type_, this);
this->store_.setup(internal_pin, this);
this->publish_initial_state(this->store_.get_state());
} else {
this->pin_->setup();
@@ -66,14 +64,14 @@ void GPIOBinarySensor::setup() {
void GPIOBinarySensor::dump_config() {
LOG_BINARY_SENSOR("", "GPIO Binary Sensor", this);
LOG_PIN(" Pin: ", this->pin_);
ESP_LOGCONFIG(TAG, " Mode: %s", LOG_STR_ARG(gpio_mode_to_string(this->use_interrupt_)));
if (this->use_interrupt_) {
ESP_LOGCONFIG(TAG, " Interrupt Type: %s", LOG_STR_ARG(interrupt_type_to_string(this->interrupt_type_)));
ESP_LOGCONFIG(TAG, " Mode: %s", LOG_STR_ARG(gpio_mode_to_string(this->store_.use_interrupt_)));
if (this->store_.use_interrupt_) {
ESP_LOGCONFIG(TAG, " Interrupt Type: %s", LOG_STR_ARG(interrupt_type_to_string(this->store_.interrupt_type_)));
}
}
void GPIOBinarySensor::loop() {
if (this->use_interrupt_) {
if (this->store_.use_interrupt_) {
if (this->store_.is_changed()) {
// Clear the flag immediately to minimize the window where we might miss changes
this->store_.clear_changed();
@@ -8,10 +8,10 @@
namespace esphome {
namespace gpio {
// Store class for ISR data (no vtables, ISR-safe)
// Store class for ISR data and configuration (no vtables, ISR-safe)
class GPIOBinarySensorStore {
public:
void setup(InternalGPIOPin *pin, gpio::InterruptType type, Component *component);
void setup(InternalGPIOPin *pin, Component *component);
static void gpio_intr(GPIOBinarySensorStore *arg);
@@ -32,11 +32,13 @@ class GPIOBinarySensorStore {
}
protected:
friend class GPIOBinarySensor;
ISRInternalGPIOPin isr_pin_;
volatile bool state_{false};
volatile bool last_state_{false};
volatile bool changed_{false};
Component *component_{nullptr}; // Pointer to the component for enable_loop_soon_any_context()
volatile bool state_{false};
volatile bool changed_{false};
bool use_interrupt_{true};
gpio::InterruptType interrupt_type_{gpio::INTERRUPT_ANY_EDGE};
};
class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component {
@@ -44,9 +46,9 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon
// No destructor needed: ESPHome components are created at boot and live forever.
// Interrupts are only detached on reboot when memory is cleared anyway.
void set_pin(GPIOPin *pin) { pin_ = pin; }
void set_use_interrupt(bool use_interrupt) { use_interrupt_ = use_interrupt; }
void set_interrupt_type(gpio::InterruptType type) { interrupt_type_ = type; }
void set_pin(GPIOPin *pin) { this->pin_ = pin; }
void set_use_interrupt(bool use_interrupt) { this->store_.use_interrupt_ = use_interrupt; }
void set_interrupt_type(gpio::InterruptType type) { this->store_.interrupt_type_ = type; }
// ========== INTERNAL METHODS ==========
// (In most use cases you won't need these)
/// Setup pin
@@ -59,8 +61,6 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon
protected:
GPIOPin *pin_;
bool use_interrupt_{true};
gpio::InterruptType interrupt_type_{gpio::INTERRUPT_ANY_EDGE};
GPIOBinarySensorStore store_;
};
+1 -1
View File
@@ -748,7 +748,7 @@ void HonClimate::update_sub_sensor_(SubSensorType type, float value) {
if (type < SubSensorType::SUB_SENSOR_TYPE_COUNT) {
size_t index = (size_t) type;
if ((this->sub_sensors_[index] != nullptr) &&
((!this->sub_sensors_[index]->has_state()) || (this->sub_sensors_[index]->raw_state != value)))
((!this->sub_sensors_[index]->has_state()) || (this->sub_sensors_[index]->get_raw_state() != value)))
this->sub_sensors_[index]->publish_state(value);
}
}
+16 -16
View File
@@ -322,22 +322,6 @@ class LightState : public EntityBase, public Component {
FixedVector<LightEffect *> effects_;
/// Object used to store the persisted values of the light.
ESPPreferenceObject rtc_;
/// Value for storing the index of the currently active effect. 0 if no effect is active
uint32_t active_effect_index_{};
/// Default transition length for all transitions in ms.
uint32_t default_transition_length_{};
/// Transition length to use for flash transitions.
uint32_t flash_transition_length_{};
/// Gamma correction factor for the light.
float gamma_correct_{};
#ifdef USE_LIGHT_GAMMA_LUT
const uint16_t *gamma_table_{nullptr};
#endif // USE_LIGHT_GAMMA_LUT
/// Whether the light value should be written in the next cycle.
bool next_write_{true};
// for effects, true if a transformer (transition) is active.
bool is_transformer_active_ = false;
/** Listeners for remote values changes.
*
@@ -361,6 +345,22 @@ class LightState : public EntityBase, public Component {
/// Initial state of the light.
optional<LightStateRTCState> initial_state_{};
/// Value for storing the index of the currently active effect. 0 if no effect is active
uint32_t active_effect_index_{};
/// Default transition length for all transitions in ms.
uint32_t default_transition_length_{};
/// Transition length to use for flash transitions.
uint32_t flash_transition_length_{};
/// Gamma correction factor for the light.
float gamma_correct_{};
#ifdef USE_LIGHT_GAMMA_LUT
const uint16_t *gamma_table_{nullptr};
#endif // USE_LIGHT_GAMMA_LUT
/// Whether the light value should be written in the next cycle.
bool next_write_{true};
// for effects, true if a transformer (transition) is active.
bool is_transformer_active_{false};
/// Restore mode of the light.
LightRestoreMode restore_mode_;
};
@@ -85,10 +85,6 @@ void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) {
}
this->publish_state(published_state);
} else {
this->raw_state = state;
this->state = state;
this->set_has_state(true);
}
}
this->update_component_settings();
+7 -2
View File
@@ -300,9 +300,14 @@ def do_packages_pass(config: dict, skip_update: bool = False) -> dict:
context_vars = package_config.vars
if CONF_PACKAGES in package_config or CONF_URL in package_config:
# Remote package definition: eagerly resolve before PACKAGE_SCHEMA validation.
from esphome.components.substitutions import substitute_context_vars
from esphome.components.substitutions import ContextVars, substitute
substitute_context_vars(package_config, context_vars)
package_config = substitute(
package_config,
[],
ContextVars(context_vars),
strict_undefined=False,
)
package_config = PACKAGE_SCHEMA(package_config)
if isinstance(package_config, str):
return package_config # Jinja string, skip processing
+8 -2
View File
@@ -40,7 +40,10 @@ const LogString *state_class_to_string(StateClass state_class) {
return StateClassStrings::get_log_str(static_cast<uint8_t>(state_class), 0);
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
Sensor::Sensor() : state(NAN), raw_state(NAN) {}
#pragma GCC diagnostic pop
int8_t Sensor::get_accuracy_decimals() {
if (this->sensor_flags_.has_accuracy_override)
@@ -63,8 +66,13 @@ StateClass Sensor::get_state_class() {
}
void Sensor::publish_state(float state) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->raw_state = state;
#pragma GCC diagnostic pop
#ifdef USE_SENSOR_FILTER
this->raw_callback_.call(state);
#endif
ESP_LOGV(TAG, "'%s': Received new state %f", this->name_.c_str(), state);
@@ -110,8 +118,6 @@ void Sensor::clear_filters() {
this->filter_list_ = nullptr;
}
#endif // USE_SENSOR_FILTER
float Sensor::get_state() const { return this->state; }
float Sensor::get_raw_state() const { return this->raw_state; }
void Sensor::internal_send_state_to_frontend(float state) {
this->set_has_state(true);
+21 -7
View File
@@ -95,9 +95,14 @@ class Sensor : public EntityBase {
#endif
/// Getter-syntax for .state.
float get_state() const;
float get_state() const { return this->state; }
/// Getter-syntax for .raw_state
float get_raw_state() const;
float get_raw_state() const {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return this->raw_state;
#pragma GCC diagnostic pop
}
/** Publish a new state to the front-end.
*
@@ -113,8 +118,14 @@ class Sensor : public EntityBase {
/// Add a callback that will be called every time a filtered value arrives.
template<typename F> void add_on_state_callback(F &&callback) { this->callback_.add(std::forward<F>(callback)); }
/// Add a callback that will be called every time the sensor sends a raw value.
/// When USE_SENSOR_FILTER is not enabled, delegates to the regular callback
/// since raw state equals filtered state without filter support compiled in.
template<typename F> void add_on_raw_state_callback(F &&callback) {
#ifdef USE_SENSOR_FILTER
this->raw_callback_.add(std::forward<F>(callback));
#else
this->callback_.add(std::forward<F>(callback));
#endif
}
/** This member variable stores the last state that has passed through all filters.
@@ -126,17 +137,20 @@ class Sensor : public EntityBase {
*/
float state;
/** This member variable stores the current raw state of the sensor, without any filters applied.
*
* Unlike .state,this will be updated immediately when publish_state is called.
*/
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
/// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0.
ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0")
float raw_state;
#pragma GCC diagnostic pop
void internal_send_state_to_frontend(float state);
protected:
#ifdef USE_SENSOR_FILTER
LazyCallbackManager<void(float)> raw_callback_; ///< Storage for raw state callbacks.
LazyCallbackManager<void(float)> callback_; ///< Storage for filtered state callbacks.
#endif
LazyCallbackManager<void(float)> callback_; ///< Storage for filtered state callbacks.
#ifdef USE_SENSOR_FILTER
Filter *filter_list_{nullptr}; ///< Store all active filters.
+35 -58
View File
@@ -81,12 +81,6 @@ def _restore_data_base(value: Any, orig_value: ESPHomeDataBase) -> ESPHomeDataBa
return value
def _try_substitute(value: Any, context: ContextVars) -> Any:
"""Substitute variables in value, returning the result or the original if unchanged."""
result = _substitute_item(value, [], context, strict_undefined=True)
return result if result is not None else value
def _resolve_var(name: str, context_vars: ContextVars) -> Any:
"""Look up a substitution variable, falling back to the resolver callback."""
sub = context_vars.get(name, Missing)
@@ -253,7 +247,7 @@ def _push_context(
if value is Missing:
return Missing
try:
value = _try_substitute(value, resolver_context)
value = substitute(value, [], resolver_context, True)
except UndefinedError as err:
unresolvables[key] = (value, err)
return Missing
@@ -297,68 +291,51 @@ def push_context(
return parent_context
def _substitute_item(
def substitute(
item: Any,
path: SubstitutionPath,
parent_context: ContextVars,
strict_undefined: bool,
errors: ErrList | None = None,
) -> Any | None:
"""Recursively substitute variables in a config item.
) -> Any:
"""Returns a recursively substituted version of `item`."""
Walks dicts, lists, strings, Lambdas, Extend, and Remove nodes,
replacing variable references with values from context_vars.
Mutates containers in-place; returns a replacement value for
strings/scalars, or None if the item was unchanged.
"""
if isinstance(item, ESPLiteralValue):
return item # do not substitute inside literal blocks
def _walk(item: Any, path: SubstitutionPath, parent_ctx: ContextVars) -> Any | None:
if isinstance(item, ESPLiteralValue):
return None # do not substitute inside literal blocks
# Push the current item's context onto the context stack
context_vars = push_context(item, parent_context, errors)
ctx = push_context(item, parent_ctx, errors)
result = item
if isinstance(item, list):
for idx, it in enumerate(item):
sub = _walk(it, path + [idx], ctx)
if sub is not None:
item[idx] = sub
elif isinstance(item, dict):
replace_keys: list[tuple[str, Any]] = []
for k, v in item.items():
if path or k != CONF_SUBSTITUTIONS:
sub = _walk(k, path + [k], ctx)
if sub is not None:
replace_keys.append((k, sub))
sub = _walk(v, path + [k], ctx)
if sub is not None:
item[k] = sub
for old, new in replace_keys:
if str(new) == str(old):
item[new] = item[old]
else:
item[new] = merge_config(item.get(new), item.get(old))
del item[old]
elif isinstance(item, str):
sub = _expand_substitutions(item, path, ctx, strict_undefined, errors)
if not isinstance(sub, str) or sub != item:
return sub
elif isinstance(item, (core.Lambda, Extend, Remove)) and item.value:
sub = _expand_substitutions(item.value, path, ctx, strict_undefined, errors)
if sub != item.value:
item.value = sub
return None
if isinstance(item, list):
result = [
substitute(it, path + [i], context_vars, strict_undefined, errors)
for i, it in enumerate(item)
]
return _walk(item, path, parent_context)
elif isinstance(item, dict):
result = OrderedDict()
for k, v in item.items():
v = substitute(v, path + [k], context_vars, strict_undefined, errors)
k = substitute(k, path + [k], context_vars, strict_undefined, errors)
result[k] = merge_config(result.get(k), v)
elif isinstance(item, str):
result = _expand_substitutions(
item, path, context_vars, strict_undefined, errors
)
def substitute_context_vars(node: Any, context_vars: dict[str, Any]) -> None:
"""Eagerly substitute context vars into a config node in-place.
elif isinstance(item, (core.Lambda, Extend, Remove)) and item.value:
value = _expand_substitutions(
item.value, path, context_vars, strict_undefined, errors
)
if item.value != value:
result = type(item)(value)
Undefined variables are silently ignored — this is used before
the main substitution pass when not all variables are visible yet.
"""
_substitute_item(node, [], ContextVars(context_vars), strict_undefined=False)
if isinstance(item, ESPHomeDataBase):
result = make_data_base(result, item)
return result
def _warn_unresolved_variables(errors: ErrList) -> None:
@@ -387,7 +364,7 @@ def do_substitution_pass(
Extracts the ``substitutions:`` block, merges in any command-line
overrides, resolves inter-variable dependencies, then walks the
config tree replacing all ``$var`` / ``${expr}`` references.
Returns the (mutated) config dict with resolved substitutions
Returns a new config dict with resolved substitutions
restored at the front.
"""
# Extract substitutions from config, overriding with substitutions coming from command line:
@@ -415,7 +392,7 @@ def do_substitution_pass(
errors: ErrList = [] # Collect undefined errors during substitution
parent_context, substitutions = _push_context(substitutions, ContextVars(), errors)
_substitute_item(config, [], parent_context, False, errors)
config = substitute(config, [], parent_context, False, errors)
if errors:
_warn_unresolved_variables(errors)
@@ -664,11 +664,22 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
config.show_hidden = 1;
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0)
config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE;
// Use shorter dwell times for roaming scans - we only need to detect strong
// nearby APs, not do a thorough survey. This also reduces off-channel time
// which can cause Beacon Timeout disconnects on some APs.
// Roaming times match the ESP32 IDF scan defaults.
static constexpr uint32_t SCAN_PASSIVE_DEFAULT_MS = 500;
static constexpr uint32_t SCAN_PASSIVE_ROAMING_MS = 300;
static constexpr uint32_t SCAN_ACTIVE_MIN_DEFAULT_MS = 400;
static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500;
static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100;
static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300;
bool roaming = this->roaming_state_ == RoamingState::SCANNING;
if (passive) {
config.scan_time.passive = 500;
config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS;
} else {
config.scan_time.active.min = 400;
config.scan_time.active.max = 500;
config.scan_time.active.min = roaming ? SCAN_ACTIVE_MIN_ROAMING_MS : SCAN_ACTIVE_MIN_DEFAULT_MS;
config.scan_time.active.max = roaming ? SCAN_ACTIVE_MAX_ROAMING_MS : SCAN_ACTIVE_MAX_DEFAULT_MS;
}
#endif
bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback);
+40 -6
View File
@@ -550,8 +550,8 @@ def test_lambda_substitution() -> None:
"lambda": lam,
}
)
substitutions.do_substitution_pass(config)
assert lam.value == "return 42;"
config = substitutions.do_substitution_pass(config)
assert config["lambda"].value == "return 42;"
def test_lambda_no_substitution_unchanged() -> None:
@@ -564,8 +564,8 @@ def test_lambda_no_substitution_unchanged() -> None:
"lambda": lam,
}
)
substitutions.do_substitution_pass(config)
assert lam.value is original_value
config = substitutions.do_substitution_pass(config)
assert config["lambda"].value is original_value
def test_extend_substitution() -> None:
@@ -577,8 +577,42 @@ def test_extend_substitution() -> None:
"sensor": ext,
}
)
substitutions.do_substitution_pass(config)
assert ext.value == "my_sensor"
config = substitutions.do_substitution_pass(config)
assert config["sensor"].value == "my_sensor"
def test_substitute_does_not_mutate_input() -> None:
"""substitute() must return a new tree without modifying the original."""
inner_list = ["${var}", "static"]
inner_dict = OrderedDict({"key": "${var}"})
lam = Lambda("return ${var};")
config = OrderedDict(
{
"a_list": inner_list,
"a_dict": inner_dict,
"a_lambda": lam,
"plain": "${var}",
}
)
context = substitutions.ContextVars({"var": "replaced"})
result = substitutions.substitute(config, [], context, strict_undefined=True)
# Result has substitutions applied
assert result["plain"] == "replaced"
assert result["a_list"] == ["replaced", "static"]
assert result["a_dict"]["key"] == "replaced"
assert result["a_lambda"].value == "return replaced;"
# Original input is untouched
assert config["plain"] == "${var}"
assert inner_list == ["${var}", "static"]
assert inner_dict["key"] == "${var}"
assert lam.value == "return ${var};"
# Containers are new objects, not the originals
assert result["a_list"] is not inner_list
assert result["a_dict"] is not inner_dict
assert result["a_lambda"] is not lam
def test_do_substitution_pass_substitutions_must_be_mapping_from_config() -> None: