Compare commits

...
Author SHA1 Message Date
J. Nick Koston 0abc7ad3ec Merge branch 'copy-follow-entities' into source-follow-entities 2026-10-24 23:35:17 -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 e95f2d8fe5 Merge branch 'copy-follow-entities' into source-follow-entities 2026-10-24 23:32:02 -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 e995e12ad2 [switch] Check that a switch that never published leaves its binary sensor unknown 2026-10-24 23:05:41 -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 08f015b832 Merge branch 'copy-follow-entities' into source-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 297208aab0 [switch][number][text] Follow the source with plain binary sensors, sensors and text sensors 2026-10-24 22:46:24 -10:00
J. Nick Koston 984fa28cb7 [switch][number][text] Add an integration test for the entities that follow a source 2026-10-24 22:44:59 -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
25 changed files with 458 additions and 254 deletions

No files matched your search

+5 -16
View File
@@ -13,19 +13,10 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopySensor = copy_ns.class_("CopySensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
sensor.sensor_schema(CopySensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(sensor.Sensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -40,6 +31,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
sensor.follow_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.sensor");
void CopySensor::setup() {
source_->add_on_state_callback([this](float value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopySensor::dump_config() { LOG_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::copy {
class CopySensor final : public sensor::Sensor, public Component {
public:
void set_source(sensor::Sensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
sensor::Sensor *source_;
};
} // namespace esphome::copy
@@ -5,19 +5,10 @@ from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
from .. import copy_ns
CopyTextSensor = copy_ns.class_("CopyTextSensor", text_sensor.TextSensor, cg.Component)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(CopyTextSensor)
.extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
.extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(text_sensor.TextSensor),
}
)
FINAL_VALIDATE_SCHEMA = cv.All(
@@ -28,6 +19,4 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
text_sensor.follow_text_sensor(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,16 +0,0 @@
#include "copy_text_sensor.h"
#include "esphome/core/log.h"
namespace esphome::copy {
ESPHOME_LOG_TAG(TAG, "copy.text_sensor");
void CopyTextSensor::setup() {
source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); });
if (source_->has_state())
this->publish_state(source_->state);
}
void CopyTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Copy Sensor", this); }
} // namespace esphome::copy
@@ -1,18 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome::copy {
class CopyTextSensor final : public text_sensor::TextSensor, public Component {
public:
void set_source(text_sensor::TextSensor *source) { source_ = source; }
void setup() override;
void dump_config() override;
protected:
text_sensor::TextSensor *source_;
};
} // namespace esphome::copy
+10 -16
View File
@@ -2,24 +2,18 @@ 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, number_ns
from .. import 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)
CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Number),
}
)
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)
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]))
@@ -1,16 +0,0 @@
#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
@@ -1,19 +0,0 @@
#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
+5
View File
@@ -124,6 +124,7 @@ from esphome.core import (
)
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
lazy_load_validator,
@@ -1109,6 +1110,10 @@ def sub_sensors(
return SubEntities(new_sensor, config, parent)
# Makes a plain sensor follow another sensor: follow_sensor(entity, source)
follow_sensor = FollowEntity("add_on_state_callback", ((cg.float_, "x"),), "x")
SENSOR_IN_RANGE_CONDITION_SCHEMA = cv.All(
{
cv.Required(CONF_ID): cv.use_id(Sensor),
@@ -2,29 +2,22 @@ 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, switch_ns
from .. import Switch
CODEOWNERS = ["@ssieb"]
SwitchBinarySensor = switch_ns.class_(
"SwitchBinarySensor", binary_sensor.BinarySensor, cg.Component
_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),
}
)
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):
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
await cg.add_variable(var.set_source, config[CONF_SOURCE_ID])
_FOLLOW_SWITCH(var, await cg.get_variable(config[CONF_SOURCE_ID]))
@@ -1,15 +0,0 @@
#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_
@@ -1,19 +0,0 @@
#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,23 +4,16 @@ import esphome.config_validation as cv
from esphome.const import CONF_SOURCE_ID
from esphome.types import ConfigType
from .. import Text, text_ns
from .. import 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)
CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend(
{
cv.Required(CONF_SOURCE_ID): cv.use_id(Text),
}
)
async def to_code(config: ConfigType) -> None:
source = await cg.get_variable(config[CONF_SOURCE_ID])
var = await text_sensor.new_text_sensor(config, source)
await cg.register_component(var, config)
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]))
@@ -1,16 +0,0 @@
#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
@@ -1,19 +0,0 @@
#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
@@ -22,6 +22,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
FollowEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
@@ -238,6 +239,12 @@ def sub_text_sensors(
return SubEntities(new_text_sensor, config, parent)
# Makes a plain text sensor follow another text sensor: follow_text_sensor(entity, source)
follow_text_sensor = FollowEntity(
"add_on_state_callback", ((cg.std_string_ref.operator("const"), "x"),), "x"
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
cg.add_global(text_sensor_ns.using)
+18
View File
@@ -45,6 +45,7 @@ from esphome.core.config import (
UNIT_OF_MEASUREMENT_MAX_LENGTH,
)
from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
RawExpression,
@@ -519,6 +520,23 @@ class ApplyEntity:
_APPLY_DOMAIN = "entity_apply"
@dataclass(frozen=True, slots=True)
class FollowEntity:
"""A state callback, its parameters and the value an entity republishes from it."""
callback: str
params: tuple[tuple[SafeExpType, str], ...]
value: str
def __call__(self, entity: MockObj, source: MockObj, *args: SafeExpType) -> None:
"""Make ``entity`` publish every state of ``source``; ``args`` precede the callback."""
publish = f"{parent_ref(entity).publish_state(RawExpression(self.value))};"
callback = LambdaExpression(
[publish], list(self.params), capture="", return_type=cg.void
)
add(getattr(source, self.callback)(*args, callback))
@dataclass(frozen=True, slots=True)
class SubEntities:
"""Create the child entity configured under key, set parent (if bound) and pass it to setter.
@@ -0,0 +1,84 @@
esphome:
name: test-copy-entity-state
on_boot:
# Before the copies' setup
- priority: 800
then:
- sensor.template.publish:
id: early_sensor
state: 1.5
- binary_sensor.template.publish:
id: early_binary
state: true
- text_sensor.template.publish:
id: early_text
state: early
# After the copies' setup
- priority: -100
then:
- sensor.template.publish:
id: late_sensor
state: 2.5
- text_sensor.template.publish:
id: late_text
state: late
host:
api:
batch_delay: 0ms
logger:
sensor:
- platform: template
id: early_sensor
update_interval: never
- platform: template
id: late_sensor
update_interval: never
- platform: copy
source_id: early_sensor
name: Copy Early Sensor
- platform: copy
source_id: late_sensor
name: Copy Late Sensor
binary_sensor:
- platform: template
id: early_binary
- platform: copy
source_id: early_binary
name: Copy Early Binary
text_sensor:
- platform: template
id: early_text
update_interval: never
- platform: template
id: late_text
update_interval: never
- platform: copy
source_id: early_text
name: Copy Early Text
- platform: copy
source_id: late_text
name: Copy Late Text
button:
- platform: template
name: Update Sources
on_press:
- sensor.template.publish:
id: early_sensor
state: 3.5
- sensor.template.publish:
id: late_sensor
state: 7.25
- binary_sensor.template.publish:
id: early_binary
state: false
- text_sensor.template.publish:
id: early_text
state: changed
- text_sensor.template.publish:
id: late_text
state: updated
@@ -0,0 +1,45 @@
esphome:
name: test-copy-initially-off
host:
api:
batch_delay: 0ms
logger:
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy DEPENDENCIES = ["uart"]
uart:
baud_rate: 9600
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 9600
globals:
- id: changes
type: int
binary_sensor:
# Holds off from its constructor, so it never publishes a first state
- platform: uart
id: source_binary
uart_id: mock_uart
data: [0x01]
- platform: copy
source_id: source_binary
name: Copy Binary
on_state_change:
- lambda: id(changes)++;
- component.update: change_count
sensor:
- platform: template
id: change_count
name: Change Count
update_interval: never
lambda: return id(changes);
@@ -0,0 +1,56 @@
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
@@ -0,0 +1,57 @@
"""Integration test for the copy sensor, binary sensor and text sensor platforms."""
from __future__ import annotations
import asyncio
from aioesphomeapi import (
BinarySensorState,
ButtonInfo,
EntityState,
SensorState,
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]] = {
"copy_early_sensor": (SensorState, 1.5),
"copy_late_sensor": (SensorState, 2.5),
"copy_early_binary": (BinarySensorState, True),
"copy_early_text": (TextSensorState, "early"),
"copy_late_text": (TextSensorState, "late"),
}
UPDATED: dict[str, tuple[type[EntityState], float | bool | str]] = {
"copy_early_sensor": (SensorState, 3.5),
"copy_late_sensor": (SensorState, 7.25),
"copy_early_binary": (BinarySensorState, False),
"copy_early_text": (TextSensorState, "changed"),
"copy_late_text": (TextSensorState, "updated"),
}
@pytest.mark.asyncio
async def test_copy_entity_state(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Copies hold the state their source published before and after their setup, then follow it."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
keys = {name: require_entity(entities, name).key for name in INITIAL}
button = require_entity(entities, "update_sources", ButtonInfo)
sub = await subscribe_after_initial_states(client, entities)
for name, (cls, state) in INITIAL.items():
key = keys[name]
assert sub.initial_states[key] == cls(key=key, state=state), name
names = list(UPDATED)
waits = [sub.waiter.next_state(keys[name]) for name in names]
client.button_command(button.key)
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
@@ -0,0 +1,28 @@
"""Integration test for a copy binary sensor whose source starts off."""
from __future__ import annotations
from aioesphomeapi import BinarySensorInfo, BinarySensorState, SensorInfo, SensorState
import pytest
from .state_utils import require_entity, subscribe_after_initial_states
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_copy_initially_off(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The copy takes the source's constructor state and its on_state_change sees it."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
copy = require_entity(entities, "copy_binary", BinarySensorInfo)
count = require_entity(entities, "change_count", SensorInfo)
sub = await subscribe_after_initial_states(client, entities)
assert sub.initial_states[copy.key] == BinarySensorState(
key=copy.key, state=False
)
assert sub.initial_states[count.key] == SensorState(key=count.key, state=1.0)
@@ -0,0 +1,71 @@
"""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
+48 -1
View File
@@ -11,7 +11,7 @@ import pytest
from esphome import final_validate as fv
import esphome.codegen as cg
from esphome.components import button, number, select, switch
from esphome.components import button, number, select, sensor, switch, text_sensor
from esphome.config import Config
import esphome.config_validation as cv
from esphome.config_validation import Invalid
@@ -1883,6 +1883,53 @@ async def test_sub_entities_create_from_whole_config(
assert setup_test_environment == ["hub->set_rf_switch(rf_sw)"]
@pytest.mark.parametrize(
("follow", "expected"),
[
(
sensor.follow_sensor,
[
(
"src->add_on_state_callback([](float x) -> void {\n"
" ::dst->publish_state(x);\n})"
)
],
),
(
text_sensor.follow_text_sensor,
[
(
"src->add_on_state_callback([](const std::string & x) -> void {\n"
" ::dst->publish_state(x);\n})"
)
],
),
],
)
def test_follow_entity(
setup_test_environment: list[str],
follow: Callable[[MockObj, MockObj], None],
expected: list[str],
) -> None:
"""The entity follows its source through a lambda that captures nothing."""
follow(MockObj("dst", "->"), MockObj("src", "->"))
assert setup_test_environment == expected
def test_follow_entity_hub_callback(setup_test_environment: list[str]) -> None:
"""A hub callback takes leading arguments and the published value is an expression."""
follow = entity_helpers.FollowEntity(
"register_listener", ((cg.uint8, "x"),), "x == 3"
)
follow(MockObj("dst", "->"), MockObj("hub", "->"), 7)
assert setup_test_environment == [
(
"hub->register_listener(7, [](uint8_t x) -> void {\n"
" ::dst->publish_state(x == 3);\n})"
)
]
def test_entity_duplicate_validator_without_name() -> None:
"""A config with no name is returned untouched and not tracked."""
validator = entity_duplicate_validator("sensor")