diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index a7bcf1996..d984e78b2 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -25,7 +25,11 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text } void TextSensor::publish_state(const std::string &state) { - this->raw_state = state; + // Only store raw_state_ separately when filters exist + // When no filters, raw_state == state, so we avoid the duplicate storage + if (this->filter_list_ != nullptr) { + this->raw_state_ = state; + } if (this->raw_callback_) { this->raw_callback_->call(state); } @@ -80,7 +84,11 @@ void TextSensor::add_on_raw_state_callback(std::function call } std::string TextSensor::get_state() const { return this->state; } -std::string TextSensor::get_raw_state() const { return this->raw_state; } +std::string TextSensor::get_raw_state() const { + // When no filters exist, raw_state == state, so return state to avoid + // requiring separate storage + return this->filter_list_ != nullptr ? this->raw_state_ : this->state; +} void TextSensor::internal_send_state_to_frontend(const std::string &state) { this->state = state; this->set_has_state(true); diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index db2e857ae..fcfbed2fb 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -50,7 +50,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void add_on_raw_state_callback(std::function callback); std::string state; - std::string raw_state; // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -63,6 +62,10 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { CallbackManager callback_; ///< Storage for filtered state callbacks. Filter *filter_list_{nullptr}; ///< Store all active filters. + + /// Raw state (before filters). Only populated when filters are configured. + /// When no filters exist, get_raw_state() returns state directly. + std::string raw_state_; }; } // namespace text_sensor diff --git a/tests/integration/fixtures/text_sensor_raw_state.yaml b/tests/integration/fixtures/text_sensor_raw_state.yaml new file mode 100644 index 000000000..03aece0a0 --- /dev/null +++ b/tests/integration/fixtures/text_sensor_raw_state.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-text-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Text sensor WITHOUT filters - get_raw_state() should return same as state +text_sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + + # Text sensor WITH filter - get_raw_state() should return original value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + filters: + - to_upper + +# Button to publish values and log raw_state vs state +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - text_sensor.template.publish: + id: no_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify they match + - logger.log: + format: "NO_FILTER: state='%s' raw_state='%s'" + args: + - id(no_filter_sensor).state.c_str() + - id(no_filter_sensor).get_raw_state().c_str() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - text_sensor.template.publish: + id: with_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify filter works + # state should be "HELLO WORLD" (filtered), raw_state should be "hello world" (original) + - logger.log: + format: "WITH_FILTER: state='%s' raw_state='%s'" + args: + - id(with_filter_sensor).state.c_str() + - id(with_filter_sensor).get_raw_state().c_str() diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py new file mode 100644 index 000000000..a53ec8c96 --- /dev/null +++ b/tests/integration/test_text_sensor_raw_state.py @@ -0,0 +1,114 @@ +"""Integration test for TextSensor get_raw_state() functionality. + +This tests the optimization in PR #12205 where raw_state is only stored +when filters are configured. When no filters exist, get_raw_state() should +return state directly. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that get_raw_state() works correctly with and without filters. + + Without filters: get_raw_state() should return the same value as state + With filters: get_raw_state() should return the original (unfiltered) value + """ + loop = asyncio.get_running_loop() + + # Futures to track log messages + no_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + + # Patterns to match log output + # NO_FILTER: state='hello world' raw_state='hello world' + no_filter_pattern = re.compile(r"NO_FILTER: state='([^']*)' raw_state='([^']*)'") + # WITH_FILTER: state='HELLO WORLD' raw_state='hello world' + with_filter_pattern = re.compile( + r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'" + ) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not no_filter_future.done(): + match = no_filter_pattern.search(line) + if match: + no_filter_future.set_result((match.group(1), match.group(2))) + + if not with_filter_future.done(): + match = with_filter_pattern.search(line) + if match: + with_filter_future.set_result((match.group(1), match.group(2))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + 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-text-sensor-raw-state" + + # Get entities to find our buttons + entities, _ = await client.list_entities_services() + + # Find the test buttons + no_filter_button = next( + (e for e in entities if "test_no_filter_button" in e.object_id.lower()), + None, + ) + assert no_filter_button is not None, "Test No Filter Button not found" + + with_filter_button = next( + (e for e in entities if "test_with_filter_button" in e.object_id.lower()), + None, + ) + assert with_filter_button is not None, "Test With Filter Button not found" + + # Test 1: Text sensor without filters + # get_raw_state() should return the same as state + client.button_command(no_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(no_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for NO_FILTER log message") + + assert state == "hello world", f"Expected state='hello world', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state == raw_state, ( + f"Without filters, state and raw_state should be equal. " + f"state='{state}', raw_state='{raw_state}'" + ) + + # Test 2: Text sensor with to_upper filter + # state should be filtered (uppercase), raw_state should be original + client.button_command(with_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(with_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for WITH_FILTER log message") + + assert state == "HELLO WORLD", f"Expected state='HELLO WORLD', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state != raw_state, ( + f"With filters, state and raw_state should differ. " + f"state='{state}', raw_state='{raw_state}'" + )