mirror of
https://github.com/esphome/esphome.git
synced 2026-02-08 00:31:58 +00:00
[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>
This commit is contained in:
@@ -34,13 +34,20 @@ void BinarySensor::publish_initial_state(bool new_state) {
|
|||||||
void BinarySensor::send_state_internal(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
|
// copy the new state to the visible property for backwards compatibility, before any callbacks
|
||||||
this->state = new_state;
|
this->state = new_state;
|
||||||
// Note that set_state_ de-dups and will only trigger callbacks if the state has actually changed
|
// Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed
|
||||||
if (this->set_state_(new_state)) {
|
this->set_new_state(new_state);
|
||||||
ESP_LOGD(TAG, "'%s': New state is %s", this->get_name().c_str(), ONOFF(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)
|
#if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY)
|
||||||
ControllerRegistry::notify_binary_sensor_update(this);
|
ControllerRegistry::notify_binary_sensor_update(this);
|
||||||
#endif
|
#endif
|
||||||
|
ESP_LOGD(TAG, "'%s': %s", this->get_name().c_str(), ONOFFMAYBE(new_state));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void BinarySensor::add_filter(Filter *filter) {
|
void BinarySensor::add_filter(Filter *filter) {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ class BinarySensor : public StatefulEntityBase<bool>, public EntityBase_DeviceCl
|
|||||||
|
|
||||||
protected:
|
protected:
|
||||||
Filter *filter_list_{nullptr};
|
Filter *filter_list_{nullptr};
|
||||||
|
|
||||||
|
bool set_new_state(const optional<bool> &new_state) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
class BinarySensorInitiallyOff : public BinarySensor {
|
class BinarySensorInitiallyOff : public BinarySensor {
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ template<typename T> class StatefulEntityBase : public EntityBase {
|
|||||||
virtual bool has_state() const { return this->state_.has_value(); }
|
virtual bool has_state() const { return this->state_.has_value(); }
|
||||||
virtual const T &get_state() const { return this->state_.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); }
|
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) {
|
void add_full_state_callback(std::function<void(optional<T> previous, optional<T> current)> &&callback) {
|
||||||
if (this->full_state_callbacks_ == nullptr)
|
if (this->full_state_callbacks_ == nullptr)
|
||||||
@@ -227,20 +227,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.
|
* 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.
|
* @return True if the state was changed, false if it was the same as before.
|
||||||
*/
|
*/
|
||||||
bool set_state_(const optional<T> &state) {
|
virtual bool set_new_state(const optional<T> &new_state) {
|
||||||
if (this->state_ != state) {
|
if (this->state_ != new_state) {
|
||||||
// call the full state callbacks with the previous and new state
|
// call the full state callbacks with the previous and new state
|
||||||
if (this->full_state_callbacks_ != nullptr)
|
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
|
// 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
|
// the previous state was valid
|
||||||
auto had_state = this->has_state();
|
auto had_state = this->has_state();
|
||||||
this->state_ = state;
|
this->state_ = new_state;
|
||||||
if (this->state_callbacks_ != nullptr && state.has_value() && (this->trigger_on_initial_state_ || had_state))
|
if (this->state_callbacks_ != nullptr && new_state.has_value() && (this->trigger_on_initial_state_ || had_state))
|
||||||
this->state_callbacks_->call(state.value());
|
this->state_callbacks_->call(new_state.value());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te
|
|||||||
- `conftest.py` - Common fixtures and utilities
|
- `conftest.py` - Common fixtures and utilities
|
||||||
- `const.py` - Constants used throughout the integration tests
|
- `const.py` - Constants used throughout the integration tests
|
||||||
- `types.py` - Type definitions for fixtures and functions
|
- `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
|
- `fixtures/` - YAML configuration files for tests
|
||||||
- `test_*.py` - Individual test files
|
- `test_*.py` - Individual test files
|
||||||
|
|
||||||
@@ -53,6 +53,28 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe
|
|||||||
**Future work:**
|
**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.
|
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
|
### Writing Tests
|
||||||
|
|
||||||
The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures:
|
The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,11 +4,74 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from aioesphomeapi import ButtonInfo, EntityInfo, EntityState
|
from aioesphomeapi import ButtonInfo, EntityInfo, EntityState
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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(
|
def build_key_to_entity_mapping(
|
||||||
entities: list[EntityInfo], entity_names: list[str]
|
entities: list[EntityInfo], entity_names: list[str]
|
||||||
|
|||||||
138
tests/integration/test_binary_sensor_invalidate_state.py
Normal file
138
tests/integration/test_binary_sensor_invalidate_state.py
Normal 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."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user