Compare commits

..
Author SHA1 Message Date
J. Nick Koston 7c5059818d Merge remote-tracking branch 'origin/dev' into apply-entities-output
# Conflicts:
#	tests/unit_tests/core/test_entity_helpers.py
2026-10-25 07:57:07 -10:00
J. Nick Koston 2c7e9348c1 Merge branch 'dev' into apply-entities-output 2026-10-23 19:05:52 -10:00
J. Nick Koston f0c3c35800 Merge remote-tracking branch 'origin/dev' into apply-entities-output
# Conflicts:
#	esphome/components/output/__init__.py
2026-10-23 15:11:36 -10:00
J. Nick Koston ee99bab08b Merge remote-tracking branch 'origin/dev' into apply-entities-output
# Conflicts:
#	tests/unit_tests/core/test_entity_helpers.py
2026-10-21 16:39:59 -10:00
J. Nick Koston 6c1a7642f3 Merge remote-tracking branch 'origin/dev' into apply-entities-output 2026-10-20 17:36:24 -10:00
J. Nick Koston 1ce0db00b3 [output] Share one Apply output instantiation per hub with a channel index 2026-10-20 17:29:33 -10:00
J. Nick Koston a50a30414e [output] Add ApplyFloatOutput and ApplyBinaryOutput 2026-10-20 17:09:32 -10:00
22 changed files with 382 additions and 305 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
+38 -1
View File
@@ -15,7 +15,9 @@ from esphome.const import (
CONF_MIN_POWER,
CONF_POWER_SUPPLY,
)
from esphome.core import CORE
from esphome.core import CORE, ID
from esphome.core.entity_helpers import ApplyEntity, SubEntities
from esphome.cpp_generator import MockObj, SafeExpType
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -57,6 +59,8 @@ BinaryOutput = output_ns.class_("BinaryOutput")
BinaryOutputPtr = BinaryOutput.operator("ptr")
FloatOutput = output_ns.class_("FloatOutput", BinaryOutput)
FloatOutputPtr = FloatOutput.operator("ptr")
ApplyBinaryOutput = output_ns.class_("ApplyBinaryOutput", BinaryOutput)
ApplyFloatOutput = output_ns.class_("ApplyFloatOutput", FloatOutput)
async def setup_output_platform_(obj, config):
@@ -87,6 +91,39 @@ async def register_output(var, config):
await setup_output_platform_(var, config)
async def new_output(config: ConfigType, *args: SafeExpType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID], *args)
await register_output(var, config)
return var
BINARY_APPLY_ENTITY = ApplyEntity(
ApplyBinaryOutput,
((BinaryOutputPtr, "entity"), (cg.uint16, "index"), (cg.bool_, "state")),
)
FLOAT_APPLY_ENTITY = ApplyEntity(
ApplyFloatOutput,
((FloatOutputPtr, "entity"), (cg.uint16, "index"), (cg.float_, "state")),
)
def sub_binary_outputs(
config: ConfigType, *, parent: MockObj | ID | None = None
) -> SubEntities:
"""Return a SubEntities bound to new_output; ``apply`` can use ``entity``, ``index`` and ``state``.
``index`` is the constructor argument (default 0), so a hub's channels share one class.
"""
return SubEntities(new_output, config, parent, BINARY_APPLY_ENTITY)
def sub_float_outputs(
config: ConfigType, *, parent: MockObj | ID | None = None
) -> SubEntities:
"""Like sub_binary_outputs; ``apply`` gets the scaled level as ``state``."""
return SubEntities(new_output, config, parent, FLOAT_APPLY_ENTITY)
BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(BinaryOutput),
+12
View File
@@ -68,4 +68,16 @@ class BinaryOutput {
#endif
};
/// Calls the codegen-generated Fn(this, index, state) on a state write; the hub is baked into Fn.
/// The index (a hub's channel) is data, so all channels of a hub share one instantiation.
template<auto Fn> class ApplyBinaryOutput final : public BinaryOutput {
public:
explicit ApplyBinaryOutput(uint16_t index = 0) : index_(index) {}
protected:
void write_state(bool state) override { Fn(this, this->index_, state); }
uint16_t index_; // Fits in the base's tail padding
};
} // namespace esphome::output
+12
View File
@@ -130,4 +130,16 @@ class FloatOutput : public BinaryOutput {
#endif
};
/// Calls the codegen-generated Fn(this, index, state) with the scaled, inverted level; the hub is baked into Fn.
/// The index (a hub's channel) is data, so all channels of a hub share one instantiation.
template<auto Fn> class ApplyFloatOutput final : public FloatOutput {
public:
explicit ApplyFloatOutput(uint16_t index = 0) : index_(index) {}
protected:
void write_state(float state) override { Fn(this, this->index_, state); }
uint16_t index_; // Fits in the base's tail padding
};
} // namespace esphome::output
@@ -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
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Builds the min/max power scaling so the Apply output tests cover it
async def to_code_testing(config: ConfigType) -> None:
cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING")
manifest.to_code = to_code_testing
@@ -0,0 +1,81 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/output/binary_output.h"
#include "esphome/components/output/float_output.h"
namespace esphome::output::testing {
static void *last_output = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static uint16_t last_index = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static std::vector<bool> bool_writes; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static std::vector<float> levels; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void on_binary_write(BinaryOutput *output, uint16_t index, bool state) {
last_output = output;
last_index = index;
bool_writes.push_back(state);
}
static void on_float_write(FloatOutput *output, uint16_t index, float state) {
last_output = output;
last_index = index;
levels.push_back(state);
}
// The index sits in the base's tail padding; only the power supply requester leaves none.
#ifndef USE_POWER_SUPPLY
static_assert(sizeof(ApplyBinaryOutput<on_binary_write>) == sizeof(BinaryOutput));
static_assert(sizeof(ApplyFloatOutput<on_float_write>) == sizeof(FloatOutput));
#endif
static void reset() {
last_output = nullptr;
last_index = 0;
bool_writes.clear();
levels.clear();
}
TEST(ApplyBinaryOutput, WritesReachTheFunctionWithIndexAndInversion) {
reset();
ApplyBinaryOutput<on_binary_write> out(7);
out.turn_on();
out.turn_off();
out.set_inverted(true);
out.turn_on();
EXPECT_EQ(bool_writes, (std::vector<bool>{true, false, false}));
EXPECT_EQ(last_output, &out);
EXPECT_EQ(last_index, 7);
}
TEST(ApplyFloatOutput, WritesReachTheFunctionWithIndexInversionAndClamp) {
reset();
ApplyFloatOutput<on_float_write> out(3);
out.set_level(0.25f);
out.turn_on();
out.set_inverted(true);
out.set_level(0.25f);
out.set_level(2.0f);
EXPECT_EQ(levels, (std::vector<float>{0.25f, 1.0f, 0.75f, 0.0f}));
EXPECT_EQ(last_output, &out);
EXPECT_EQ(last_index, 3);
}
#ifdef USE_OUTPUT_FLOAT_POWER_SCALING
TEST(ApplyFloatOutput, PowerScalingIsAppliedBeforeTheFunction) {
reset();
ApplyFloatOutput<on_float_write> out;
out.set_min_power(0.2f);
out.set_max_power(0.8f);
out.set_zero_means_zero(true);
out.set_level(0.5f);
out.turn_off();
ASSERT_EQ(levels.size(), 2u);
EXPECT_FLOAT_EQ(levels[0], 0.5f);
EXPECT_EQ(levels[1], 0.0f);
EXPECT_EQ(last_index, 0);
}
#endif
} // namespace esphome::output::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
+45 -1
View File
@@ -11,7 +11,15 @@ import pytest
from esphome import final_validate as fv
import esphome.codegen as cg
from esphome.components import button, number, select, sensor, switch, text_sensor
from esphome.components import (
button,
number,
output,
select,
sensor,
switch,
text_sensor,
)
from esphome.config import Config
import esphome.config_validation as cv
from esphome.config_validation import Invalid
@@ -1663,6 +1671,30 @@ async def test_sub_entities_apply_shares_equal_functions(
]
@pytest.mark.asyncio
async def test_sub_outputs_share_one_function_per_hub(
setup_test_environment: list[str], apply_functions: list[tuple[str, str, str]]
) -> None:
"""A hub's channels pass their channel as the index, so they share one function."""
calls: list[tuple[Any, ...]] = []
cls = output.ApplyFloatOutput
config = {"a": _apply_conf("ch0", cls), "b": _apply_conf("ch5", cls)}
sub = _apply_sub(calls, config, output.FLOAT_APPLY_ENTITY)
await sub("a", None, 0, apply="write_channel(index, state)")
await sub("b", None, 5, apply="write_channel(index, state)")
assert calls == [
("ch0", "<esphome__ch0__fn>", "0"),
("ch5", "<esphome__ch0__fn>", "5"),
]
assert apply_functions == [
(
"esphome__ch0__fn",
"output::FloatOutput * entity, uint16_t index, float state",
"::hub->write_channel(index, state);",
)
]
@pytest.mark.asyncio
async def test_sub_entities_apply_parent_id(
monkeypatch: pytest.MonkeyPatch,
@@ -1838,6 +1870,18 @@ async def test_sub_entities_apply_without_parent_shares_equal_functions(
"select::Select * entity, size_t index",
"entity->publish_state(index);",
),
(
output.sub_binary_outputs,
output.ApplyBinaryOutput,
"output::BinaryOutput * entity, uint16_t index, bool state",
None,
),
(
output.sub_float_outputs,
output.ApplyFloatOutput,
"output::FloatOutput * entity, uint16_t index, float state",
None,
),
],
)
def test_domain_apply_entities(