Compare commits

...

17 Commits

Author SHA1 Message Date
Jonathan Swoboda
a593965372 Merge pull request #12380 from esphome/bump-2025.11.5
2025.11.5
2025-12-09 12:54:30 -05:00
Jonathan Swoboda
4743e5592a Bump version to 2025.11.5 2025-12-09 12:02:53 -05:00
Jonathan Swoboda
464607011c [mqtt] Fix logger method case sensitivity error (#12379)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-09 12:02:53 -05:00
J. Nick Koston
16fe8f9e9e [libretiny] Fix WiFi scan timeout loop when scan fails (#12356) 2025-12-09 12:02:46 -05:00
J. Nick Koston
436d2c44e8 [wifi] Fix scan timeout loop when scan returns zero networks (#12354) 2025-12-09 12:01:51 -05:00
J. Nick Koston
b213555dd2 [scheduler] Fix missing lock when recycling items in defer queue processing (#12343) 2025-12-09 12:01:51 -05:00
Clyde Stubbs
b6336f9e63 [lvgl] Number saves value on interactive change (#12315) 2025-12-09 12:01:51 -05:00
Clyde Stubbs
fb7800a22f [binary_sensor] Fix reporting of 'unknown' (#12296)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2025-12-09 12:01:51 -05:00
Jonathan Swoboda
42811edeb4 Merge pull request #12293 from esphome/bump-2025.11.4
2025.11.4
2025-12-04 22:54:55 -05:00
Jonathan Swoboda
8f20abebf6 Bump version to 2025.11.4 2025-12-04 21:52:48 -05:00
J. Nick Koston
7077488dc7 [scheduler] Fix use-after-free when cancelling timeouts from non-main-loop threads (#12288) 2025-12-04 21:52:48 -05:00
Jesse Hills
ef34239064 [CI] Trigger generic version notifier job on release (#12292) 2025-12-04 21:52:48 -05:00
Jonathan Swoboda
44148c0c6b [esp32_hosted] Fix build and bump IDF component version to 2.7.0 (#12282)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 21:52:48 -05:00
Jonathan Swoboda
1b53fcf634 [es8311] Remove MIN and MAX from mic_gain enum options (#12281)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 21:52:48 -05:00
Clyde Stubbs
b18e3d943a [config] Provide path for has_at_most_one_of messages (#12277) 2025-12-04 21:52:48 -05:00
Jonathan Swoboda
f0673f6304 [ld2420] Add missing USE_SELECT ifdefs (#12275)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-04 21:52:48 -05:00
Clyde Stubbs
320ba30d50 [esp32] Add build flag to suppress noexecstack message (#12272) 2025-12-04 21:52:48 -05:00
21 changed files with 403 additions and 57 deletions

View File

@@ -219,10 +219,19 @@ jobs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
let description = "ESPHome";
if (context.eventName == "release") {
@@ -245,10 +254,19 @@ jobs:
needs: [init]
environment: ${{ needs.init.outputs.deploy_env }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }}
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
@@ -259,3 +277,34 @@ jobs:
version: "${{ needs.init.outputs.tag }}",
}
})
version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: version-notifier
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
}
})

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2025.11.3
PROJECT_NUMBER = 2025.11.5
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -36,13 +36,20 @@ void BinarySensor::publish_initial_state(bool 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_state_ de-dups and will only trigger callbacks if the state has actually changed
if (this->set_state_(new_state)) {
ESP_LOGD(TAG, "'%s': New state is %s", this->get_name().c_str(), ONOFF(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
#if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_binary_sensor_update(this);
#endif
ESP_LOGD(TAG, "'%s': %s", this->get_name().c_str(), ONOFFMAYBE(new_state));
return true;
}
return false;
}
void BinarySensor::add_filter(Filter *filter) {

View File

@@ -63,6 +63,8 @@ class BinarySensor : public StatefulEntityBase<bool>, public EntityBase_DeviceCl
protected:
Filter *filter_list_{nullptr};
bool set_new_state(const optional<bool> &new_state) override;
};
class BinarySensorInitiallyOff : public BinarySensor {

View File

@@ -22,7 +22,6 @@ ES8311_BITS_PER_SAMPLE_ENUM = {
es8311_mic_gain = es8311_ns.enum("ES8311MicGain")
ES8311_MIC_GAIN_ENUM = {
"MIN": es8311_mic_gain.ES8311_MIC_GAIN_MIN,
"0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB,
"6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB,
"12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB,
@@ -31,7 +30,6 @@ ES8311_MIC_GAIN_ENUM = {
"30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB,
"36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB,
"42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB,
"MAX": es8311_mic_gain.ES8311_MIC_GAIN_MAX,
}

View File

@@ -860,6 +860,7 @@ async def to_code(config):
)
cg.set_cpp_standard("gnu++20")
cg.add_build_flag("-DUSE_ESP32")
cg.add_build_flag("-Wl,-z,noexecstack")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")

View File

@@ -93,9 +93,9 @@ async def to_code(config):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}"
if framework_ver >= cv.Version(5, 5, 0):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.2.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.6.1")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.7.0")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")

View File

@@ -205,8 +205,10 @@ void LD2420Component::dump_config() {
LOG_BUTTON(" ", "Factory Reset:", this->factory_reset_button_);
LOG_BUTTON(" ", "Restart Module:", this->restart_module_button_);
#endif
#ifdef USE_SELECT
ESP_LOGCONFIG(TAG, "Select:");
LOG_SELECT(" ", "Operating Mode", this->operating_selector_);
#endif
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
}
@@ -238,12 +240,20 @@ void LD2420Component::setup() {
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
this->set_operating_mode(OP_SIMPLE_MODE_STRING);
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
#ifdef USE_SELECT
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
}
#endif
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
} else {
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
#ifdef USE_SELECT
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING);
}
#endif
}
#ifdef USE_NUMBER
this->init_gate_config_numbers();
@@ -383,8 +393,12 @@ void LD2420Component::set_operating_mode(const char *state) {
// If unsupported firmware ignore mode select
if (ld2420::get_firmware_int(firmware_ver_) >= CALIBRATE_VERSION_MIN) {
this->current_operating_mode = find_uint8(OP_MODE_BY_STR, state);
// Entering Auto Calibrate we need to clear the privoiuos data collection
this->operating_selector_->publish_state(state);
// Entering Auto Calibrate we need to clear the previous data collection
#ifdef USE_SELECT
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(state);
}
#endif
if (current_operating_mode == OP_CALIBRATE_MODE) {
this->set_calibration_(true);
for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) {
@@ -404,7 +418,11 @@ void LD2420Component::set_operating_mode(const char *state) {
}
} else {
this->current_operating_mode = OP_SIMPLE_MODE;
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
#ifdef USE_SELECT
if (this->operating_selector_ != nullptr) {
this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING);
}
#endif
}
}

View File

@@ -29,15 +29,18 @@ class LVGLNumber : public number::Number, public Component {
this->publish_state(value);
}
void on_value() { this->publish_state(this->value_lambda_()); }
void on_value() { this->publish_(this->value_lambda_()); }
protected:
void control(float value) override {
this->control_lambda_(value);
void publish_(float value) {
this->publish_state(value);
if (this->restore_)
this->pref_.save(&value);
}
void control(float value) override {
this->control_lambda_(value);
this->publish_(value);
}
std::function<void(float)> control_lambda_;
std::function<float()> value_lambda_;
lv_event_code_t event_;

View File

@@ -1209,8 +1209,8 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() {
}
case WiFiRetryPhase::SCAN_CONNECTING:
// If scan found no matching networks, skip to hidden network mode
if (!this->scan_result_.empty() && !this->scan_result_[0].get_matches()) {
// If scan found no networks or no matching networks, skip to hidden network mode
if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) {
return WiFiRetryPhase::RETRY_HIDDEN;
}

View File

@@ -412,6 +412,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
}
void WiFiComponent::wifi_scan_done_callback_() {
this->scan_result_.clear();
this->scan_done_ = true;
int16_t num = WiFi.scanComplete();
if (num < 0)
@@ -430,7 +431,6 @@ void WiFiComponent::wifi_scan_done_callback_() {
ssid.length() == 0);
}
WiFi.scanDelete();
this->scan_done_ = true;
}
#ifdef USE_WIFI_AP

View File

@@ -740,9 +740,10 @@ def has_at_most_one_key(*keys):
if not isinstance(obj, dict):
raise Invalid("expected dictionary")
number = sum(k in keys for k in obj)
if number > 1:
raise Invalid(f"Cannot specify more than one of {', '.join(keys)}.")
used = set(obj) & set(keys)
if len(used) > 1:
msg = "Cannot specify more than one of '" + "', '".join(used) + "'."
raise MultipleInvalid([Invalid(msg, path=[k]) for k in used])
return obj
return validate

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2025.11.3"
__version__ = "2025.11.5"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -202,7 +202,7 @@ template<typename T> class StatefulEntityBase : public EntityBase {
virtual bool has_state() const { return this->state_.has_value(); }
virtual const T &get_state() const { return this->state_.value(); }
virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); }
void invalidate_state() { this->set_state_({}); }
void invalidate_state() { this->set_new_state({}); }
void add_full_state_callback(std::function<void(optional<T> previous, optional<T> current)> &&callback) {
if (this->full_state_callbacks_ == nullptr)
@@ -224,20 +224,20 @@ template<typename T> class StatefulEntityBase : public EntityBase {
/**
* Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous.
*
* @param state The new state.
* @param new_state The new state.
* @return True if the state was changed, false if it was the same as before.
*/
bool set_state_(const optional<T> &state) {
if (this->state_ != state) {
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
if (this->full_state_callbacks_ != nullptr)
this->full_state_callbacks_->call(this->state_, 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_ = state;
if (this->state_callbacks_ != nullptr && state.has_value() && (this->trigger_on_initial_state_ || had_state))
this->state_callbacks_->call(state.value());
this->state_ = new_state;
if (this->state_callbacks_ != nullptr && new_state.has_value() && (this->trigger_on_initial_state_ || had_state))
this->state_callbacks_->call(new_state.value());
return true;
}
return false;

View File

@@ -315,7 +315,7 @@ void Scheduler::full_cleanup_removed_items_() {
valid_items.push_back(std::move(item));
} else {
// Recycle removed items
this->recycle_item_(std::move(item));
this->recycle_item_main_loop_(std::move(item));
}
}
@@ -400,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) {
// Don't run on failed components
if (item->component != nullptr && item->component->is_failed()) {
LockGuard guard{this->lock_};
this->recycle_item_(this->pop_raw_locked_());
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
@@ -413,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) {
{
LockGuard guard{this->lock_};
if (is_item_removed_(item.get())) {
this->recycle_item_(this->pop_raw_locked_());
this->recycle_item_main_loop_(this->pop_raw_locked_());
this->to_remove_--;
continue;
}
@@ -422,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) {
// Single-threaded or multi-threaded with atomics: can check without lock
if (is_item_removed_(item.get())) {
LockGuard guard{this->lock_};
this->recycle_item_(this->pop_raw_locked_());
this->recycle_item_main_loop_(this->pop_raw_locked_());
this->to_remove_--;
continue;
}
@@ -449,7 +449,7 @@ void HOT Scheduler::call(uint32_t now) {
if (executed_item->remove) {
// We were removed/cancelled in the function call, recycle and continue
this->to_remove_--;
this->recycle_item_(std::move(executed_item));
this->recycle_item_main_loop_(std::move(executed_item));
continue;
}
@@ -460,7 +460,7 @@ void HOT Scheduler::call(uint32_t now) {
this->to_add_.push_back(std::move(executed_item));
} else {
// Timeout completed - recycle it
this->recycle_item_(std::move(executed_item));
this->recycle_item_main_loop_(std::move(executed_item));
}
has_added_items |= !this->to_add_.empty();
@@ -475,7 +475,7 @@ void HOT Scheduler::process_to_add() {
for (auto &it : this->to_add_) {
if (is_item_removed_(it.get())) {
// Recycle cancelled items
this->recycle_item_(std::move(it));
this->recycle_item_main_loop_(std::move(it));
continue;
}
@@ -509,7 +509,7 @@ size_t HOT Scheduler::cleanup_() {
if (!item->remove)
break;
this->to_remove_--;
this->recycle_item_(this->pop_raw_locked_());
this->recycle_item_main_loop_(this->pop_raw_locked_());
}
return this->items_.size();
}
@@ -562,20 +562,15 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c
#endif /* not ESPHOME_THREAD_SINGLE */
// Cancel items in the main heap
// Special case: if the last item in the heap matches, we can remove it immediately
// (removing the last element doesn't break heap structure)
// We only mark items for removal here - never recycle directly.
// The main loop may be executing an item's callback right now, and recycling
// would destroy the callback while it's running (use-after-free).
// Only the main loop in call() should recycle items after execution completes.
if (!this->items_.empty()) {
auto &last_item = this->items_.back();
if (this->matches_item_locked_(last_item, component, name_cstr, type, match_retry)) {
this->recycle_item_(std::move(this->items_.back()));
this->items_.pop_back();
total_cancelled++;
}
// For other items in heap, we can only mark for removal (can't remove from middle of heap)
size_t heap_cancelled =
this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry);
total_cancelled += heap_cancelled;
this->to_remove_ += heap_cancelled; // Track removals for heap items
this->to_remove_ += heap_cancelled;
}
// Cancel items in to_add_
@@ -749,7 +744,11 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr<SchedulerItem> &a,
: (a->next_execution_high_ > b->next_execution_high_);
}
void Scheduler::recycle_item_(std::unique_ptr<SchedulerItem> item) {
// Recycle a SchedulerItem back to the pool for reuse.
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
// This protects scheduler_item_pool_ from concurrent access by other threads
// that may be acquiring items from the pool in set_timer_common_().
void Scheduler::recycle_item_main_loop_(std::unique_ptr<SchedulerItem> item) {
if (!item)
return;

View File

@@ -272,8 +272,11 @@ class Scheduler {
return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed());
}
// Helper to recycle a SchedulerItem
void recycle_item_(std::unique_ptr<SchedulerItem> item);
// Helper to recycle a SchedulerItem back to the pool.
// IMPORTANT: Only call from main loop context! Recycling clears the callback,
// so calling from another thread while the callback is executing causes use-after-free.
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
void recycle_item_main_loop_(std::unique_ptr<SchedulerItem> item);
// Helper to perform full cleanup when too many items are cancelled
void full_cleanup_removed_items_();
@@ -329,7 +332,10 @@ class Scheduler {
now = this->execute_item_(item.get(), now);
}
// Recycle the defer item after execution
this->recycle_item_(std::move(item));
{
LockGuard lock(this->lock_);
this->recycle_item_main_loop_(std::move(item));
}
}
// If we've consumed all items up to the snapshot point, clean up the dead space

View File

@@ -192,7 +192,7 @@ def get_esphome_device_ip(
data = json.loads(payload)
if "name" not in data or data["name"] != dev_name:
_LOGGER.Warn("Wrong device answer")
_LOGGER.warning("Wrong device answer")
return
dev_ip = []

View File

@@ -7,7 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
- `conftest.py` - Common fixtures and utilities
- `const.py` - Constants used throughout the integration tests
- `types.py` - Type definitions for fixtures and functions
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `build_key_to_entity_mapping`)
- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`)
- `fixtures/` - YAML configuration files for tests
- `test_*.py` - Individual test files
@@ -53,6 +53,28 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe
**Future work:**
Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts.
#### Entity Lookup Helpers (`state_utils.py`)
Two helper functions simplify finding entities in test code:
**`find_entity(entities, object_id_substring, entity_type=None)`**
- Finds an entity by searching for a substring in its `object_id` (case-insensitive)
- Optionally filters by entity type (e.g., `BinarySensorInfo`)
- Returns `None` if not found
**`require_entity(entities, object_id_substring, entity_type=None, description=None)`**
- Same as `find_entity` but raises `AssertionError` if not found
- Use `description` parameter for clearer error messages
```python
from aioesphomeapi import BinarySensorInfo
from .state_utils import require_entity
# Find entities with clear error messages
binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo)
button = require_entity(entities, "set_true", description="Set True button")
```
### Writing Tests
The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures:

View File

@@ -0,0 +1,39 @@
esphome:
name: test-binary-sensor-invalidate
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Template binary sensor that we can control
binary_sensor:
- platform: template
name: "Test Binary Sensor"
id: test_binary_sensor
# Buttons to control the binary sensor state
button:
- platform: template
name: "Set True"
id: set_true_button
on_press:
- binary_sensor.template.publish:
id: test_binary_sensor
state: true
- platform: template
name: "Set False"
id: set_false_button
on_press:
- binary_sensor.template.publish:
id: test_binary_sensor
state: false
- platform: template
name: "Invalidate State"
id: invalidate_button
on_press:
- binary_sensor.invalidate_state:
id: test_binary_sensor

View File

@@ -4,11 +4,74 @@ from __future__ import annotations
import asyncio
import logging
from typing import TypeVar
from aioesphomeapi import ButtonInfo, EntityInfo, EntityState
_LOGGER = logging.getLogger(__name__)
T = TypeVar("T", bound=EntityInfo)
def find_entity(
entities: list[EntityInfo],
object_id_substring: str,
entity_type: type[T] | None = None,
) -> T | EntityInfo | None:
"""Find an entity by object_id substring and optionally by type.
Args:
entities: List of entity info objects from the API
object_id_substring: Substring to search for in object_id (case-insensitive)
entity_type: Optional entity type to filter by (e.g., BinarySensorInfo)
Returns:
The first matching entity, or None if not found
Example:
binary_sensor = find_entity(entities, "test_binary_sensor", BinarySensorInfo)
button = find_entity(entities, "set_true") # Any entity type
"""
substring_lower = object_id_substring.lower()
for entity in entities:
if substring_lower in entity.object_id.lower() and (
entity_type is None or isinstance(entity, entity_type)
):
return entity
return None
def require_entity(
entities: list[EntityInfo],
object_id_substring: str,
entity_type: type[T] | None = None,
description: str | None = None,
) -> T | EntityInfo:
"""Find an entity or raise AssertionError if not found.
Args:
entities: List of entity info objects from the API
object_id_substring: Substring to search for in object_id (case-insensitive)
entity_type: Optional entity type to filter by (e.g., BinarySensorInfo)
description: Human-readable description for error message
Returns:
The first matching entity
Raises:
AssertionError: If no matching entity is found
Example:
binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo)
button = require_entity(entities, "set_true", description="Set True button")
"""
entity = find_entity(entities, object_id_substring, entity_type)
if entity is None:
desc = description or f"entity with '{object_id_substring}' in object_id"
type_info = f" of type {entity_type.__name__}" if entity_type else ""
raise AssertionError(f"{desc}{type_info} not found in entities")
return entity
def build_key_to_entity_mapping(
entities: list[EntityInfo], entity_names: list[str]

View File

@@ -0,0 +1,138 @@
"""Integration test for binary_sensor.invalidate_state() functionality.
This tests the fix in PR #12296 where invalidate_state() was not properly
reporting the 'unknown' state to the API. The binary sensor should report
missing_state=True when invalidated.
Regression test for: https://github.com/esphome/esphome/issues/12252
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_binary_sensor_invalidate_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that binary_sensor.invalidate_state() reports unknown to the API.
This verifies that:
1. Binary sensor starts with missing_state=True (no initial state)
2. Publishing true sets missing_state=False and state=True
3. Publishing false sets missing_state=False and state=False
4. Invalidating state sets missing_state=True (unknown state)
"""
loop = asyncio.get_running_loop()
# Track state changes
states_received: list[BinarySensorState] = []
state_future: asyncio.Future[BinarySensorState] = loop.create_future()
def on_state(state: EntityState) -> None:
"""Track binary sensor state changes."""
if isinstance(state, BinarySensorState):
states_received.append(state)
if not state_future.done():
state_future.set_result(state)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "test-binary-sensor-invalidate"
# Get entities
entities, _ = await client.list_entities_services()
# Find our binary sensor and buttons using helper
binary_sensor = require_entity(entities, "test_binary_sensor", BinarySensorInfo)
set_true_button = require_entity(
entities, "set_true", description="Set True button"
)
set_false_button = require_entity(
entities, "set_false", description="Set False button"
)
invalidate_button = require_entity(
entities, "invalidate", description="Invalidate button"
)
# Set up initial state helper to handle the initial state broadcast
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Check initial state - should be missing (unknown)
initial_state = initial_state_helper.initial_states.get(binary_sensor.key)
assert initial_state is not None, "No initial state received for binary sensor"
assert isinstance(initial_state, BinarySensorState)
assert initial_state.missing_state is True, (
f"Initial state should have missing_state=True, got {initial_state}"
)
# Test 1: Set state to true
states_received.clear()
state_future = loop.create_future()
client.button_command(set_true_button.key)
try:
state = await asyncio.wait_for(state_future, timeout=5.0)
except TimeoutError:
pytest.fail("Timeout waiting for state=true")
assert state.missing_state is False, (
f"After setting true, missing_state should be False, got {state}"
)
assert state.state is True, f"Expected state=True, got {state}"
# Test 2: Set state to false
states_received.clear()
state_future = loop.create_future()
client.button_command(set_false_button.key)
try:
state = await asyncio.wait_for(state_future, timeout=5.0)
except TimeoutError:
pytest.fail("Timeout waiting for state=false")
assert state.missing_state is False, (
f"After setting false, missing_state should be False, got {state}"
)
assert state.state is False, f"Expected state=False, got {state}"
# Test 3: Invalidate state (set to unknown)
# This is the critical test for the bug fix
states_received.clear()
state_future = loop.create_future()
client.button_command(invalidate_button.key)
try:
state = await asyncio.wait_for(state_future, timeout=5.0)
except TimeoutError:
pytest.fail(
"Timeout waiting for invalidated state - "
"binary_sensor.invalidate_state() may not be reporting to the API. "
"See issue #12252."
)
assert state.missing_state is True, (
f"After invalidate_state(), missing_state should be True (unknown), "
f"got {state}. This is the regression from issue #12252."
)