Compare commits

..
Author SHA1 Message Date
J. Nick Koston 568161d3f2 Merge remote-tracking branch 'origin/dev' into copy-follow-entities 2026-10-25 07:52:02 -10:00
J. Nick Koston 0a3993639e [copy] Keep the binary sensor copy as a component 2026-10-24 23:35:17 -10:00
J. Nick Koston b1342196c3 Merge branch 'entity-follow-helper' into copy-follow-entities 2026-10-24 23:35:02 -10:00
J. Nick Koston 9f5ff8ef6e [core] Drop the binary sensor follower 2026-10-24 23:34:51 -10:00
J. Nick Koston 79c86833ee Merge branch 'entity-follow-helper' into copy-follow-entities 2026-10-24 23:31:58 -10:00
J. Nick Koston 4ed6599b7e [binary_sensor] Copy the constructor state after the follower's automations are wired 2026-10-24 23:31:57 -10:00
J. Nick Koston de1a854266 Merge branch 'entity-follow-helper' into copy-follow-entities 2026-10-24 23:31:14 -10:00
J. Nick Koston 11b73fa6eb Merge branch 'copy-entity-state-tests' into entity-follow-helper 2026-10-24 23:31:14 -10:00
J. Nick Koston 7f5898279c [copy] Check a copy binary sensor whose source starts off 2026-10-24 23:31:14 -10:00
J. Nick Koston 916fc01a6a Merge branch 'entity-follow-helper' into copy-follow-entities 2026-10-24 22:49:11 -10:00
J. Nick Koston de49ad67fb [core] Keep the state sync out of FollowEntity and parenthesize test strings 2026-10-24 22:49:10 -10:00
J. Nick Koston 4ee6141b02 [copy] Check that the early copies follow later updates 2026-10-24 22:41:33 -10:00
J. Nick Koston 0a97ed5706 Merge branch 'entity-follow-helper' into copy-follow-entities 2026-10-24 22:41:33 -10:00
J. Nick Koston 8359b10cf8 Merge branch 'copy-entity-state-tests' into entity-follow-helper 2026-10-24 22:41:33 -10:00
J. Nick Koston f00b11a48a Merge branch 'entity-follow-helper' into copy-follow-entities
# Conflicts:
#	tests/integration/test_copy_entity_state.py
2026-10-24 22:31:38 -10:00
J. Nick Koston 9749698201 [core] Make the follow sync opt-in and pass leading callback arguments 2026-10-24 22:31:20 -10:00
J. Nick Koston 6f4bdf49fd [copy] Check the copy states from a table 2026-10-24 22:31:03 -10:00
J. Nick Koston 772acacb9e Merge branch 'copy-entity-state-tests' into entity-follow-helper 2026-10-24 22:31:03 -10:00
J. Nick Koston c1e1555918 [copy] Follow the source with plain sensors, binary sensors and text sensors 2026-10-24 22:26:31 -10:00
J. Nick Koston e647b2acc1 [core] Add follow helpers for plain sensors, binary sensors and text sensors 2026-10-24 22:25:08 -10:00
J. Nick Koston 3af8fec562 [copy] Add an integration test for the sensor, binary sensor and text sensor platforms 2026-10-24 22:23:00 -10:00
16 changed files with 183 additions and 303 deletions

No files matched your search

+16 -10
View File
@@ -2,18 +2,24 @@ import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Number
from .. import Number, number_ns
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
NumberSensor = number_ns.class_("NumberSensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(NumberSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
# Number has the same float state callback as Sensor
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
async def to_code(config):
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await sensor.new_sensor(config, source)
await cg.register_component(var, config)
@@ -0,0 +1,16 @@
#include "number_sensor.h"
#include "esphome/core/log.h"
namespace esphome::number {
ESPHOME_LOG_TAG(TAG, "number.sensor");
void NumberSensor::setup() {
this->source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void NumberSensor::dump_config() { LOG_SENSOR("", "Number Sensor", this); }
} // namespace esphome::number
@@ -0,0 +1,19 @@
#pragma once
#include "../number.h"
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::number {
class NumberSensor final : public sensor::Sensor, public Component {
public:
explicit NumberSensor(Number *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Number *source_;
};
} // namespace esphome::number
@@ -8,8 +8,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <new>
#ifdef USE_ESP_IDF
#include "esp_task_wdt.h"
#endif
@@ -96,38 +94,7 @@ static int draw_callback(JPEGDRAW *jpeg) {
return 1;
}
JpegDecoder::JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {
RAMAllocator<JPEGDEC> allocator;
JPEGDEC *jpeg = allocator.allocate(1);
if (jpeg == nullptr) {
ESP_LOGE(TAG, "Failed to allocate memory for the JPEG decoder");
return;
}
this->jpeg_ = new (jpeg) JPEGDEC();
}
JpegDecoder::~JpegDecoder() {
if (this->jpeg_ != nullptr) {
this->jpeg_->~JPEGDEC();
RAMAllocator<JPEGDEC> allocator;
allocator.deallocate(this->jpeg_, 1);
}
}
int JpegDecoder::prepare(size_t expected_size) {
// Check before the base prepare() so a failure never leaves an active session
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
return ImageDecoder::prepare(expected_size);
}
int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
// JPEG decoder requires complete data
// If we know the expected size, wait for it
if (this->expected_size_ > 0 && size < this->expected_size_) {
@@ -138,22 +105,22 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
// If size unknown, try to decode and see if it's valid
// The JPEGDEC library will fail gracefully if data is incomplete
if (!this->jpeg_->openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
if (!this->jpeg_.openRAM(buffer, size, draw_callback)) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
return DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_->decode(0, 0, this->scale_option_()))
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_->close();
this->jpeg_.close();
return size;
}
int JpegDecoder::start_() {
auto jpeg_type = this->jpeg_->getJPEGType();
auto jpeg_type = this->jpeg_.getJPEGType();
if (jpeg_type == JPEG_MODE_INVALID) {
ESP_LOGE(TAG, "Unsupported JPEG image");
return DECODE_ERROR_INVALID_TYPE;
@@ -161,12 +128,11 @@ int JpegDecoder::start_() {
ESP_LOGE(TAG, "Progressive JPEG images not supported");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(),
this->jpeg_->getBpp());
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
this->jpeg_->setUserPointer(this);
const int width = this->jpeg_->getWidth();
const int height = this->jpeg_->getHeight();
this->jpeg_.setUserPointer(this);
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
// The tables for drawing are only built once the decoded size is known, below
if (!this->resize_image_(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
@@ -189,17 +155,17 @@ int JpegDecoder::start_() {
if (this->direct_rgb565_) {
// JPEGDEC's RGB565_BIG_ENDIAN output is not byte swapped on every build (the x86 host build
// returns little-endian words for it), so the byte order is applied in draw_rgb565 instead
this->jpeg_->setPixelType(RGB565_LITTLE_ENDIAN);
this->jpeg_.setPixelType(RGB565_LITTLE_ENDIAN);
} else {
this->jpeg_->setPixelType(RGB8888);
this->jpeg_.setPixelType(RGB8888);
}
return 0;
}
int JpegDecoder::fail_() {
const bool read_failed = this->source_ != nullptr && this->source_failed_;
const int error = this->jpeg_->getLastError();
this->jpeg_->close();
const int error = this->jpeg_.getLastError();
this->jpeg_.close();
this->source_ = nullptr;
if (read_failed) {
ESP_LOGE(TAG, "Reading the image failed");
@@ -223,7 +189,7 @@ int JpegDecoder::fail_() {
void JpegDecoder::reset() {
// A decode from a source that was given up part way still has the image open
if (this->source_ != nullptr) {
this->jpeg_->close();
this->jpeg_.close();
this->source_ = nullptr;
}
ImageDecoder::reset();
@@ -252,10 +218,6 @@ int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
}
int JpegDecoder::begin_source(DecodeSource *source) {
if (this->jpeg_ == nullptr) {
ESP_LOGE(TAG, "JPEG decoder engine not initialized!");
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
return DECODE_ERROR_INVALID_TYPE;
@@ -266,19 +228,19 @@ int JpegDecoder::begin_source(DecodeSource *source) {
}
this->source_ = source;
this->source_failed_ = false;
if (!this->jpeg_->open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
// The caller may recover, for example by reading the whole image first, so it reports the failure
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError());
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
this->source_ = nullptr;
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0) {
this->jpeg_->close();
this->jpeg_.close();
this->source_ = nullptr;
return err;
}
if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_()))
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
@@ -286,7 +248,7 @@ int JpegDecoder::begin_source(DecodeSource *source) {
int HOT JpegDecoder::decode_step() {
if (this->source_ == nullptr)
return DECODE_ERROR_INVALID_TYPE;
const int result = this->jpeg_->decodeRows(1);
const int result = this->jpeg_.decodeRows(1);
// JPEGDEC may decode on with the missing data, so check the read before its result
if (this->source_failed_)
return this->fail_();
@@ -297,7 +259,7 @@ int HOT JpegDecoder::decode_step() {
if (result == 0)
return this->fail_();
this->decoded_bytes_ = this->expected_size_;
this->jpeg_->close();
this->jpeg_.close();
this->source_ = nullptr;
return DECODE_STEP_DONE;
}
@@ -18,10 +18,9 @@ class JpegDecoder : public ImageDecoder {
*
* @param image The RuntimeImage to decode the stream into.
*/
JpegDecoder(RuntimeImage *image);
~JpegDecoder() override;
JpegDecoder(RuntimeImage *image) : ImageDecoder(image, JPEG) {}
~JpegDecoder() override {}
int prepare(size_t expected_size) override;
int HOT decode(uint8_t *buffer, size_t size) override;
void reset() override;
@@ -47,8 +46,7 @@ class JpegDecoder : public ImageDecoder {
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
/// About 18 KB of decoder state, allocated like pngle in PngDecoder: PSRAM first, nullptr if out of memory
JPEGDEC *jpeg_{nullptr};
JPEGDEC jpeg_{};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool source_failed_{false};
@@ -2,22 +2,29 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.core.entity_helpers import FollowEntity
from esphome.types import ConfigType
from .. import Switch
from .. import Switch, switch_ns
CODEOWNERS = ["@ssieb"]
_FOLLOW_SWITCH = FollowEntity("add_on_state_callback", ((cg.bool_, "x"),), "x")
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(binary_sensor.BinarySensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
SwitchBinarySensor = switch_ns.class_(
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
)
async def to_code(config: ConfigType) -> None:
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(SwitchBinarySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Switch),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
@@ -0,0 +1,15 @@
#include "switch_binary_sensor.h"
#include "esphome/core/log.h"
namespace esphome::switch_ {
ESPHOME_LOG_TAG(TAG, "switch.binary_sensor");
void SwitchBinarySensor::setup() {
source_->add_on_state_callback([this](bool value) { this->publish_state(value); });
this->publish_state(source_->state);
}
void SwitchBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Switch Binary Sensor", this); }
} // namespace esphome::switch_
@@ -0,0 +1,19 @@
#pragma once
#include "../switch.h"
#include "esphome/core/component.h"
#include "esphome/components/binary_sensor/binary_sensor.h"
namespace esphome::switch_ {
class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component {
public:
void set_source(Switch *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
Switch *source_;
};
} // namespace esphome::switch_
@@ -4,16 +4,23 @@ import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Text
from .. import Text, text_ns
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
TextTextSensor = text_ns.class_("TextTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(TextTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
.extend(cv.COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
# Text has the same string state callback as TextSensor
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await text_sensor.new_text_sensor(config, source)
await cg.register_component(var, config)
@@ -0,0 +1,16 @@
#include "text_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::text {
ESPHOME_LOG_TAG(TAG, "text.text_sensor");
void TextTextSensor::setup() {
this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (this->source_->has_state())
this->publish_state(this->source_->state);
}
void TextTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Text Text Sensor", this); }
} // namespace esphome::text
@@ -0,0 +1,19 @@
#pragma once
#include "../text.h"
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::text {
class TextTextSensor final : public text_sensor::TextSensor, public Component {
public:
explicit TextTextSensor(Text *source) : source_(source) {}
void setup() override;
void dump_config() override;
protected:
Text *source_;
};
} // namespace esphome::text
+7 -7
View File
@@ -483,6 +483,13 @@ uint32_t HOT Scheduler::call(uint32_t now) {
// Not reached timeout yet, done for this call
break;
}
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
// Check if item is marked for removal
// This handles two cases:
// 1. Item was marked for removal after cleanup_() but before we got here
@@ -507,13 +514,6 @@ uint32_t HOT Scheduler::call(uint32_t now) {
}
#endif
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
#ifdef ESPHOME_DEBUG_SCHEDULER
{
SchedulerNameLog name_log;
@@ -5,8 +5,6 @@
#include <gtest/gtest.h>
#include <chrono>
#include <memory>
#include <string>
#include <thread>
namespace esphome::core::testing {
@@ -43,40 +41,4 @@ TEST(Scheduler, TimeoutAddedByACancelledCallbackIsVisibleAfterCall) {
scheduler.trim_freelist();
}
// A cancelled timeout of a component that has since failed has to be counted as removed when call()
// reaches it, or the leftover count makes the next few cancellations trigger a full cleanup early.
TEST(Scheduler, CancelledItemOfFailedComponentIsCountedAsRemoved) {
Scheduler scheduler;
SchedulerTestComponent healthy;
SchedulerTestComponent failing;
// The healthy item is due first, so cleanup_() stops at it and the cancelled item is only reached in the run loop
scheduler.set_timeout(&healthy, "first", 1, []() {});
scheduler.set_timeout(&failing, "cancelled", 2, []() {});
scheduler.process_to_add();
scheduler.cancel_timeout(&failing, "cancelled");
failing.mark_failed();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
scheduler.call(millis());
// Four cancelled items behind a live one stay below the full cleanup threshold of five, so their callbacks are
// released only once they reach the top of the heap
auto token = std::make_shared<int>(0);
scheduler.set_timeout(&healthy, "keep", 10000, []() {});
static constexpr const char *NAMES[] = {"c0", "c1", "c2", "c3"};
for (const char *name : NAMES)
scheduler.set_timeout(&healthy, name, 20000, [token]() {});
scheduler.process_to_add();
for (const char *name : NAMES)
scheduler.cancel_timeout(&healthy, name);
ASSERT_EQ(token.use_count(), 5);
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 5);
scheduler.cancel_timeout(&healthy, "keep");
scheduler.call(millis());
EXPECT_EQ(token.use_count(), 1);
scheduler.trim_freelist();
}
} // namespace esphome::core::testing
@@ -1,39 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/components/runtime_image/jpeg_decoder.h"
#include "esphome/components/runtime_image/runtime_image.h"
#ifdef USE_RUNTIME_IMAGE_JPEG
namespace esphome::runtime_image::testing {
// A decoder whose JPEGDEC state is missing, as when its allocation fails. The state is handed back before the base
// destructor runs, so the teardown stays with JpegDecoder.
class StatelessJpegDecoder : public JpegDecoder {
public:
explicit StatelessJpegDecoder(RuntimeImage *image) : JpegDecoder(image), state_(this->jpeg_) {
this->jpeg_ = nullptr;
}
~StatelessJpegDecoder() override { this->jpeg_ = this->state_; }
protected:
JPEGDEC *state_;
};
// The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM
TEST(JpegOutOfMemory, DecoderStateIsAllocatedSeparately) { EXPECT_LT(sizeof(JpegDecoder), sizeof(JPEGDEC) / 4); }
TEST(JpegOutOfMemory, MissingStateFailsWithoutStartingASession) {
RuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);
StatelessJpegDecoder decoder(&img);
EXPECT_EQ(decoder.prepare(100), DECODE_ERROR_OUT_OF_MEMORY);
EXPECT_FALSE(decoder.is_active());
uint8_t data[4] = {};
EXPECT_EQ(decoder.decode(data, sizeof(data)), DECODE_ERROR_OUT_OF_MEMORY);
EXPECT_EQ(decoder.begin_source(nullptr), DECODE_ERROR_OUT_OF_MEMORY);
decoder.reset();
}
} // namespace esphome::runtime_image::testing
#endif // USE_RUNTIME_IMAGE_JPEG
@@ -1,56 +0,0 @@
esphome:
name: test-source-entity-follow
host:
api:
batch_delay: 0ms
logger:
switch:
- platform: template
id: source_switch
name: Source Switch
optimistic: true
restore_mode: ALWAYS_ON
# Publishes nothing until commanded
- platform: template
id: quiet_switch
name: Quiet Switch
optimistic: true
restore_mode: DISABLED
number:
- platform: template
id: source_number
name: Source Number
optimistic: true
min_value: 0
max_value: 10
step: 0.5
initial_value: 4
text:
- platform: template
id: source_text
name: Source Text
optimistic: true
mode: text
initial_value: hello
binary_sensor:
- platform: switch
source_id: source_switch
name: Switch State
- platform: switch
source_id: quiet_switch
name: Quiet Switch State
sensor:
- platform: number
source_id: source_number
name: Number Value
text_sensor:
- platform: text
source_id: source_text
name: Text Value
@@ -1,71 +0,0 @@
"""Integration test for the switch binary sensor, number sensor and text text sensor platforms."""
from __future__ import annotations
import asyncio
from aioesphomeapi import (
BinarySensorState,
EntityState,
NumberInfo,
SensorState,
SwitchInfo,
TextInfo,
TextSensorState,
)
import pytest
from .state_utils import require_entity, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
INITIAL: dict[str, tuple[type[EntityState], float | bool | str | None]] = {
"switch_state": (BinarySensorState, True),
# A switch that has not published leaves its binary sensor unknown
"quiet_switch_state": (BinarySensorState, None),
"number_value": (SensorState, 4.0),
"text_value": (TextSensorState, "hello"),
}
UPDATED: dict[str, tuple[type[EntityState], float | bool | str | None]] = {
"switch_state": (BinarySensorState, False),
"quiet_switch_state": (BinarySensorState, True),
"number_value": (SensorState, 6.5),
"text_value": (TextSensorState, "bye"),
}
@pytest.mark.asyncio
async def test_source_entity_follow(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Each entity holds its source's state from boot, then follows it."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
keys = {
name: require_entity(entities, name, exact=True).key for name in INITIAL
}
switch = require_entity(entities, "source_switch", SwitchInfo)
quiet = require_entity(entities, "quiet_switch", SwitchInfo)
number = require_entity(entities, "source_number", NumberInfo)
text = require_entity(entities, "source_text", TextInfo)
sub = await subscribe_after_initial_states(client, entities)
for name, (cls, state) in INITIAL.items():
key = keys[name]
expected = (
cls(key=key, missing_state=True)
if state is None
else cls(key=key, state=state)
)
assert sub.initial_states[key] == expected, name
names = list(UPDATED)
waits = [sub.waiter.next_state(keys[name]) for name in names]
client.switch_command(switch.key, False)
client.switch_command(quiet.key, True)
client.number_command(number.key, 6.5)
client.text_command(text.key, "bye")
for name, got in zip(names, await asyncio.gather(*waits), strict=True):
cls, state = UPDATED[name]
assert got == cls(key=keys[name], state=state), name