[sensor] Add get_raw_state() integration tests and fix publish_state docs

This commit is contained in:
J. Nick Koston
2026-09-10 16:20:25 -05:00
parent a952e7236b
commit 9442956f54
4 changed files with 195 additions and 2 deletions
+3 -2
View File
@@ -107,8 +107,9 @@ class Sensor : public EntityBase {
/** Publish a new state to the front-end.
*
* First, the new state will be assigned to the raw_value. Then it's passed through all filters
* until it finally lands in the .value member variable and a callback is issued.
* The value is passed through the filter chain (when filters are compiled in) before landing in
* the `state` member and triggering the state callback. The pre-filter value is available via
* get_raw_state().
*
* @param state The state as a floating point number.
*/
@@ -0,0 +1,53 @@
esphome:
name: test-sensor-raw-state
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# Filters are compiled in for this config (USE_SENSOR_FILTER), so raw storage exists
sensor:
# No filters on this sensor: get_raw_state() must equal state
- platform: template
name: "No Filter Sensor"
id: no_filter_sensor
accuracy_decimals: 1
# Filtered sensor: get_raw_state() must be the pre-filter value
- platform: template
name: "With Filter Sensor"
id: with_filter_sensor
accuracy_decimals: 1
filters:
- multiply: 2.0
button:
- platform: template
name: "Test No Filter Button"
id: test_no_filter_button
on_press:
- sensor.template.publish:
id: no_filter_sensor
state: 21.5
- delay: 50ms
- logger.log:
format: "NO_FILTER: state=%.1f raw_state=%.1f"
args:
- id(no_filter_sensor).state
- id(no_filter_sensor).get_raw_state()
- platform: template
name: "Test With Filter Button"
id: test_with_filter_button
on_press:
- sensor.template.publish:
id: with_filter_sensor
state: 21.5
- delay: 50ms
- logger.log:
format: "WITH_FILTER: state=%.1f raw_state=%.1f"
args:
- id(with_filter_sensor).state
- id(with_filter_sensor).get_raw_state()
@@ -0,0 +1,31 @@
esphome:
name: test-sensor-raw-state-no-filter
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: DEBUG
# No sensor in this config has filters, so USE_SENSOR_FILTER is not defined and
# get_raw_state() falls back to state
sensor:
- platform: template
name: "No Filter Sensor"
id: no_filter_sensor
accuracy_decimals: 1
button:
- platform: template
name: "Test No Filter Button"
id: test_no_filter_button
on_press:
- sensor.template.publish:
id: no_filter_sensor
state: 21.5
- delay: 50ms
- logger.log:
format: "NO_FILTER: state=%.1f raw_state=%.1f"
args:
- id(no_filter_sensor).state
- id(no_filter_sensor).get_raw_state()
+108
View File
@@ -0,0 +1,108 @@
"""Integration tests for Sensor::get_raw_state().
Raw state storage only exists when filters are compiled in (USE_SENSOR_FILTER).
Without it, get_raw_state() returns state, so both build configurations are covered:
one fixture with a filtered sensor and one with no filters at all.
"""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import APIClient, EntityInfo
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
NO_FILTER_PATTERN = re.compile(r"NO_FILTER: state=([\d.]+) raw_state=([\d.]+)")
WITH_FILTER_PATTERN = re.compile(r"WITH_FILTER: state=([\d.]+) raw_state=([\d.]+)")
async def _press_and_read(
client: APIClient,
entities: list[EntityInfo],
button_object_id: str,
future: asyncio.Future[tuple[float, float]],
label: str,
) -> tuple[float, float]:
button = next(
(e for e in entities if button_object_id in e.object_id.lower()), None
)
assert button is not None, f"{button_object_id} not found"
client.button_command(button.key)
try:
return await asyncio.wait_for(future, timeout=5.0)
except TimeoutError:
pytest.fail(f"Timeout waiting for {label} log message")
@pytest.mark.asyncio
async def test_sensor_raw_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""With filters compiled in, raw state is stored separately from state."""
loop = asyncio.get_running_loop()
no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
with_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
def check_output(line: str) -> None:
if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)):
no_filter_future.set_result((float(match.group(1)), float(match.group(2))))
if not with_filter_future.done() and (
match := WITH_FILTER_PATTERN.search(line)
):
with_filter_future.set_result(
(float(match.group(1)), float(match.group(2)))
)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
state, raw_state = await _press_and_read(
client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER"
)
assert state == 21.5
assert raw_state == 21.5
state, raw_state = await _press_and_read(
client,
entities,
"test_with_filter_button",
with_filter_future,
"WITH_FILTER",
)
assert state == 43.0
assert raw_state == 21.5
@pytest.mark.asyncio
async def test_sensor_raw_state_no_filter(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Without filters compiled in, get_raw_state() returns state."""
loop = asyncio.get_running_loop()
no_filter_future: asyncio.Future[tuple[float, float]] = loop.create_future()
def check_output(line: str) -> None:
if not no_filter_future.done() and (match := NO_FILTER_PATTERN.search(line)):
no_filter_future.set_result((float(match.group(1)), float(match.group(2))))
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
state, raw_state = await _press_and_read(
client, entities, "test_no_filter_button", no_filter_future, "NO_FILTER"
)
assert state == 21.5
assert raw_state == 21.5