Merge remote-tracking branch 'origin/esp8266-entity-names-flash' into esp8266-entity-names-components

This commit is contained in:
J. Nick Koston
2026-10-14 16:15:42 -10:00
539 changed files with 20953 additions and 1454 deletions
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Loops that only exist on boards with PSRAM are gated on this define;
# set it so the host benchmark measures the same code those boards run.
add_defines_to_code(manifest, "USE_PSRAM")
@@ -0,0 +1,82 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#include <memory>
#include "esphome/components/display/display_buffer.h"
namespace esphome::display::benchmarks {
static constexpr int kWidth = 128;
static constexpr int kHeight = 96;
// Square frame so a rotated block stays inside it.
static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight;
// A buffered display without its own draw_pixels_at(), so every block goes
// through the base class fallback and the DisplayBuffer pixel path.
class BenchDisplay : public DisplayBuffer {
public:
BenchDisplay() : frame_(std::make_unique<uint8_t[]>(kStride * kStride)) {}
void update() override {}
DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return kStride; }
int get_height_internal() override { return kStride; }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; }
std::unique_ptr<uint8_t[]> frame_;
};
// Deterministic pseudo random pixel bytes, sized for the widest format.
static std::unique_ptr<uint8_t[]> make_pixels() {
constexpr size_t bytes = kWidth * kHeight * 3;
auto data = std::make_unique<uint8_t[]>(bytes);
uint32_t seed = 11897;
for (size_t i = 0; i < bytes; i++) {
seed = seed * 1103515245u + 12345u;
data[i] = static_cast<uint8_t>(seed >> 16);
}
return data;
}
// One full block the way LVGL hands a display its rendering.
static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian,
DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) {
auto data = make_pixels();
BenchDisplay display;
display.set_rotation(rotation);
for (auto _ : state) {
display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); }
BENCHMARK(DrawPixelsAt_RGB565_BigEndian);
static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); }
BENCHMARK(DrawPixelsAt_RGB565_LittleEndian);
static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); }
BENCHMARK(DrawPixelsAt_RGB888);
static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); }
BENCHMARK(DrawPixelsAt_RGB332);
static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) {
draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(DrawPixelsAt_RGB565_Rotated);
static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) {
draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(DrawPixelsAt_RGB888_Rotated);
static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) {
draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(DrawPixelsAt_RGB332_Rotated);
} // namespace esphome::display::benchmarks
@@ -0,0 +1,196 @@
#include "esphome/core/defines.h"
#if defined(USE_API_PLAINTEXT) && defined(USE_API_HOMEASSISTANT_STATES) && defined(USE_SENSOR)
#include <benchmark/benchmark.h>
#include <sys/socket.h>
#include <array>
#include <memory>
#include "esphome/components/api/api_connection.h"
#include "esphome/components/api/api_server.h"
#include "esphome/components/homeassistant/sensor/homeassistant_sensor.h"
#include "esphome/components/socket/socket.h"
#include "esphome/core/string_ref.h"
namespace esphome::homeassistant::benchmarks {
static constexpr int kInnerIterations = 2000;
// Each benchmark gets its own API server so subscriptions from one do not
// stay in the list scanned by the next. The constructor installs itself as
// global_api_server, which is what HomeassistantSensor::setup() subscribes to.
class ScopedApiServer {
public:
ScopedApiServer() : previous_(api::global_api_server) {}
~ScopedApiServer() { api::global_api_server = this->previous_; }
api::APIServer *get() { return &this->server_; }
protected:
api::APIServer *previous_;
api::APIServer server_;
};
// The handler only reads the server's subscription list, so the connection is
// never started and its socket never used.
static std::unique_ptr<api::APIConnection> create_connection(api::APIServer *server) {
const int fd = ::socket(AF_INET, SOCK_STREAM, 0);
return std::make_unique<api::APIConnection>(std::make_unique<socket::Socket>(fd), server);
}
// State strings as Home Assistant sends them. The handler writes a terminator
// one byte past the state, so the buffers are mutable and the terminator it
// writes lands on the one the literal already has.
static constexpr int kStateCount = 4;
static constexpr size_t kStateBufferSize = 16;
using StateBuffers = char[kStateCount][kStateBufferSize];
using Messages = std::array<api::HomeAssistantStateResponse, kStateCount>;
static StateBuffers NUMERIC_STATES = {"100.50", "101.25", "99.75", "100.00"};
static StateBuffers INTEGER_STATES = {"100", "101", "99", "0"};
static StateBuffers UNAVAILABLE_STATES = {"unavailable", "unknown", "unavailable", "unknown"};
static Messages make_messages(const char *entity_id, const char *attribute, StateBuffers &states) {
Messages msgs;
for (int i = 0; i < kStateCount; i++) {
msgs[i].entity_id = StringRef(entity_id);
if (attribute != nullptr) {
msgs[i].attribute = StringRef(attribute);
}
msgs[i].state = StringRef(states[i]);
}
return msgs;
}
// --- One imported sensor, numeric state that changes every message ---
// Exercises: subscription match, in-place terminator, parse_number, publish_state.
static void HomeassistantSensor_StateResponse(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("sensor.power", nullptr, NUMERIC_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("sensor.power");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse);
// --- The same sensor with integer states such as a percentage or a count ---
// Exercises: the same path with no fractional digits to parse. Only the state
// differs from the decimal case, so the gap between the two is the parse.
static void HomeassistantSensor_StateResponse_Integer(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("sensor.power", nullptr, INTEGER_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("sensor.power");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_Integer);
// --- Attribute import: the subscription and the message both carry an attribute ---
// Exercises: the extra attribute compare before the callback.
static void HomeassistantSensor_StateResponse_Attribute(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("climate.living_room", "current_temperature", NUMERIC_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("climate.living_room");
sensor.set_attribute("current_temperature");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_Attribute);
// --- Sixteen imported sensors, message for the last one ---
// Exercises: the linear scan over the subscription list with a string compare
// per entry, which is what a device with many imported entities pays per update.
// The IDs share a length and differ only in the last two bytes, so every
// entry runs a near-full compare, the worst case for the scan.
static constexpr int kSensorCount = 16;
static void HomeassistantSensor_StateResponse_ManySubscriptions(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
static constexpr const char *const ENTITY_IDS[kSensorCount] = {
"sensor.import_00", "sensor.import_01", "sensor.import_02", "sensor.import_03",
"sensor.import_04", "sensor.import_05", "sensor.import_06", "sensor.import_07",
"sensor.import_08", "sensor.import_09", "sensor.import_10", "sensor.import_11",
"sensor.import_12", "sensor.import_13", "sensor.import_14", "sensor.import_15",
};
HomeassistantSensor sensors[kSensorCount];
for (int i = 0; i < kSensorCount; i++) {
sensors[i].set_entity_id(ENTITY_IDS[i]);
sensors[i].setup();
}
const Messages msgs = make_messages(ENTITY_IDS[kSensorCount - 1], nullptr, NUMERIC_STATES);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensors[kSensorCount - 1].state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_ManySubscriptions);
// --- Non-numeric state such as "unavailable" ---
// Exercises: the failed parse, the warning the platform logs for it, and the
// NAN publish. The benchmark logger runs at the production default level with
// console output off, so a log line at DEBUG or above costs its formatting
// here, the same as on a device, and a log level change in the platform shows
// up as a delta.
static void HomeassistantSensor_StateResponse_Unavailable(benchmark::State &state) {
ScopedApiServer server;
auto conn = create_connection(server.get());
const Messages msgs = make_messages("sensor.power", nullptr, UNAVAILABLE_STATES);
HomeassistantSensor sensor;
sensor.set_entity_id("sensor.power");
sensor.setup();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
conn->on_home_assistant_state_response(msgs[i % kStateCount]);
}
benchmark::DoNotOptimize(sensor.state);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(HomeassistantSensor_StateResponse_Unavailable);
} // namespace esphome::homeassistant::benchmarks
#endif // USE_API_PLAINTEXT && USE_API_HOMEASSISTANT_STATES && USE_SENSOR
@@ -0,0 +1,3 @@
# The homeassistant platforms subscribe through the API server.
api:
sensor:
@@ -0,0 +1,13 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# In production the platform's to_code emits this define; the host build
# suppresses that to_code, and the API server needs the define for the
# state subscription path the benchmarks drive.
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_STATES")
manifest.to_code = to_code
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Loops that only exist on boards with PSRAM are gated on this define;
# set it so the host benchmark measures the same code those boards run.
add_defines_to_code(manifest, "USE_PSRAM")
@@ -0,0 +1,245 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#include <cstring>
#include <memory>
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/image/image.h"
namespace esphome::image::benchmarks {
static constexpr int kWidth = 128;
static constexpr int kHeight = 96;
// Square frame so a rotated draw of the image stays inside it.
static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight;
// One byte per pixel frame buffer with no hardware behind it, so the
// measurement is Image::draw() plus the DisplayBuffer pixel path every
// buffered display shares. Everything fits the host cache, so this counts
// the per pixel work, not what a frame buffer in PSRAM costs.
class BenchDisplay : public display::DisplayBuffer {
public:
BenchDisplay() : frame_(std::make_unique<uint8_t[]>(kStride * kStride)) {}
void update() override {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return kStride; }
int get_height_internal() override { return kStride; }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; }
std::unique_ptr<uint8_t[]> frame_;
public:
uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; }
};
// Models a driver with a bulk path: rows of little endian RGB565 are copied
// straight into a 16 bit frame, as the TFT drivers do for their own format.
// Image::draw() only reaches it once it hands opaque images to
// draw_pixels_at(); until then this case matches ImageDraw_RGB565.
class BulkBenchDisplay : public BenchDisplay {
public:
BulkBenchDisplay() : frame565_(std::make_unique<uint16_t[]>(kStride * kStride)) {}
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override {
if (bitness != display::COLOR_BITNESS_565 || big_endian) {
BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
}
const size_t stride = x_offset + w + x_pad;
for (int y = 0; y < h; y++) {
memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2,
w * 2);
}
}
protected:
std::unique_ptr<uint16_t[]> frame565_;
};
// Draws the image at rotation 0 and 90 and checks the second frame is the
// transpose of the first, which is what a wrong traversal order would break.
static bool frames_match(Image &image) {
BenchDisplay plain;
BenchDisplay rotated;
rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES);
image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF);
image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF);
for (int y = 0; y < kHeight; y++) {
for (int x = 0; x < kWidth; x++) {
// DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees
if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x))
return false;
}
}
return true;
}
// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of
// pixels. Sized for the widest format, the image reads only what it needs.
static std::unique_ptr<uint8_t[]> make_pixels() {
constexpr size_t bytes = kWidth * kHeight * 4;
auto data = std::make_unique<uint8_t[]>(bytes);
uint32_t seed = 11897;
for (size_t i = 0; i < bytes; i++) {
seed = seed * 1103515245u + 12345u;
data[i] = static_cast<uint8_t>(seed >> 16);
}
return data;
}
static void draw_image(benchmark::State &state, ImageType type, Transparency transparency,
display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) {
auto data = make_pixels();
Image image(data.get(), kWidth, kHeight, type, transparency);
BenchDisplay display;
display.set_rotation(rotation);
if (!frames_match(image)) {
state.SkipWithError("rotated draw is not the transpose of the plain draw");
return;
}
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_Binary);
static void ImageDraw_Grayscale(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE);
}
BENCHMARK(ImageDraw_Grayscale);
static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB565);
static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB);
// The same opaque RGB565 image on a display with a bulk path.
static void ImageDraw_RGB565_Bulk(benchmark::State &state) {
auto data = make_pixels();
Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE);
BulkBenchDisplay display;
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
BENCHMARK(ImageDraw_RGB565_Bulk);
static void ImageDraw_RGB_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB_Rotated);
static void ImageDraw_Binary_Transparent(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_Binary_Transparent);
static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_Grayscale_ChromaKey);
static void ImageDraw_Grayscale_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_Grayscale_Alpha);
static void ImageDraw_RGB_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_RGB_Alpha);
static void ImageDraw_RGB_ChromaKey(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_RGB_ChromaKey);
static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(ImageDraw_RGB565_ChromaKey);
static void ImageDraw_RGB565_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_RGB565_Alpha);
static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); }
BENCHMARK(ImageDraw_RGB111);
static void ImageDraw_RGB111_Alpha(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL);
}
BENCHMARK(ImageDraw_RGB111_Alpha);
// The same cases with the display rotated 90 degrees, which walks the other loop order.
static void ImageDraw_Binary_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Binary_Rotated);
static void ImageDraw_Grayscale_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Grayscale_Rotated);
static void ImageDraw_RGB565_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB565_Rotated);
static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Binary_Transparent_Rotated);
static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated);
static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated);
static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB_Alpha_Rotated);
static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated);
static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated);
static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB565_Alpha_Rotated);
static void ImageDraw_RGB111_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB111_Rotated);
static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) {
draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES);
}
BENCHMARK(ImageDraw_RGB111_Alpha_Rotated);
} // namespace esphome::image::benchmarks
@@ -0,0 +1,125 @@
#include <benchmark/benchmark.h>
#include <cstdint>
#include <memory>
#include "esphome/components/display/display_buffer.h"
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::benchmarks {
static constexpr int kWidth = 128;
static constexpr int kHeight = 96;
// One byte per pixel frame buffer with no hardware behind it, so the
// measurement is the image draw plus the DisplayBuffer pixel path every
// buffered display shares.
class BenchDisplay : public display::DisplayBuffer {
public:
BenchDisplay() : frame_(std::make_unique<uint8_t[]>(kWidth * kHeight)) {}
void update() override {}
display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return kWidth; }
int get_height_internal() override { return kHeight; }
protected:
void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; }
std::unique_ptr<uint8_t[]> frame_;
};
// A runtime image filled the way a decoder fills it, one draw_pixel() per
// pixel, then published as decoded so draw() uses the buffer.
class BenchImage : public RuntimeImage {
public:
BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian)
: RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {}
bool fill() {
if (this->resize(kWidth, kHeight) == 0)
return false;
uint32_t seed = 11897;
for (int y = 0; y < kHeight; y++) {
for (int x = 0; x < kWidth; x++) {
seed = seed * 1103515245u + 12345u;
// Every fourth pixel is transparent so the chroma key cases hit both branches
const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF;
this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha));
}
}
this->width_ = this->buffer_width_;
this->height_ = this->buffer_height_;
this->data_start_ = this->buffer_;
return true;
}
};
// The decoder side: writing every pixel of the image into its buffer.
static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) {
BenchImage image(type, transparency, false);
for (auto _ : state) {
if (!image.fill()) {
state.SkipWithError("buffer allocation failed");
return;
}
benchmark::DoNotOptimize(image.get_data_start());
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
// The display side: drawing the decoded image onto a buffered display.
static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency,
bool big_endian = false) {
BenchImage image(type, transparency, big_endian);
if (!image.fill()) {
state.SkipWithError("buffer allocation failed");
return;
}
BenchDisplay display;
for (auto _ : state) {
image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF);
}
state.SetItemsProcessed(state.iterations() * kWidth * kHeight);
}
static void RuntimeImageFill_RGB565(benchmark::State &state) {
fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageFill_RGB565);
static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) {
fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(RuntimeImageFill_RGB565_ChromaKey);
static void RuntimeImageFill_RGB(benchmark::State &state) {
fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageFill_RGB);
static void RuntimeImageDraw_RGB565(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageDraw_RGB565);
static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true);
}
BENCHMARK(RuntimeImageDraw_RGB565_BigEndian);
static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY);
}
BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey);
static void RuntimeImageDraw_RGB(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageDraw_RGB);
static void RuntimeImageDraw_Grayscale(benchmark::State &state) {
draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE);
}
BENCHMARK(RuntimeImageDraw_Grayscale);
} // namespace esphome::runtime_image::benchmarks
@@ -0,0 +1,105 @@
"""CodSpeed benchmarks for importing entity components.
Every ``esphome config`` or ``esphome compile`` imports the Python module of
each component the YAML uses. A component that imports its optional
integrations (mqtt, web_server, zigbee) at module scope drags their whole
import chains in for every user, so the cost of a fresh import is what these
benchmarks pin down.
The core of esphome is imported once up front and stays loaded; each run
drops only the component modules from ``sys.modules`` and imports the
component again, so the import machinery and the module bodies of the
component and everything it pulls in run every time, as they do in a new
process, while a change to the core moves no number here. The modules and
registry entries the session had before a case are put back afterwards, so
later tests keep the objects they already hold.
Skipped when ``pytest-codspeed`` isn't installed so the regular unit-test
suite keeps working unchanged.
"""
from __future__ import annotations
from collections.abc import Generator, Iterable
import importlib
import sys
import pytest
pytest.importorskip("pytest_codspeed")
# Load the core before the first case so no case pays for it.
from esphome import automation, pins # noqa: E402
import esphome.codegen # noqa: E402, F401
import esphome.config_validation # noqa: E402, F401
COMPONENTS = ("binary_sensor", "sensor", "logger")
COMPONENT_PACKAGE = "esphome.components"
COMPONENT_PREFIX = COMPONENT_PACKAGE + "."
# Core registries that component module bodies write to when they are imported
REGISTRIES = (
automation.ACTION_REGISTRY,
automation.CONDITION_REGISTRY,
pins.PIN_SCHEMA_REGISTRY,
)
def _component_modules() -> dict[str, object]:
# The package itself goes too: ``from esphome.components import x`` is
# answered from its attributes, so a stale package would hide the import.
return {
n: m
for n, m in sys.modules.items()
if n == COMPONENT_PACKAGE or n.startswith(COMPONENT_PREFIX)
}
def _forget_component_modules() -> None:
for name in _component_modules():
del sys.modules[name]
def _import_component(name: str) -> object:
_forget_component_modules()
return importlib.import_module(COMPONENT_PREFIX + name)
_MISSING = object()
def _parent_bindings(names: Iterable[str]) -> list[tuple[object, str, object]]:
# An import binds the module to its parent package; a package may hold something else
# under that name (substitutions keeps a Jinja() instance as ``jinja``), so the value
# the parent has now is what goes back, not the module.
bindings = []
for name in names:
parent, _, child = name.rpartition(".")
bindings.append(
(sys.modules[parent], child, getattr(sys.modules[parent], child, _MISSING))
)
return bindings
@pytest.fixture
def restore_component_modules() -> Generator[None]:
saved = _component_modules()
saved_bindings = _parent_bindings(saved)
saved_registries = [dict(registry) for registry in REGISTRIES]
yield
_forget_component_modules()
sys.modules.update(saved)
for parent, child, value in saved_bindings:
if value is _MISSING:
delattr(parent, child)
else:
setattr(parent, child, value)
# The re-imported bodies registered their actions, conditions and pin schemas again
for registry, entries in zip(REGISTRIES, saved_registries, strict=True):
registry.clear()
registry.update(entries)
@pytest.mark.usefixtures("restore_component_modules")
@pytest.mark.parametrize("component", COMPONENTS)
def test_import_component(benchmark, component: str) -> None:
benchmark(_import_component, component)
@@ -123,3 +123,66 @@ def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin(
assert (
result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP"
)
def test_deep_sleep_empty_wakeup_pin_list_rejected() -> None:
"""An empty wakeup_pin list is rejected at validation instead of crashing codegen."""
with pytest.raises(cv.Invalid, match="at least one entry"):
deep_sleep.validate_wakeup_pin([])
def test_deep_sleep_bk72xx_wakeup_pin_mode_without_wakeup_pin_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, a top-level wakeup_pin_mode with no wakeup_pin is rejected at validation."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"):
deep_sleep.validate_config(config)
def test_deep_sleep_esp32_wakeup_pin_mode_without_wakeup_pin_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On ESP32 the same mode-only config is rejected too, instead of being silently dead."""
set_core_config(PlatformFramework.ESP32_IDF)
config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"):
deep_sleep.validate_config(config)
@pytest.mark.parametrize(
"platform", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.NRF52_ZEPHYR]
)
def test_deep_sleep_wakeup_pin_rejected_on_unsupported_platform(
set_core_config: SetCoreConfigCallable, platform: PlatformFramework
) -> None:
"""wakeup_pin is rejected where the C++ component has no wakeup pin support."""
set_core_config(platform)
with pytest.raises(cv.Invalid, match="only available on"):
deep_sleep.CONFIG_SCHEMA({CONF_WAKEUP_PIN: "GPIO4"})
def test_deep_sleep_esp32_multiple_wakeup_pins_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""ESP32 accepts only one wakeup_pin entry."""
set_core_config(PlatformFramework.ESP32_IDF)
config = {CONF_WAKEUP_PIN: [{"pin": "GPIO4"}, {"pin": "GPIO5"}]}
with pytest.raises(cv.Invalid, match="ESP32 does not support multiple") as exc:
deep_sleep.validate_config(config)
assert exc.value.path == [CONF_WAKEUP_PIN]
def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, a top-level wakeup_pin_mode cannot be applied to several pins."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {
CONF_WAKEUP_PIN: [{"pin": "GPIO12"}, {"pin": "GPIO13"}],
deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE",
}
with pytest.raises(cv.Invalid, match="define it per pin") as exc:
deep_sleep.validate_config(config)
assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE]
@@ -0,0 +1,28 @@
esphome:
name: test
esp32:
board: esp32dev
external_components:
- source:
type: local
path: ../external_components
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: epaper_spi
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
fake_display_writer:
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
external_components:
- source:
type: local
path: ../external_components
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: it8951
model: m5stack-m5paper
fake_display_writer:
@@ -0,0 +1,25 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: it8951
id: it8951_display
model: m5stack-m5paper
- platform: epaper_spi
id: epaper_display
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
@@ -0,0 +1,33 @@
esphome:
name: test
esp32:
board: esp32dev
external_components:
- source:
type: local
path: ../external_components
spi:
clk_pin: GPIO14
mosi_pin: GPIO12
miso_pin: GPIO13
display:
- platform: it8951
id: it8951_display
model: m5stack-m5paper
- platform: epaper_spi
id: epaper_display
model: ssd1677
dimensions:
width: 200
height: 200
cs_pin: GPIO5
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO25
fake_display_writer:
display_id: epaper_display
@@ -0,0 +1,20 @@
"""External component that draws into a display from outside its lambda or pages."""
from esphome.components import display
import esphome.config_validation as cv
from esphome.const import CONF_DISPLAY_ID
from esphome.types import ConfigType
DEPENDENCIES = ["display"]
DOMAIN = "fake_display_writer"
def _register_writer(config: ConfigType) -> ConfigType:
display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema({cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display)}),
_register_writer,
)
@@ -0,0 +1,98 @@
"""Tests for the display external writer registry."""
from collections.abc import Callable, Generator
from pathlib import Path
import sys
import pytest
from esphome import loader
from esphome.components.display import (
Display,
get_external_writers,
has_external_writer,
register_external_writer,
)
from esphome.core import ID
from esphome.final_validate import full_config
TEST_CARD = "show_test_card()"
UPDATE_NEVER = "set_update_interval(4294967295UL)"
@pytest.fixture
def unload_fake_writer() -> Generator[None]:
"""Drop the fake external component so later tests start clean."""
yield
loader._COMPONENT_CACHE.pop("fake_display_writer", None)
sys.modules.pop("esphome.components.fake_display_writer", None)
loader.clear_component_meta_finders()
def test_unresolved_id_matches_after_resolution() -> None:
"""An auto-picked ID registered during schema validation matches once resolved."""
pending = ID(None, is_declaration=False, type=Display)
register_external_writer(pending, "owner")
declared = ID("epd", is_declaration=True, type=Display)
assert get_external_writers(declared) == []
assert has_external_writer(declared) is False
pending.id = "epd"
assert get_external_writers(declared) == ["owner"]
assert has_external_writer(declared) is True
assert get_external_writers(ID("other", is_declaration=True, type=Display)) == []
def test_all_owners_returned() -> None:
register_external_writer(ID("epd"), "first")
register_external_writer(ID("epd"), "second")
register_external_writer(ID("other"), "third")
assert get_external_writers(ID("epd")) == ["first", "second"]
def test_lvgl_counts_as_external_writer() -> None:
assert has_external_writer(ID("epd")) is False
full_config.get()["lvgl"] = []
assert has_external_writer(ID("epd")) is True
def test_drivers_show_test_card_without_owner(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("no_owner.yaml"))
assert f"it8951_display->{TEST_CARD}" in main_cpp
assert f"epaper_display->{TEST_CARD}" in main_cpp
assert UPDATE_NEVER not in main_cpp
@pytest.mark.usefixtures("unload_fake_writer")
@pytest.mark.parametrize(
"config_file", ["it8951_owner_auto_id.yaml", "epaper_spi_owner_auto_id.yaml"]
)
def test_owned_display_has_no_test_card(
config_file: str,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The owner picks the display ID automatically, so it resolves after the schema runs."""
main_cpp = generate_main(component_config_path(config_file))
assert TEST_CARD not in main_cpp
assert UPDATE_NEVER in main_cpp
@pytest.mark.usefixtures("unload_fake_writer")
def test_only_owned_display_loses_test_card(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("owner_by_id.yaml"))
assert f"it8951_display->{TEST_CARD}" in main_cpp
assert f"epaper_display->{TEST_CARD}" not in main_cpp
assert f"epaper_display->{UPDATE_NEVER}" in main_cpp
assert f"it8951_display->{UPDATE_NEVER}" not in main_cpp
@@ -0,0 +1,38 @@
"""Tests for the emontx lock_on_reboot option: auto (the default), true or false."""
import pytest
from esphome import config_validation as cv
from esphome.components.emontx import (
CONF_LOCK_ON_REBOOT,
CONFIG_SCHEMA,
LOCK_ON_REBOOT_AUTO,
)
def _lock_key() -> cv.Optional:
return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT)
def test_default_is_auto() -> None:
assert _lock_key().default() == LOCK_ON_REBOOT_AUTO
@pytest.mark.parametrize(
("value", "expected"),
[
("auto", LOCK_ON_REBOOT_AUTO),
("AUTO", LOCK_ON_REBOOT_AUTO),
(True, True),
(False, False),
("yes", True),
("off", False),
],
)
def test_accepted_values(value: object, expected: object) -> None:
assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected
def test_other_values_rejected() -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA.schema[_lock_key()]("sometimes")
@@ -0,0 +1,16 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
keep_alive: true
@@ -0,0 +1,19 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
keep_alive: true
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO13
@@ -0,0 +1,22 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
keep_alive: true
microphone:
- platform: i2s_audio
i2s_din_pin: GPIO34
adc_type: external
pdm: false
@@ -0,0 +1,20 @@
---
esphome:
name: i2s-test
esp32:
board: esp32dev
i2s_audio:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
speaker:
- platform: i2s_audio
dac_type: external
i2s_dout_pin: GPIO12
spdif_mode: true
use_apll: true
sample_rate: 48000
channel: stereo
keep_alive: true
@@ -0,0 +1,36 @@
"""Exercise the I2S speaker keep_alive validation."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.core import CORE
def test_keep_alive_generates_setter(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = generate_main(component_config_path("keep_alive.yaml"))
assert "set_keep_alive(true)" in source
@pytest.mark.parametrize(
("config_file", "message"),
[
("keep_alive_shared_bus.yaml", "needs its own I2S bus"),
("keep_alive_shared_mic.yaml", "needs its own I2S bus"),
("keep_alive_spdif.yaml", "SPDIF mode does not support 'keep_alive'"),
],
)
def test_keep_alive_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
config_file: str,
message: str,
) -> None:
CORE.config_path = component_config_path(config_file)
assert read_config({}) is None
assert message in capsys.readouterr().out
+90
View File
@@ -102,6 +102,16 @@ from esphome.core import CORE
"File can't be opened as image",
id="invalid_image_file",
),
pytest.param(
{
"id": "image_id",
"file": "image.png",
"type": "rgb111",
"transparency": "chroma_key",
},
"Image format 'RGB111' cannot have transparency: chroma_key",
id="rgb111_with_chroma_key_transparency",
),
],
)
def test_file_platform_configuration_errors(
@@ -963,3 +973,83 @@ async def test_rgb565_alpha_animation_layout_per_frame(
assert all(a == 0x00 for a in frame1_alpha), (
f"Frame 1 alpha plane should be transparent, got {frame1_alpha}"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
(
"mode",
"transparency",
"frame0_color",
"frame1_color",
"frame0_row",
"frame1_row",
),
[
pytest.param(
"RGB",
CONF_OPAQUE,
(255, 0, 0),
(0, 0, 255),
[0x92, 0x48],
[0x24, 0x92],
id="rgb111",
),
pytest.param(
"RGBA",
CONF_ALPHA_CHANNEL,
(255, 0, 0, 255),
(0, 0, 255, 0),
[0x99, 0x99, 0x90],
[0x22, 0x22, 0x20],
id="rgb111_alpha",
),
],
)
async def test_rgb111_layout_per_frame(
tmp_path: Path,
mock_progmem_array: MagicMock,
mode: str,
transparency: str,
frame0_color: tuple[int, ...],
frame1_color: tuple[int, ...],
frame0_row: list[int],
frame1_row: list[int],
) -> None:
"""RGB111 packs one bit per channel MSB first and pads every row to a byte,
so each frame is a self-contained block of row_stride * height bytes.
"""
# A width that is not a multiple of 8 leaves padding bits in every row.
width = 5
height = 3
frame0 = PILImage.new(mode, (width, height), frame0_color)
frame1 = PILImage.new(mode, (width, height), frame1_color)
apng_path = tmp_path / "anim.png"
frame0.save(
apng_path,
format="PNG",
save_all=True,
append_images=[frame1],
duration=100,
loop=0,
)
config = {
CONF_FILE: str(apng_path),
CONF_TYPE: "RGB111",
CONF_TRANSPARENCY: transparency,
CONF_DITHER: "NONE",
CONF_INVERT_ALPHA: False,
CONF_RAW_DATA_ID: "test_raw_data_id",
}
_, _, _, _, _, frame_count = await write_image(config, all_frames=True)
assert frame_count == 2
_, raw_data = mock_progmem_array.call_args.args
data = [int(x) for x in raw_data]
frame_size = len(frame0_row) * height
assert data == frame0_row * height + frame1_row * height, (
f"Expected {frame_size} row-padded bytes per frame, got {data}"
)
@@ -0,0 +1,31 @@
esphome:
name: test
esp8266:
board: d1_mini
uart:
tx_pin: GPIO0
rx_pin: GPIO2
baud_rate: 256000
ld2450:
id: radar
text:
- platform: ld2450
ld2450_id: radar
polygon_zones:
- id: couch
name: Couch
- id: desk
name: Desk
binary_sensor:
- platform: ld2450
ld2450_id: radar
polygon_zones:
- polygon_zone_id: couch
name: Couch Occupied
- polygon_zone_id: desk
name: Desk Occupied
@@ -0,0 +1,64 @@
"""Tests for the polygon zone text pattern and maximum length.
Home Assistant validates polygon input with the pattern before it reaches the device, so the pattern must accept
what Polygon::parse() accepts. Coordinate ranges are the one exception: only the device checks those.
"""
from __future__ import annotations
import re
import pytest
from esphome.components.ld2450.text import (
MAX_POLYGON_POINTS,
POLYGON_MAX_LENGTH,
POLYGON_PATTERN,
)
# The same inputs as the C++ parser tests (ld2450_polygon.cpp), without out-of-range values
ACCEPTED = [
"",
" ",
"-1000,500;1000,500;0,3000",
" -1000 , 500 ; 1000,500; 0,3000; ",
"-4860,0;4860,0;4860,7560",
]
REJECTED = [
"0,0",
"0,0;100,100",
"junk",
"1,2;3",
"1,2;3,4;5,",
"1;2;3",
"1,2 3,4 5,6",
"1,2;;3,4;5,6",
"--1,2;3,4;5,6",
"1,2;3,4;5,6x",
"1,2;\t3,4;5,6",
]
@pytest.mark.parametrize("value", ACCEPTED)
def test_pattern_accepts_valid_polygons(value: str) -> None:
assert re.fullmatch(POLYGON_PATTERN, value)
@pytest.mark.parametrize("value", REJECTED)
def test_pattern_rejects_invalid_polygons(value: str) -> None:
assert not re.fullmatch(POLYGON_PATTERN, value)
def test_point_count_limits() -> None:
assert re.fullmatch(POLYGON_PATTERN, ";".join(["0,0"] * MAX_POLYGON_POINTS))
assert not re.fullmatch(
POLYGON_PATTERN, ";".join(["0,0"] * (MAX_POLYGON_POINTS + 1))
)
def test_longest_polygon_fits_max_length() -> None:
longest = ";".join(["-4860,7560"] * MAX_POLYGON_POINTS)
assert re.fullmatch(POLYGON_PATTERN, longest)
assert len(longest) <= POLYGON_MAX_LENGTH
# MAX_POLYGON_POINTS is the most that always fits
assert len(longest + ";-4860,7560") > POLYGON_MAX_LENGTH
@@ -0,0 +1,45 @@
"""Tests for the ld2450 polygon zone presence binary sensor schema."""
from __future__ import annotations
import pytest
from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES
from esphome.components.ld2450.binary_sensor import (
CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA,
)
import esphome.config_validation as cv
from esphome.const import CONF_FILTERS, CONF_NAME, PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _presence(**extra: object) -> ConfigType:
return {
CONF_POLYGON_ZONES: [
{CONF_POLYGON_ZONE_ID: "couch_zone", CONF_NAME: "Couch Occupied", **extra}
]
}
def test_presence_without_filters_passes(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(_presence())
def test_presence_with_filters_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Clearing a polygon bypasses filters, so they would get out of step with the zone."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(
cv.Invalid,
match=r"^Polygon zone presence binary sensors can't have filters; .*presence_timeout",
):
cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(
_presence(**{CONF_FILTERS: [{"delayed_off": "1s"}]})
)
@@ -0,0 +1,163 @@
"""Tests for the ld2450 polygon zone validation.
Polygon zones are declared in the text platform and their presence sensors in the
binary sensor platform, so the pairing between the two is checked at final validation.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.esp8266.const import (
KEY_ESP8266,
KEY_PREFERENCES,
preference_words,
)
from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES
from esphome.components.ld2450.text import (
CONFIG_SCHEMA as TEXT_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as TEXT_FINAL_VALIDATE_SCHEMA,
POLYGON_SAVED_BYTES,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_NAME, CONF_PLATFORM, PlatformFramework
from esphome.core import CORE, ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
HUB_ID = "ld2450_hub"
OTHER_HUB_ID = "ld2450_other"
def _text_config(*zone_ids: str) -> ConfigType:
"""A text platform config declaring the given polygon zones."""
return {
"ld2450_id": ID(HUB_ID, is_declaration=False, type="ld2450"),
CONF_POLYGON_ZONES: [
{CONF_ID: ID(zone_id, is_declaration=True), CONF_NAME: zone_id}
for zone_id in zone_ids
],
}
def _binary_sensors(*zone_ids: str, hub_id: str = HUB_ID) -> list[ConfigType]:
"""A binary sensor platform list with one presence sensor per given zone reference."""
return [
{
CONF_PLATFORM: "ld2450",
"ld2450_id": ID(hub_id, is_declaration=False, type="ld2450"),
CONF_POLYGON_ZONES: [
{CONF_POLYGON_ZONE_ID: ID(zone_id, is_declaration=False)}
for zone_id in zone_ids
],
}
]
def test_zones_paired_with_one_presence_pass(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": _binary_sensors("couch", "desk")},
)
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk"))
def test_zone_without_presence_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": _binary_sensors("couch")},
)
with pytest.raises(
cv.Invalid,
match=r"^Polygon zone 'desk' has no presence binary sensor; .* @ data\['polygon_zones'\]\[1\]$",
):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk"))
def test_zone_without_any_binary_sensor_platform_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, full_config={})
with pytest.raises(cv.Invalid, match="'couch' has no presence binary sensor"):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_zone_with_two_presences_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Two references to one zone are rejected, even across binary sensor platform entries."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={
"binary_sensor": _binary_sensors("couch") + _binary_sensors("couch")
},
)
with pytest.raises(
cv.Invalid,
match=r"^Polygon zone 'couch' is used by more than one presence binary sensor; ",
):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_other_platforms_are_ignored(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Binary sensors of other platforms never count as presence for a zone."""
other = {CONF_PLATFORM: "template", CONF_POLYGON_ZONES: []}
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": [other, *_binary_sensors("couch")]},
)
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_zone_without_id_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The presence sensor refers to the zone by id, so an automatic id is no use."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match=r"^'id' is required, so the zone's presence"):
TEXT_CONFIG_SCHEMA({CONF_POLYGON_ZONES: [{CONF_NAME: "Couch Zone Polygon"}]})
def test_presence_on_another_ld2450_is_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""The zone's own LD2450 drives its presence, so the sensor must name the same one."""
set_core_config(
PlatformFramework.ESP32_IDF,
full_config={"binary_sensor": _binary_sensors("couch", hub_id=OTHER_HUB_ID)},
)
with pytest.raises(
cv.Invalid,
match=(
r"^Polygon zone 'couch' belongs to LD2450 'ld2450_hub', but its presence binary "
r"sensor is under LD2450 'ld2450_other'; .* @ data\['polygon_zones'\]\[0\]$"
),
):
TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch"))
def test_esp8266_zones_reserve_their_saved_space(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Every zone reserves its polygon, so the ESP8266 can check that all saved values fit together."""
generate_main(component_config_path("esp8266_zones.yaml"))
words = preference_words(POLYGON_SAVED_BYTES)
assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(words, False)] * 2
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 B

@@ -30,7 +30,7 @@ display:
image:
- platform: file
file: mdi:battery
file: image.png
id: my_image
resize: 8x8
type: binary
@@ -0,0 +1,39 @@
"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host
build and the native ESP8266 build shell-lex build flags, while the ESP-IDF
and Zephyr builds write each flag into a quoted CMake argument; both must
decode it to one ``-DLV_CONF_PATH="..."`` argument, including when the
project path contains a space."""
from __future__ import annotations
from pathlib import Path
import re
import shlex
import pytest
from esphome.components.lvgl import lv_conf_path_flag
def _cmake_quoted_argument(flag: str) -> str:
"""Decode ``flag`` as CMake decodes a quoted argument: a backslash escapes the next character."""
return re.sub(r"\\(.)", r"\1", flag)
@pytest.mark.parametrize(
"build_path",
[
"/home/user/.esphome/build/node",
"/Users/me/with space/.esphome/build/node",
"/srv/odd 'quote' $(and)/build",
"C:/Users/Kris Maddens/esphome/.esphome/build/node",
],
ids=["plain", "space", "shell-specials", "windows"],
)
def test_flag_is_one_argument_everywhere(build_path: str) -> None:
lv_conf_h_file = Path(build_path) / "src" / "lv_conf.h"
flag = lv_conf_path_flag(lv_conf_h_file)
expected = f'-DLV_CONF_PATH="{lv_conf_h_file.as_posix()}"'
assert shlex.split(flag) == [expected]
assert _cmake_quoted_argument(flag) == expected
+53 -1
View File
@@ -2,14 +2,23 @@
from __future__ import annotations
from contextlib import nullcontext
import pytest
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER
from esphome.components.display import add_metadata
from esphome.components.lvgl import final_validation
from esphome.components.lvgl.defines import get_lv_images_used
from esphome.config import Config
from esphome.config_validation import Invalid
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.const import (
CONF_ID,
CONF_TYPE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, ID
from esphome.final_validate import full_config
@@ -175,3 +184,46 @@ class TestDrawRoundingMerge:
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0]["draw_rounding"] == 2
class TestImageTypeValidation:
"""Test the image types LVGL accepts."""
@pytest.mark.parametrize(
("image_type", "expectation"),
[
pytest.param(
"RGB111", pytest.raises(Invalid, match="does not support RGB111")
),
pytest.param("RGB565", nullcontext()),
],
)
def test_image_type(self, image_type: str, expectation) -> None:
image_id = ID("my_image", True)
fc = full_config.get()
fc["image"] = [{CONF_ID: image_id, CONF_TYPE: image_type}]
fc.declare_ids.append((image_id, ["image", 0, CONF_ID]))
get_lv_images_used().add(image_id)
with expectation:
final_validation([_make_lvgl_config(["my_disp"])])
@pytest.mark.parametrize(
("image_type", "expectation"),
[
pytest.param(
"RGB111", pytest.raises(Invalid, match="does not support RGB111")
),
pytest.param("RGB565", nullcontext()),
],
)
def test_nested_image_takes_parent_type(self, image_type: str, expectation) -> None:
"""A sendspin artwork image declares its ID below the slot that holds the type."""
image_id = ID("album_art", True)
fc = full_config.get()
fc["image"] = [{CONF_TYPE: image_type, "current_image": {CONF_ID: image_id}}]
fc.declare_ids.append((image_id, ["image", 0, "current_image", CONF_ID]))
get_lv_images_used().add(image_id)
with expectation:
final_validation([_make_lvgl_config(["my_disp"])])
@@ -93,12 +93,12 @@ def test_transform_matches_model_support(
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
# ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays
# The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays
# never support axis swapping).
model = MODELS["ESP32-8048S070"]
model = MODELS["ST7701S"]
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21}
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}})
# An unsupported axis may be explicitly disabled (a harmless no-op)...
@@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x(
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}})
def test_pure_rgb_model_has_no_hardware_transform(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A model with no init sequence has no command interface, so no transform
can be applied in hardware and every rotation must be done in software.
Previously such models claimed mirror_x/mirror_y support, so a rotation of
180 was handed to the (nonexistent) hardware and the display was not rotated.
"""
_set_s3(set_core_config)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS
model = MODELS["ESP32-8048S070"]
assert model.initsequence == ()
assert model.transforms == set()
base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21}
for rotation in (90, 180, 270):
config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation})
assert not model.rotation_as_transform(config)
metadata = get_display_metadata(config["id"])
assert metadata.rotation == rotation
assert not metadata.has_hardware_rotation
with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"):
CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}})
def test_metadata_records_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
@@ -0,0 +1,28 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 19
mosi_pin: 21
miso_pin: 23
cs_pin: 18
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
network:
priority:
- ethernet
- wifi
api:
@@ -0,0 +1,26 @@
esphome:
name: test
rp2:
board: rpipicow
wifi:
ssid: "test_ssid"
password: "test_password"
ethernet:
type: W5500
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
mac_address: "02:AA:BB:CC:DD:01"
network:
priority:
- ethernet
- wifi
api:
@@ -0,0 +1,52 @@
"""mDNS follows the interface holding the default route through an
ActiveInterfaceListener, only where NetworkComponent tracks that interface
(ESP32 with more than one interface) and the network TXT record exists."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT"
LISTENER_DEFINES = {
"USE_MDNS_ACTIVE_INTERFACE_LISTENER",
"NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT",
}
def _defines() -> set[str]:
return {define.name for define in CORE.defines}
def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml"))
assert "add_active_interface_listener(" in main_cpp
assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE}
@pytest.mark.parametrize(
("fixture", "dynamic_txt"),
[
# No api means no _esphomelib service, so no network TXT record
pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"),
# No route arbitration on RP2; mDNS keeps its periodic check there
pytest.param("priority_rp2040_api.yaml", True, id="rp2040"),
],
)
def test_mdns_does_not_register(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
fixture: str,
dynamic_txt: bool,
) -> None:
main_cpp = generate_main(component_config_path(fixture))
assert "add_active_interface_listener(" not in main_cpp
assert not LISTENER_DEFINES & _defines()
assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt
@@ -9,7 +9,10 @@ from pathlib import Path
import pytest
from esphome.components.network import require_high_performance_networking
from esphome.components.network import (
require_high_performance_networking,
require_high_performance_wifi,
)
from tests.component_tests.network import sdkconfig_option
@@ -52,3 +55,15 @@ def test_lwip_tier(
assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale
# Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded
assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers
def test_wifi_only_request_leaves_lwip_alone(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A WiFi-only request sizes the driver buffers but not the TCP windows."""
require_high_performance_wifi()
generate_main(component_config_path("high_perf_wifi_only.yaml"))
assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") is None
assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == 64
assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == 64
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
network:
enable_ipv6: true
openthread:
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
pan_id: 0x8f28
core_options:
OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1"
OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5"
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
network:
enable_ipv6: true
openthread:
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
pan_id: 0x8f28
+166 -5
View File
@@ -1,11 +1,22 @@
"""Tests for the openthread component schema."""
"""Tests for the openthread component."""
from collections.abc import Callable
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6
from esphome.components.openthread import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from esphome.core import HexInt
from esphome.components.esp32 import (
KEY_ESP32,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32C6,
)
from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE
from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX
import esphome.config_validation as cv
from esphome.const import PlatformFramework, Toolchain
from esphome.core import CORE, HexInt
from esphome.yaml_util import Sensitive
from tests.component_tests.types import SetCoreConfigCallable
@@ -37,3 +48,153 @@ def test_credentials_are_sensitive(
assert isinstance(validated[field], Sensitive)
assert validated[field] == expected
assert isinstance(validated[field], HexInt) == isinstance(expected, int)
def _generate_in(
tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str]
) -> tuple[Path, MagicMock]:
"""Generate from ``yaml_text`` written to tmp_path, so the build directory lands there.
Returns the header path and the mocked ``clean_build``.
"""
config_path = tmp_path / "test.yaml"
config_path.write_text(yaml_text, encoding="utf-8")
with patch("esphome.components.openthread.clean_build") as clean_build:
generate_main(config_path)
return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build
def test_core_options_written_to_header(
tmp_path: Path,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
header, _ = _generate_in(
tmp_path,
component_config_path("custom_header.yaml").read_text(encoding="utf-8"),
generate_main,
)
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True
assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR
assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE
assert header.read_text(encoding="utf-8") == (
"// Auto-generated by ESPHome from openthread core_options - do not edit\n"
"#pragma once\n\n"
"#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n"
"#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n"
)
def test_no_header_options_no_header(
tmp_path: Path,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
header, clean_build = _generate_in(
tmp_path,
component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"),
generate_main,
)
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig
assert not header.exists()
clean_build.assert_not_called()
@pytest.mark.parametrize(
("toolchain", "cleans"),
[(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)],
)
def test_header_change_cleans_only_on_platformio(
toolchain: Toolchain,
cleans: bool,
tmp_path: Path,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = component_config_path("custom_header.yaml").read_text(encoding="utf-8")
CORE.toolchain = toolchain
_, clean_build = _generate_in(tmp_path, source, generate_main)
assert clean_build.called is cleans
# Unchanged content: no clean on either toolchain.
CORE.reset()
CORE.toolchain = toolchain
_, clean_build = _generate_in(tmp_path, source, generate_main)
clean_build.assert_not_called()
# A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread.
changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"')
assert changed != source
CORE.reset()
CORE.toolchain = toolchain
header, clean_build = _generate_in(tmp_path, changed, generate_main)
assert clean_build.called is cleans
assert (
"#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n"
in header.read_text(encoding="utf-8")
)
@pytest.mark.parametrize(
"name",
[
"CONFIG_OPENTHREAD_JOINER",
"openthread_config_x",
"OT_CONFIG_X",
OPENTHREAD_CONFIG_PREFIX,
],
)
def test_core_option_without_prefix_rejected(
name: str, set_core_config: SetCoreConfigCallable
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
)
with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX):
CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}})
@pytest.mark.parametrize(
"name",
["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"],
)
def test_core_option_bad_characters_rejected(
name: str, set_core_config: SetCoreConfigCallable
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
)
with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"):
CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}})
@pytest.mark.parametrize(
("value", "message"),
[
("", "must not be empty"),
(" ", "must not be empty"),
("1\n#define X 2", "must be a single line"),
("1\r2", "must be a single line"),
("1 \\", "must not end with"),
("1 \\ ", "must not end with"),
],
)
def test_core_option_bad_value_rejected(
value: str, message: str, set_core_config: SetCoreConfigCallable
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6}
)
with pytest.raises(cv.Invalid, match=message):
CONFIG_SCHEMA(
{
"network_key": hex(NETWORK_KEY),
"core_options": {"OPENTHREAD_CONFIG_X": value},
}
)
def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.NRF52_ZEPHYR)
with pytest.raises(cv.Invalid, match="ESP32"):
CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}})
@@ -0,0 +1,27 @@
---
esphome:
name: test
logger:
esp32:
board: esp32dev
sensor:
- platform: template
id: temp_sensor
lambda: return 21.0;
climate:
- platform: thermostat
id: heater
name: Heater
sensor: temp_sensor
min_idle_time: 30s
min_heating_off_time: 300s
min_heating_run_time: 300s
min_cooling_off_time: 300s
heat_action:
- logger.log: heat
idle_action:
- logger.log: idle
@@ -0,0 +1,30 @@
---
esphome:
name: test
logger:
esp32:
board: esp32dev
sensor:
- platform: template
id: temp_sensor
lambda: return 21.0;
climate:
- platform: thermostat
id: dehumidifier
name: Dehumidifier
sensor: temp_sensor
min_idle_time: 0s
min_cooling_off_time: 0s
min_cooling_run_time: 0s
min_fanning_off_time: 0s
min_fanning_run_time: 0s
idle_action:
- logger.log: idle
fan_only_action:
- logger.log: fan only
dry_action:
- logger.log: dry
@@ -0,0 +1,36 @@
"""Tests for the thermostat config validation."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.core import CORE
def test_dry_only_config_is_valid(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers."""
main_cpp = generate_main(component_config_path("dry_only.yaml"))
assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp
assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp
assert "dehumidifier->set_supports_dry(true);" in main_cpp
assert "dehumidifier->set_supports_cool(" not in main_cpp
def test_cooling_timer_needs_cool_or_dry_action(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
"""A cooling timer with neither cool_action nor dry_action is still rejected."""
CORE.config_path = component_config_path("cooling_timer_without_action.yaml")
assert read_config({}) is None
captured = capsys.readouterr()
assert (
"cool_action or dry_action must be defined to use min_cooling_off_time"
in captured.out
)
@@ -0,0 +1,23 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: parent_sensor
name: Temperature
text_sensor:
- platform: tmp102
tmp102_id: parent_sensor
name: First Status
- platform: tmp102
tmp102_id: parent_sensor
name: Second Status
@@ -0,0 +1,20 @@
---
esphome:
name: tmp102-test
esp32:
board: esp32dev
i2c:
sda: GPIO21
scl: GPIO22
sensor:
- platform: tmp102
id: parent_sensor
name: Temperature
text_sensor:
- platform: tmp102
tmp102_id: parent_sensor
name: Threshold Status
@@ -58,6 +58,30 @@ def test_duplicate_alert_entities_are_rejected(
)
def test_status_entity_is_read_only(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
) -> None:
source = generate_main(component_config_path("status.yaml"))
assert "set_threshold_status_text_sensor" in source
assert "set_configuration" not in source
defines = " ".join(define.name for define in CORE.defines)
assert "USE_TMP102_TEXT_SENSOR" in defines
assert "USE_TMP102_CONFIGURE" not in defines
def test_duplicate_status_entities_are_rejected(
component_config_path: Callable[[str], Path],
capsys: pytest.CaptureFixture[str],
) -> None:
CORE.config_path = component_config_path("duplicate_status.yaml")
assert read_config({}) is None
assert (
"Only one TMP102 text_sensor may reference each temperature sensor"
in capsys.readouterr().out
)
def test_threshold_numbers_enable_configuration_once(
generate_main: Callable[[Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,44 @@
"""Test playback timeout C++ generation."""
from collections.abc import Callable
from pathlib import Path
import pytest
@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"])
def test_playback_timeout_codegen(
timeout: str | None,
tmp_path: Path,
generate_main: Callable[[Path], str],
) -> None:
config = tmp_path / "voice_assistant.yaml"
config.write_text(
"""esphome:
name: test-voice-assistant
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: test-network
api:
i2s_audio:
i2s_lrclk_pin: GPIO25
i2s_bclk_pin: GPIO26
microphone:
- platform: i2s_audio
id: test_mic
adc_type: external
i2s_din_pin: GPIO34
voice_assistant:
microphone: test_mic
"""
+ (f" tts_playback_start_timeout: {timeout}\n" if timeout else "")
)
main = generate_main(config)
if timeout in (None, "2s", "2000ms"):
assert "set_tts_playback_start_timeout(" not in main
else:
milliseconds = 1 if timeout == "1ms" else 10000
assert f"set_tts_playback_start_timeout({milliseconds});" in main
+6
View File
@@ -0,0 +1,6 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The driver lives in the time platform, so pull in what it builds against.
manifest.dependencies = manifest.dependencies + ["i2c", "time"]
+77
View File
@@ -0,0 +1,77 @@
#include <gtest/gtest.h>
#include <array>
#include <ctime>
#include "esphome/components/bm8563/bm8563.h"
#include "esphome/core/time.h"
namespace esphome::bm8563::testing {
static constexpr uint8_t ADDRESS = 0x51;
static constexpr uint8_t WEEKDAYS_REG = 0x06;
static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0
// The chip's 16 registers behind an address pointer that moves on after every byte.
class RegisterBus final : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
EXPECT_EQ(address, ADDRESS);
if (size != 0) {
this->pointer_ = data[0];
for (size_t i = 1; i < size; i++)
this->registers[this->next_()] = data[i];
}
for (size_t i = 0; i < out_size; i++)
out[i] = this->registers[this->next_()];
return i2c::ERROR_OK;
}
std::array<uint8_t, 16> registers{};
protected:
size_t next_() { return this->pointer_++ % this->registers.size(); }
uint8_t pointer_{0};
};
// write_time() takes the host's current time, so reading it back never changes the host clock.
class BM8563Test : public ::testing::Test {
protected:
void SetUp() override {
this->rtc_.set_i2c_bus(&this->bus_);
this->rtc_.set_i2c_address(ADDRESS);
this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; });
this->rtc_.setup();
}
RegisterBus bus_;
BM8563 rtc_;
unsigned syncs_{0};
};
TEST_F(BM8563Test, WriteTimeStoresTheChipsWeekday) {
// Read the clock on both sides of the write, in case UTC midnight falls in between.
const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1;
this->rtc_.write_time();
const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1;
const int stored = this->bus_.registers[WEEKDAYS_REG];
EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before;
}
// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight.
// Seven steps from the written value visit every weekday once, and each has to read back as a valid time.
// Only the weekday moves, so the time read back is still the host's time.
TEST_F(BM8563Test, WeekdayRoundTripsThroughSundayAndSaturday) {
this->rtc_.write_time();
uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG];
for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) {
weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK;
const unsigned syncs_before = this->syncs_;
this->rtc_.read_time();
EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday);
}
}
} // namespace esphome::bm8563::testing
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
bmi270: !include common.yaml
+2
View File
@@ -12,6 +12,7 @@ esphome:
bq25186:
- id: bq25186_charger
i2c_id: i2c_bus
configure_on_boot: false
fast_charge_current: 150mA
battery_regulation_voltage: 4.2V
@@ -20,6 +21,7 @@ bq25186:
charge_enabled: true
# Second charger with the boot-time write enabled
- id: bq25186_boot
i2c_id: i2c_bus
address: 0x6B
configure_on_boot: true
fast_charge_current: 20mA
+92
View File
@@ -0,0 +1,92 @@
#include <gtest/gtest.h>
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
using IntCallback = Callback<void(int)>;
// Counts destructions of the copies a callable carries, to check a boxed capture is freed exactly once.
struct DestroyCounter {
int *destroyed;
bool live{true};
explicit DestroyCounter(int *count) : destroyed(count) {}
DestroyCounter(const DestroyCounter &other) = delete;
DestroyCounter(DestroyCounter &&other) noexcept : destroyed(other.destroyed) { other.live = false; }
DestroyCounter &operator=(const DestroyCounter &other) = delete;
DestroyCounter &operator=(DestroyCounter &&other) = delete;
~DestroyCounter() {
if (this->live)
(*this->destroyed)++;
}
};
template<typename F> bool fits_inline(const F & /*callable*/) { return IntCallback::fits_inline<F>(); }
TEST(CallbackFitsInline, PointerSizedConstCallableFits) {
int value = 0;
EXPECT_TRUE(fits_inline([&value](int x) { value = x; }));
}
TEST(CallbackFitsInline, MutableCallableDoesNotFit) {
int calls = 0;
EXPECT_FALSE(fits_inline([calls](int) mutable { calls++; }));
}
TEST(CallbackFitsInline, LargeCallableDoesNotFit) {
int a = 0;
int b = 0;
EXPECT_FALSE(fits_inline([&a, &b](int x) { a = b = x; }));
}
TEST(CallbackCreate, InlineCallableIsCalled) {
int value = 0;
auto cb = IntCallback::create([&value](int x) { value = x; });
cb.call(42);
EXPECT_EQ(value, 42);
}
int mutable_seen = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// A pointer-sized mutable lambda used to be stored inline and run on a copy, losing its state.
TEST(CallbackCreate, MutableStateIsKeptAcrossCalls) {
auto counter = [n = 0](int x) mutable { mutable_seen = (n += x); };
static_assert(sizeof(counter) <= sizeof(void *) && std::is_trivially_copyable_v<decltype(counter)>);
// create() never frees its heap path; held for the whole run like a callback registered in setup().
static auto cb = IntCallback::create(counter);
cb.call(1);
cb.call(2);
EXPECT_EQ(mutable_seen, 3);
}
TEST(CallbackCreateBoxed, MutableStateIsKeptAcrossCalls) {
int seen = 0;
auto cb = IntCallback::create_boxed([count = 0, &seen](int x) mutable {
count += x;
seen = count;
});
cb.call(1);
cb.call(2);
cb.call(3);
EXPECT_EQ(seen, 6);
cb.free_boxed();
}
TEST(CallbackCreateBoxed, FreeDestroysCaptureExactlyOnce) {
int destroyed = 0;
int calls = 0;
{
auto cb = IntCallback::create_boxed([counter = DestroyCounter(&destroyed), &calls](int) { calls++; });
EXPECT_EQ(destroyed, 0);
cb.call(0);
cb.call(0);
EXPECT_EQ(destroyed, 0);
cb.free_boxed();
EXPECT_EQ(destroyed, 1);
}
// The Callback itself owns nothing, so going out of scope frees nothing more
EXPECT_EQ(destroyed, 1);
EXPECT_EQ(calls, 2);
}
} // namespace esphome::core::testing
@@ -0,0 +1,77 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
static uint32_t bits(float value) {
uint32_t out;
memcpy(&out, &value, sizeof(out));
return out;
}
// strtof is the reference: every accepted input must give the same bits.
static void expect_matches_strtof(const char *str) {
char *end = nullptr;
const float expected = ::strtof(str, &end);
const auto parsed = parse_number<float>(str);
if (!parsed.has_value()) {
ADD_FAILURE() << str << " was rejected";
return;
}
const float actual = parsed.value();
EXPECT_EQ(bits(actual), bits(expected)) << str << " parsed " << actual << " expected " << expected;
}
TEST(ParseFloat, PlainDecimalsMatchStrtof) {
for (const char *str :
{"0", "-0", "+0", "1", "-1", "100.50", "99.75", "0.1", "0.3",
"2.675", "9999999", "0.9999999", "1234.567", "-1234.567", "0.000001", "0.000000001", "12.", ".5",
"-.5", "007", "00.50", "123.4567", "0.009999999", "0.001234567", "-0.009999999"}) {
expect_matches_strtof(str);
}
}
TEST(ParseFloat, SweepOfTwoDecimalValuesMatchesStrtof) {
char buf[16];
for (int i = -20000; i <= 20000; i++) {
snprintf(buf, sizeof(buf), "%s%d.%02d", i < 0 ? "-" : "", std::abs(i) / 100, std::abs(i) % 100);
expect_matches_strtof(buf);
}
}
// A fraction longer than the fast path accepts must fall back, however long it is.
TEST(ParseFloat, LongFractionsMatchStrtof) {
std::string str = "0.";
str.append(300, '0');
str += "25";
expect_matches_strtof(str.c_str());
expect_matches_strtof(("-" + str).c_str());
str = "1.";
str.append(300, '0');
expect_matches_strtof(str.c_str());
}
TEST(ParseFloat, LibcPathInputsMatchStrtof) {
for (const char *str : {"1e3", "-2.5E-2", "0x10", "nan", " 12", "123456789", "16777215", "1.0000000", "0.0000000001",
"0.00000000001", "1.23456789", "3.4028235e38"}) {
expect_matches_strtof(str);
}
}
// Positive overflow and a positive literal infinity both come back as HUGE_VALF from strtof, and the parser
// rejects both; the negative forms pass through, as they did before the fast path.
TEST(ParseFloat, RejectsInvalidInput) {
for (const char *str :
{"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf", "infinity"}) {
EXPECT_FALSE(parse_number<float>(str).has_value()) << str;
}
}
} // namespace esphome::core::testing
@@ -0,0 +1,65 @@
#include <gtest/gtest.h>
#include <cstring>
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::core::testing {
PROGMEM_STRING_TABLE(SmallStrings, "OFF", "HEAT", "COOL", "UNKNOWN");
// Eight strings of 40 characters: a 328-byte blob, past what one-byte offsets can address, so it is scanned.
PROGMEM_STRING_TABLE(LargeStrings, "0123456789012345678901234567890123456789",
"1123456789012345678901234567890123456789", "2123456789012345678901234567890123456789",
"3123456789012345678901234567890123456789", "4123456789012345678901234567890123456789",
"5123456789012345678901234567890123456789", "6123456789012345678901234567890123456789",
"7123456789012345678901234567890123456789");
// Five 50-character strings pack into exactly 255 bytes; one more byte switches to scanning.
PROGMEM_STRING_TABLE(Exactly255, "01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789");
PROGMEM_STRING_TABLE(Exactly256, "01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789",
"01234567890123456789012345678901234567890123456789x");
TEST(ProgmemStringTable, ScansInsteadOfIndexingAfter255Bytes) {
static_assert(Exactly255::BLOB_SIZE == 255);
static_assert(Exactly255::Table::INDEXED);
static_assert(Exactly256::BLOB_SIZE == 256);
static_assert(!Exactly256::Table::INDEXED);
static_assert(Exactly256::OFFSETS.empty());
EXPECT_EQ(Exactly255::OFFSETS[4], 204);
EXPECT_STREQ(reinterpret_cast<const char *>(Exactly256::get_progmem_str(4, 0)),
"01234567890123456789012345678901234567890123456789x");
}
TEST(ProgmemStringTable, SmallTableKeepsOneByteOffsets) {
static_assert(SmallStrings::BLOB_SIZE <= 255);
static_assert(SmallStrings::Table::INDEXED);
static_assert(SmallStrings::MAX_LENGTH == 7);
EXPECT_STREQ(reinterpret_cast<const char *>(SmallStrings::get_log_str(0, SmallStrings::LAST_INDEX)), "OFF");
EXPECT_STREQ(reinterpret_cast<const char *>(SmallStrings::get_log_str(2, SmallStrings::LAST_INDEX)), "COOL");
EXPECT_STREQ(reinterpret_cast<const char *>(SmallStrings::get_log_str(200, SmallStrings::LAST_INDEX)), "UNKNOWN");
}
TEST(ProgmemStringTable, LargeTableIsScanned) {
static_assert(LargeStrings::BLOB_SIZE > 255);
static_assert(!LargeStrings::Table::INDEXED);
static_assert(LargeStrings::MAX_LENGTH == 40);
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(0, LargeStrings::LAST_INDEX)),
"0123456789012345678901234567890123456789");
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(7, LargeStrings::LAST_INDEX)),
"7123456789012345678901234567890123456789");
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(8, LargeStrings::LAST_INDEX)),
"7123456789012345678901234567890123456789");
EXPECT_STREQ(reinterpret_cast<const char *>(LargeStrings::get_progmem_str(3, LargeStrings::LAST_INDEX)),
"3123456789012345678901234567890123456789");
}
} // namespace esphome::core::testing
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <vector>
#include "esphome/components/uart/uart_component.h"
namespace esphome::cse7766::testing {
class MockUARTComponent : public uart::UARTComponent {
public:
std::vector<uint8_t> rx;
void push_rx(const std::vector<uint8_t> &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); }
// UARTComponent
void write_array(const uint8_t *data, size_t len) override {}
bool read_array(uint8_t *data, size_t len) override {
if (this->rx.size() < len) {
return false;
}
std::copy(this->rx.begin(), this->rx.begin() + len, data);
this->rx.erase(this->rx.begin(), this->rx.begin() + len);
return true;
}
size_t available() override { return this->rx.size(); }
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
MOCK_METHOD(void, check_logger_conflict, (), (override));
#if defined(USE_ESP8266) || defined(USE_ESP32)
void load_settings(bool dump_config) override {}
#endif // defined(USE_ESP8266) || defined(USE_ESP32)
};
} // namespace esphome::cse7766::testing
@@ -0,0 +1,117 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "esphome/components/cse7766/cse7766.h"
#include "esphome/components/sensor/sensor.h"
#include "../common.h"
namespace esphome::cse7766::testing {
namespace {
// Coefficients and cycles taken from a Sonoff S60 (CSE7759B). With this power coefficient a power
// cycle of 2507613 is about 2.16 W, and the cycle register advances by about 1050000 per second.
constexpr uint32_t VOLTAGE_COEFF = 0x030BB0;
constexpr uint32_t VOLTAGE_CYCLE = 0x000352;
constexpr uint32_t CURRENT_COEFF = 0x004137;
constexpr uint32_t CURRENT_CYCLE = 0x053339;
constexpr uint32_t POWER_COEFF = 5425000;
constexpr uint32_t LOW_LOAD_CYCLE = 2507613;
constexpr uint32_t SHORT_CYCLE = 900000; // about 6 W, just under 1 s
constexpr uint32_t HIGH_LOAD_CYCLE = 2000;
constexpr uint8_t HEADER_OK = 0x55;
constexpr uint8_t HEADER_POWER_CYCLE_EXCEEDS_RANGE = 0xF2;
constexpr uint8_t ADJ_NEW_POWER = 0x71;
constexpr uint8_t ADJ_NO_NEW_POWER = 0x61;
void put24(std::vector<uint8_t> &frame, uint32_t value) {
frame.push_back(value >> 16);
frame.push_back(value >> 8);
frame.push_back(value);
}
std::vector<uint8_t> make_frame(uint8_t header, uint32_t power_cycle, uint8_t adj) {
std::vector<uint8_t> frame{header, 0x5A};
put24(frame, VOLTAGE_COEFF);
put24(frame, VOLTAGE_CYCLE);
put24(frame, CURRENT_COEFF);
put24(frame, CURRENT_CYCLE);
put24(frame, POWER_COEFF);
put24(frame, power_cycle);
frame.push_back(adj);
frame.push_back(0x0B);
frame.push_back(0x81);
uint8_t checksum = 0;
for (size_t i = 2; i < frame.size(); i++) {
checksum += frame[i];
}
frame.push_back(checksum);
return frame;
}
class CSE7766Test : public ::testing::Test {
protected:
void SetUp() override {
this->sut_.set_uart_parent(&this->uart_);
this->sut_.set_power_sensor(&this->power_);
}
float feed_(uint8_t header, uint32_t power_cycle, uint8_t adj) {
this->uart_.push_rx(make_frame(header, power_cycle, adj));
this->sut_.loop();
return this->power_.state;
}
MockUARTComponent uart_;
CSE7766Component sut_;
sensor::Sensor power_;
};
} // namespace
TEST_F(CSE7766Test, NewMeasurementIsReported) {
EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER), 2.163f, 0.001f);
}
// At low load one power cycle spans many frames. Frames in between carry the last cycle or the
// time since the last pulse, and must not be reported as 0 W.
TEST_F(CSE7766Test, LowLoadKeepsLastMeasurementBetweenPulses) {
this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER);
EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NO_NEW_POWER), 2.163f, 0.001f);
EXPECT_NEAR(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 2.163f, 0.001f);
EXPECT_NEAR(this->feed_(HEADER_OK, 4900000, ADJ_NO_NEW_POWER), 2.163f, 0.001f);
}
// Below 1 s the chip does not send waiting times in normal operation, so such a frame
// means the pulse is overdue and 0 W is reported at once, as before
TEST_F(CSE7766Test, ShortCycleReportsZeroWithoutNewMeasurement) {
this->feed_(HEADER_OK, SHORT_CYCLE, ADJ_NEW_POWER);
EXPECT_EQ(this->feed_(HEADER_OK, 1050000, ADJ_NO_NEW_POWER), 0.0f);
}
TEST_F(CSE7766Test, OverduePulseReportsZero) {
this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER);
EXPECT_EQ(this->feed_(HEADER_OK, 2 * LOW_LOAD_CYCLE + 1, ADJ_NO_NEW_POWER), 0.0f);
// Stays 0 even when a later frame shows a short cycle again, until a new measurement arrives
EXPECT_EQ(this->feed_(HEADER_OK, 1000000, ADJ_NO_NEW_POWER), 0.0f);
}
// A large load switching off: the first frame without a new measurement already shows about one
// second since the last pulse, far beyond the 2 ms cycle, so 0 W is reported at once.
TEST_F(CSE7766Test, HighLoadSwitchingOffReportsZeroAtOnce) {
EXPECT_NEAR(this->feed_(HEADER_OK, HIGH_LOAD_CYCLE, ADJ_NEW_POWER), 2712.5f, 0.1f);
EXPECT_EQ(this->feed_(HEADER_OK, 1072601, ADJ_NO_NEW_POWER), 0.0f);
}
TEST_F(CSE7766Test, NoMeasurementYetReportsZero) { EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); }
TEST_F(CSE7766Test, PowerCycleExceedsRangeReportsZero) {
this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER);
EXPECT_EQ(this->feed_(HEADER_POWER_CYCLE_EXCEEDS_RANGE, 9000000, ADJ_NO_NEW_POWER), 0.0f);
EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f);
}
} // namespace esphome::cse7766::testing
@@ -121,7 +121,7 @@ struct Rig {
DaikinClimate climate;
Rig() {
// All instances share one preference key; clear it so setup starts from defaults
// A second fixture in the same test would otherwise restore what the first saved
if (global_preferences != nullptr)
global_preferences->reset();
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
+1 -1
View File
@@ -143,7 +143,7 @@ struct Fixture {
/// Pass keep_preferences to restore what an earlier fixture saved
explicit Fixture(bool keep_preferences = false) {
// Every fixture shares one preference key, so clear what an earlier test saved or setup restores it
// A second fixture in the same test would otherwise restore what the first saved
if (!keep_preferences && global_preferences != nullptr)
global_preferences->reset();
// The test main does not construct App as generated code does; the boost timer needs its scheduler
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
import esphome.codegen as cg
from esphome.const import CONF_ESPHOME, CONF_INCLUDES
from esphome.core import CORE
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
TESTS_DIR = Path(__file__).parent
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The column walk in Display::draw_pixels_at() is only built with PSRAM; define it
# when display's own tests are built so both walk orders run, and leave other
# components' test binaries on the plain build.
async def to_code_testing(config: ConfigType) -> None:
if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]):
cg.add_define("USE_PSRAM")
manifest.to_code = to_code_testing
@@ -0,0 +1,109 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <initializer_list>
#include <tuple>
#include <vector>
#include "esphome/components/display/display.h"
namespace esphome::display::testing {
// Records every pixel write; pixel_axes_swapped() picks the walk order.
class RecordingDisplay : public Display {
public:
void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; }
bool pixel_axes_swapped() const override { return this->axes_swapped_; }
void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); }
void update() override {}
DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; }
int get_width_internal() override { return 64; }
int get_height_internal() override { return 64; }
std::vector<std::tuple<int, int, uint32_t>> sorted_pixels() const {
auto sorted = this->pixels_;
std::sort(sorted.begin(), sorted.end());
return sorted;
}
size_t count() const { return this->pixels_.size(); }
const std::vector<std::tuple<int, int, uint32_t>> &pixels() const { return this->pixels_; }
protected:
bool axes_swapped_{false};
std::vector<std::tuple<int, int, uint32_t>> pixels_;
};
// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of
// right padding, so the source index arithmetic of both walks is exercised.
static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4;
static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD;
static constexpr int LINES = Y_OFFSET + H;
static std::vector<uint8_t> make_source(int bytes_per_pixel) {
std::vector<uint8_t> source(LINE_STRIDE * LINES * bytes_per_pixel);
uint8_t value = 1;
for (auto &byte : source)
byte = value++;
return source;
}
static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) {
const auto source = make_source(bytes_per_pixel);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD);
EXPECT_EQ(rows.count(), static_cast<size_t>(W * H));
EXPECT_EQ(columns.count(), static_cast<size_t>(W * H));
EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels());
}
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); }
TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); }
// One known pixel per bitness and byte order, so a decode that swapped bytes or channels
// could not pass the walk comparison above by being wrong the same way twice.
static void expect_golden(ColorBitness bitness, std::initializer_list<uint8_t> bytes, bool big_endian, Color expected) {
const std::vector<uint8_t> source(bytes);
RecordingDisplay display;
display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0);
ASSERT_EQ(display.count(), 1u);
EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32));
}
TEST(DrawPixelsAt, Golden565BigEndian) {
expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, true, Color(230, 0, 57)); // 0xE007: r 28, g 0, b 7
}
TEST(DrawPixelsAt, Golden565LittleEndian) {
expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green
}
TEST(DrawPixelsAt, Golden888BigEndian) {
expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30));
}
TEST(DrawPixelsAt, Golden888LittleEndian) {
expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10));
}
TEST(DrawPixelsAt, Golden332) {
expect_golden(COLOR_BITNESS_332, {0xE3}, true, Color(255, 0, 255)); // r 7 of 7, g 0, b 3 of 3
}
// The column walk really is a different order, otherwise the tests above prove nothing.
TEST(DrawPixelsAt, ColumnWalkChangesOrder) {
const auto source = make_source(1);
RecordingDisplay rows;
RecordingDisplay columns;
columns.set_axes_swapped(true);
rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD);
columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET,
X_PAD);
ASSERT_GT(rows.count(), 1u);
ASSERT_GT(columns.count(), 1u);
EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first
EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first
}
} // namespace esphome::display::testing
@@ -0,0 +1,9 @@
emontx:
id: test_emontx
lock_on_reboot: true
text_sensor:
- platform: emontx
emontx_id: test_emontx
version:
name: emonTx firmware version
+21
View File
@@ -8,6 +8,8 @@
#include <vector>
#include "esphome/components/uart/uart_component.h"
#include "esphome/components/emontx/emontx.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::emontx::testing {
@@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent {
#endif // defined(USE_ESP8266) || defined(USE_ESP32)
};
// The test main does not construct App as generated code does
inline void construct_app() {
static const bool APP_CONSTRUCTED = (new (&App) Application(), true);
(void) APP_CONSTRUCTED;
}
inline void cancel_timers(EmonTx *emontx) {
for (uint32_t id = 0; id < 3; id++)
App.scheduler.cancel_timeout(emontx, id);
App.scheduler.call(millis());
}
// Lets timeouts due within duration_ms run; the scheduler reads the real clock
inline void run_timers(uint32_t duration_ms) {
App.scheduler.call(millis());
delay(duration_ms); // NOLINT - test only
App.scheduler.call(millis());
}
} // namespace esphome::emontx::testing
-1
View File
@@ -8,7 +8,6 @@ button:
emontx:
id: test_emontx
lock_on_reboot: true
on_json:
- then:
- logger.log: "Got JSON"
+180 -5
View File
@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
@@ -13,11 +14,16 @@ namespace {
class EmonTxTest : public ::testing::Test {
protected:
void SetUp() override {
construct_app();
this->sut_.set_uart_parent(&this->uart_);
this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); });
this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; });
}
void TearDown() override { cancel_timers(&this->sut_); }
std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); }
MockUARTComponent uart_;
EmonTx sut_;
std::vector<std::string> lines_;
@@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) {
TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) {
EXPECT_CALL(this->uart_, flush()).Times(1);
this->sut_.set_lock_on_reboot(true);
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.on_shutdown();
EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n");
}
@@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) {
TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) {
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.set_lock_on_reboot(true);
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.set_paused(true);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) {
this->sut_.set_lock_on_reboot(true);
// The emonTx would join "emonunlock" to the pending empty line if both came in one burst
TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) {
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.setup();
EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n");
EXPECT_EQ(this->tx_(), "\n");
run_timers(300);
EXPECT_EQ(this->tx_(), "\nemonunlock\n");
}
TEST_F(EmonTxTest, SetupSendsNothingByDefault) {
@@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) {
EXPECT_TRUE(this->uart_.tx.empty());
}
class EmonTxLockAutoTest : public EmonTxTest {
protected:
void SetUp() override {
EmonTxTest::SetUp();
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO);
}
void report_version_(const char *version) {
this->uart_.push_rx(" - Version: ");
this->uart_.push_rx(version);
this->uart_.push_rx("\r\n");
this->sut_.loop();
}
};
// Only 'v' goes out until the reply shows the firmware knows 'emonunlock'
TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) {
this->sut_.setup();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) {
this->report_version_("v1.2.0");
EXPECT_TRUE(this->uart_.tx.empty());
run_timers(300);
EXPECT_EQ(this->tx_(), "emonunlock\n");
EXPECT_CALL(this->uart_, flush()).Times(1);
this->sut_.on_shutdown();
EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n");
}
// Firmware before v1.2.0 reads "emon..." as the bootloader command
TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) {
this->report_version_("v1.1.1");
run_timers(300);
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) {
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, VersionFormats) {
for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) {
this->report_version_("v0.99.0"); // So the next one is a change
this->report_version_(version);
EXPECT_CALL(this->uart_, flush()).Times(1);
this->sut_.on_shutdown();
EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version;
::testing::Mock::VerifyAndClearExpectations(&this->uart_);
this->uart_.tx.clear();
}
for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) {
this->report_version_(version);
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty()) << version;
::testing::Mock::VerifyAndClearExpectations(&this->uart_);
}
}
// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock
TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) {
this->report_version_("v1.2.0");
run_timers(300);
this->report_version_("v1.2.0");
run_timers(300);
EXPECT_EQ(this->tx_(), "emonunlock\n");
}
TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) {
this->sut_.setup();
run_timers(300);
this->uart_.push_rx("{\"MSG\":10}\n");
this->sut_.loop();
EXPECT_EQ(this->tx_(), "\nv\n");
// Text format counts too
this->uart_.push_rx("MSG:11,V1:230.1\n");
this->sut_.loop();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n\nv\n");
this->report_version_("v1.1.1");
this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n");
this->sut_.loop();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n\nv\n");
}
// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v'
TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) {
this->sut_.setup();
for (int msg = 10; msg < 20; msg++) {
char line[24];
snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg);
this->uart_.push_rx(line);
this->sut_.loop();
}
EXPECT_EQ(this->tx_(), "\n\n\n");
}
// E.g. a downgrade: after the reboot, the new firmware never answers 'v'
TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) {
this->report_version_("v1.2.0");
run_timers(300);
for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) {
char line[24];
snprintf(line, sizeof(line), "MSG:%d\n", msg);
this->uart_.push_rx(line);
this->sut_.loop();
}
this->uart_.tx.clear();
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) {
this->report_version_("v1.2.0");
run_timers(300);
this->uart_.push_rx("MSG:50\nMSG:1\n");
this->sut_.loop();
this->uart_.tx.clear();
EXPECT_CALL(this->uart_, flush()).Times(0);
this->sut_.on_shutdown();
EXPECT_TRUE(this->uart_.tx.empty());
}
TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) {
this->report_version_("v1.2.0");
this->sut_.set_paused(true);
this->sut_.loop();
run_timers(300);
EXPECT_TRUE(this->uart_.tx.empty());
// Resume asks for the version again, and its reply brings the unlock
this->sut_.set_paused(false);
this->sut_.loop();
run_timers(300);
this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume
this->report_version_("v1.2.0");
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n");
}
// The lines of a 'v' reply before the version line are not data lines
TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) {
this->sut_.setup();
run_timers(300);
this->uart_.push_rx(
"> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n");
this->sut_.loop();
run_timers(300);
EXPECT_EQ(this->tx_(), "\nv\n");
}
} // namespace esphome::emontx::testing
@@ -2,3 +2,4 @@ packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
emontx: !include common.yaml
actions: !include common-actions.yaml
version: !include common-version.yaml
@@ -0,0 +1,12 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The platform's own to_code needs a configured hub; only its define is wanted here.
async def to_code_testing(config: ConfigType) -> None:
cg.add_define("USE_EMONTX_VERSION")
cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR")
manifest.to_code = to_code_testing
@@ -0,0 +1,117 @@
#include <gtest/gtest.h>
#include <string>
#include "esphome/components/emontx/emontx.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "../common.h"
namespace esphome::emontx::testing {
namespace {
class EmonTxVersionTest : public ::testing::Test {
protected:
void SetUp() override {
construct_app();
this->sut_.set_uart_parent(&this->uart_);
this->sut_.set_version_text_sensor(&this->version_);
}
void TearDown() override { cancel_timers(&this->sut_); }
// Lets the delayed 'v' go out
static void run_timers() { testing::run_timers(300); }
std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); }
MockUARTComponent uart_;
EmonTx sut_;
text_sensor::TextSensor version_;
};
} // namespace
// 'v' must not reach the emonTx in the same burst as the line feed, or it joins the pending command
TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) {
this->sut_.setup();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) {
this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS);
this->sut_.setup();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nemonunlock\n\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nemonunlock\n\nv\n");
}
TEST_F(EmonTxVersionTest, VersionLinePublished) {
this->uart_.push_rx("> Firmware:\r\n - Bootloader: UART (.bin)\r\n - Version: v1.1.1\r\n");
this->sut_.loop();
ASSERT_TRUE(this->version_.has_state());
EXPECT_EQ(this->version_.get_state(), "v1.1.1");
}
TEST_F(EmonTxVersionTest, OtherLinesNotPublished) {
this->uart_.push_rx(" - Build: 768be1b (Default)\r\n{\"P1\":100}\r\n - Version:\r\n - Version: \r\n");
this->sut_.loop();
EXPECT_FALSE(this->version_.has_state());
}
// MSG restarts at 1 after a reboot, e.g. a firmware update
TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) {
this->uart_.push_rx("{\"MSG\":41}\n{\"MSG\":42}\n");
this->sut_.loop();
EXPECT_TRUE(this->uart_.tx.empty());
this->uart_.push_rx("{\"MSG\":1}\n");
this->sut_.loop();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) {
this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\n");
this->sut_.loop();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nv\n");
}
// The emonTx may have been flashed during the pause, with MSG already past its old value
TEST_F(EmonTxVersionTest, ResumeRequestsVersion) {
this->sut_.set_paused(true);
this->sut_.loop();
this->sut_.set_paused(false);
this->sut_.loop();
EXPECT_EQ(this->tx_(), "\n");
run_timers();
EXPECT_EQ(this->tx_(), "\nv\n");
}
TEST_F(EmonTxVersionTest, RequestDroppedByPauseSentAgainOnResume) {
this->sut_.setup();
this->sut_.set_paused(true);
this->sut_.loop();
run_timers();
EXPECT_EQ(this->tx_(), "\n");
this->sut_.set_paused(false);
this->sut_.loop();
run_timers();
EXPECT_EQ(this->tx_(), "\n\nv\n");
}
} // namespace esphome::emontx::testing
@@ -0,0 +1,145 @@
#include <gtest/gtest.h>
#include <algorithm>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h"
#include "esphome/components/epaper_spi/epaper_spi_t133a01.h"
namespace esphome::epaper_spi::testing {
/// A busy pin the test can hold at "busy" and release.
class SwitchableBusyPin : public RecordingPin {
public:
bool digital_read() override { return this->busy; }
bool busy{false};
};
template<typename T> void loop_until_idle(T &display) {
const uint32_t deadline = millis() + 1000;
while (display.state() != EPaperState::IDLE && millis() < deadline)
display.loop();
}
class TestableSSD1683 : public EPaperSSD1683 {
public:
TestableSSD1683() : EPaperSSD1683("test", 16, 2, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
this->set_busy_pin(&this->busy);
this->set_full_update_every(5);
this->set_auto_clear(false);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait
}
void draw_whole_panel() {
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
EPaperState state() const { return this->state_; }
uint8_t update_count() const { return this->update_count_; }
void set_update_count(uint8_t count) { this->update_count_ = count; }
RecordingPin dc;
SwitchableBusyPin busy;
};
/// If the busy pin never clears, the update is abandoned instead of waiting for ever: the display goes back to
/// idle with a warning. Once the pin works again the next update is accepted and is a full one, even though nothing
/// was drawn since, and completing it clears the warning.
TEST(EPaperBusyTimeout, StuckBusyPinAbandonsTheUpdateAndRecovers) {
TestableSSD1683 display;
RecordingDelegate bus(&display.dc);
display.install(&bus);
display.set_update_count(3);
// Stick the pin after the data has been sent, when the dirty area has already been emptied
display.draw_whole_panel();
display.update();
const uint32_t deadline = millis() + 1000;
while (display.state() <= EPaperState::TRANSFER_DATA && millis() < deadline)
display.loop();
ASSERT_EQ(display.state(), EPaperState::POWER_ON);
display.busy.busy = true;
loop_until_idle(display);
EXPECT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned";
EXPECT_TRUE(display.status_has_warning());
display.busy.busy = false;
display.update();
EXPECT_NE(display.state(), EPaperState::IDLE) << "a later update was refused";
loop_until_idle(display);
EXPECT_EQ(display.state(), EPaperState::IDLE) << "the later update did not complete";
EXPECT_EQ(display.update_count(), 1) << "the later update was not a full one";
EXPECT_FALSE(display.status_has_warning());
}
class TestableT133A01 : public EPaperT133A01 {
public:
TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {}
void install(spi::SPIDelegate *delegate) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
this->set_cs_pins(&this->cs, &this->cs1);
this->set_busy_pin(&this->busy);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait
}
void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); }
EPaperState state() const { return this->state_; }
RecordingPin dc, cs, cs1;
SwitchableBusyPin busy;
};
/// The T133A01 waits for the busy pin with a data stream open. Abandoning the update there must close the SPI
/// transaction and release both chip selects, or the next command would block.
TEST(EPaperBusyTimeout, AbandonedT133A01TransferReleasesTheBus) {
TestableT133A01 display;
TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5);
display.install(&delegate);
display.start_transfer();
display.loop();
ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA);
ASSERT_FALSE(display.cs.level) << "the transfer should have yielded with its data stream open";
display.busy.busy = true;
loop_until_idle(display);
ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned";
EXPECT_EQ(delegate.begin_count, delegate.end_count) << "SPI transaction left open";
EXPECT_TRUE(display.cs.level) << "CS left asserted";
EXPECT_TRUE(display.cs1.level) << "CS1 left asserted";
}
/// Abandoning an update powers the panel off, so it is not left with its high-voltage drivers enabled.
TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) {
TestableT133A01 display;
RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 5); // burn time so the transfer yields mid-stream
display.install(&bus);
display.start_transfer();
display.loop();
ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA);
display.busy.busy = true;
loop_until_idle(display);
ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned";
EXPECT_NE(std::find(bus.commands.begin(), bus.commands.end(), 0x02), bus.commands.end())
<< "the panel was not powered off (POF) when the update was abandoned";
}
} // namespace esphome::epaper_spi::testing
@@ -0,0 +1,193 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/colorconv.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h"
namespace esphome::epaper_spi::testing {
/// Exposes the buffer and the dirty bounds so the fast path can be compared with drawing pixel by pixel.
template<typename Driver> class TestableDisplay : public Driver {
public:
TestableDisplay(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {
EXPECT_TRUE(this->init_buffer_(this->buffer_length_));
this->reset_bounds();
}
void reset_bounds() {
this->x_low_ = this->width_;
this->y_low_ = this->height_;
this->x_high_ = 0;
this->y_high_ = 0;
}
std::vector<uint8_t> buffer() const {
std::vector<uint8_t> out;
for (size_t i = 0; i != this->buffer_length_; i++)
out.push_back(this->buffer_[i]);
return out;
}
/// Reallocates the buffer in blocks of at most max_block bytes, as SplitBuffer does when memory is fragmented.
void split_buffer(size_t max_block) {
ASSERT_TRUE(this->buffer_.init(this->buffer_length_, max_block));
ASSERT_GT(this->buffer_.get_buffer_count(), 1u);
this->clear();
this->reset_bounds();
}
std::vector<uint16_t> bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; }
void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order,
display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
// The per-pixel path the fast one must match
display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call)
x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
}
};
/// RGB565 data with a spread of light and dark pixels, including values either side of the threshold.
static std::vector<uint8_t> rgb565(size_t pixels, uint32_t seed) {
std::vector<uint8_t> out;
uint32_t state = seed;
for (size_t i = 0; i != pixels; i++) {
state = state * 1103515245u + 12345u;
const uint16_t pixel = static_cast<uint16_t>(state >> 8);
out.push_back(pixel & 0xFF);
out.push_back(pixel >> 8);
}
return out;
}
/// Random bytes for a block of pixels, enough for 3 bytes a pixel.
static std::vector<uint8_t> random_bytes(size_t pixels, uint32_t seed) {
std::vector<uint8_t> out;
uint32_t state = seed;
for (size_t i = 0; i != pixels * 3; i++) {
state = state * 1103515245u + 12345u;
out.push_back(static_cast<uint8_t>(state >> 16));
}
return out;
}
/// For every rotation, mirroring and byte order, with source offsets and padding, and with blocks inside the panel,
/// running off each edge and larger than the panel, drawing an RGB565 block directly gives the same buffer and the same
/// dirty area as drawing it pixel by pixel. The panel width is not a multiple of 8.
TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) {
struct Block {
int x, y, w, h;
};
const Block blocks[] = {{6, 5, 12, 6}, {-3, -2, 10, 8}, {14, 8, 12, 9}, {-5, 4, 30, 3}, {-2, -3, 26, 20}};
const display::DisplayRotation rotations[] = {
display::DISPLAY_ROTATION_0_DEGREES, display::DISPLAY_ROTATION_90_DEGREES, display::DISPLAY_ROTATION_180_DEGREES,
display::DISPLAY_ROTATION_270_DEGREES};
// Swapping the axes comes from the 90 and 270 degree rotations
const uint8_t mirrorings[] = {NONE, MIRROR_X, MIRROR_Y, MIRROR_X | MIRROR_Y};
uint32_t seed = 1;
for (const auto rotation : rotations) {
for (const uint8_t mirroring : mirrorings) {
for (const bool big_endian : {false, true}) {
for (const auto &block : blocks) {
TestableDisplay<EPaperSSD1683> fast(21, 13);
TestableDisplay<EPaperSSD1683> slow(21, 13);
for (auto *display : {&fast, &slow}) {
display->set_transform(mirroring);
display->set_rotation(rotation);
}
// a 3 pixel left offset, 2 rows skipped and 4 pixels of padding
const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++);
fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB,
display::COLOR_BITNESS_565, big_endian, 3, 2, 4);
slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB,
display::COLOR_BITNESS_565, big_endian, 3, 2, 4);
const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring)
<< " big endian " << big_endian << " block " << block.x << ","
<< block.y << " " << block.w << "x" << block.h;
EXPECT_EQ(fast.buffer(), slow.buffer()) << where;
EXPECT_EQ(fast.bounds(), slow.bounds()) << where;
}
}
}
}
}
/// The colour order reaches the RGB565 block writer.
TEST(EPaperDrawPixels, ColourOrderIsHonoured) {
for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) {
TestableDisplay<EPaperSSD1683> fast(21, 13);
TestableDisplay<EPaperSSD1683> slow(21, 13);
const auto data = random_bytes(21 * 13, 99);
fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order);
}
}
/// With a clipping region set the block goes through draw_pixel_at(), which honours it.
TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) {
TestableDisplay<EPaperSSD1683> fast(24, 16);
TestableDisplay<EPaperSSD1683> slow(24, 16);
const auto data = rgb565(24 * 16, 7);
for (auto *display : {&fast, &slow})
display->start_clipping(4, 4, 12, 10);
fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer());
EXPECT_EQ(fast.bounds(), (std::vector<uint16_t>{4, 4, 12, 10}));
}
/// A driver whose buffer is not 1 bit per pixel keeps its own draw_pixel_at() for blocks too.
TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) {
TestableDisplay<EPaperSSD1677Gray4> fast(16, 4);
TestableDisplay<EPaperSSD1677Gray4> slow(16, 4);
const auto data = rgb565(16 * 4, 11);
fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer());
}
/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order.
TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) {
for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) {
const Rgb565ToMono to_mono(order);
size_t mismatches = 0;
for (uint32_t value = 0; value != 0x10000; value++) {
const uint8_t expected = color_to_mono(ColorUtil::to_color(value, order, display::COLOR_BITNESS_565));
if (to_mono(static_cast<uint16_t>(value)) != expected)
mismatches++;
}
EXPECT_EQ(mismatches, 0u) << "order " << int(order);
}
}
/// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result.
TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) {
{
TestableDisplay<EPaperSSD1683> fast(24, 16);
TestableDisplay<EPaperSSD1683> slow(24, 16);
fast.split_buffer(7);
slow.split_buffer(7);
const auto data = rgb565(24 * 16, 5);
fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0,
0);
slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0,
0);
EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer";
EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer";
}
for (const auto bitness : {display::COLOR_BITNESS_888, display::COLOR_BITNESS_332}) {
TestableDisplay<EPaperSSD1683> fast(24, 16);
TestableDisplay<EPaperSSD1683> slow(24, 16);
const auto data = random_bytes(24 * 16, 9);
fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0);
slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0);
EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness);
EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness);
}
}
} // namespace esphome::epaper_spi::testing
@@ -87,6 +87,40 @@ display:
number: GPIO4
inverted: true
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-g
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.13in-g-v1
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
inverted: true
- platform: epaper_spi
model: seeed-reterminal-e1002
- platform: epaper_spi
@@ -234,6 +268,27 @@ display:
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-2.66in-b
cs_pin:
allow_other_uses: true
number: GPIO5
dc_pin:
allow_other_uses: true
number: GPIO17
reset_pin:
allow_other_uses: true
number: GPIO16
busy_pin:
allow_other_uses: true
number: GPIO4
lambda: |-
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK);
it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0));
# Good Display GDEY0213F51 4-color e-paper (128x250, BWYR, JD79661)
- platform: epaper_spi
spi_id: spi_bus
+4
View File
@@ -17,5 +17,9 @@ select:
es8388_id: es8388_parent
dac_output:
name: "DAC Output"
id: es8388_test_dac_output_select
initial_option: BOTH
adc_input_mic:
name: "ADC Input MIC"
id: es8388_test_adc_input_mic_select
initial_option: LINE2
@@ -0,0 +1,16 @@
esphome:
on_boot:
then:
- audio_dac.mute_off:
- audio_dac.mute_on:
- audio_dac.set_volume:
volume: 50%
audio_dac:
- platform: es9038q2m_katana
i2c_id: i2c_bus
filter_shape: hybrid
bits_per_sample: 24bit
sample_rate: 96000
deemphasis: 48khz
dop: true
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
es9038q2m_katana: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
es9038q2m_katana: !include common.yaml
+1 -1
View File
@@ -18,7 +18,7 @@ canbus:
tx_pin: ${tx_pin}
can_id: 4
bit_rate: 50kbps
mode: NORMAL
mode: NO_ACK
on_frame:
- can_id: 500
then:
@@ -0,0 +1,11 @@
esp32:
framework:
advanced:
minimum_chip_revision: "2.0"
canbus:
- platform: esp32_can
rx_pin: GPIO14
tx_pin: GPIO12
can_id: 4
bit_rate: 20kbps
@@ -8,7 +8,6 @@
#include <string>
#include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h"
#include "esphome/core/preferences.h"
#ifdef USE_HOST
#include "esphome/components/host/preferences.h"
#endif
@@ -33,11 +32,9 @@ class ExponentialMovingAverageTest : public ::testing::Test {
#ifdef USE_HOST
host::setup_preferences();
#endif
global_preferences->reset();
}
void TearDown() override {
global_preferences->reset();
if (this->saved_prefdir_.has_value()) {
setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1);
} else {
@@ -0,0 +1,12 @@
from esphome.core import CORE
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
for platform in ("sensor", "binary_sensor"):
CORE.testing_ensure_platform_registered(platform)
manifest.to_code = to_code_testing
manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"]
@@ -0,0 +1,62 @@
binary_sensor:
- platform: template
name: "Fan Running"
id: fan_running
sensor:
- platform: template
name: "Fan Speed Percent"
id: fan_speed_percent
unit_of_measurement: "%"
# Test with lambdas
- platform: filter_lifetime
name: "Filter Lifetime Lambda"
id: filter_lifetime_lambda
max_lifetime: 365d
is_on: !lambda 'return true;'
current_speed: !lambda 'return 50.0;'
update_interval: 60s
runtime_hours:
name: "Filter Runtime Hours Lambda"
remaining_days:
name: "Filter Remaining Days Lambda"
# Test with sensor references
- platform: filter_lifetime
name: "Filter Lifetime Sensor"
id: filter_lifetime_sensor
max_lifetime: 365d
is_on_sensor: fan_running
current_speed_sensor: fan_speed_percent
update_interval: 60s
runtime_hours:
name: "Filter Runtime Hours Sensor"
remaining_days:
name: "Filter Remaining Days Sensor"
# Test with defaults (always on at 100%)
- platform: filter_lifetime
name: "Filter Lifetime Default"
id: filter_lifetime_default
max_lifetime: 180d
update_interval: 60s
button:
- platform: template
name: "Reset Filter Lambda"
on_press:
- filter_lifetime.reset_filter:
id: filter_lifetime_lambda
- platform: template
name: "Reset Filter Sensor"
on_press:
- filter_lifetime.reset_filter:
id: filter_lifetime_sensor
- platform: template
name: "Reset Filter Default"
on_press:
- filter_lifetime.reset_filter:
id: filter_lifetime_default
@@ -0,0 +1,8 @@
sensor:
- platform: filter_lifetime
name: "Filter Lifetime Lambda Only"
max_lifetime: 180d
is_on: !lambda 'return true;'
current_speed: !lambda 'return 75.0f;'
remaining_days:
name: "Filter Remaining Days Lambda Only"
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include lambda_only.yaml
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include common.yaml
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include common.yaml
@@ -0,0 +1,2 @@
packages:
filter_lifetime: !include common.yaml
@@ -0,0 +1,224 @@
#ifdef USE_HOST
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/filter_lifetime/filter_lifetime.h"
#include "esphome/components/host/preferences.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::filter_lifetime::testing {
class EntityIdentity : public EntityBase {
public:
static void configure(EntityBase &entity, const char *name, uint32_t hash) {
auto configure = &EntityIdentity::configure_entity_;
(entity.*configure)(name, hash, 0);
}
};
class TestableFilterLifetime : public FilterLifetime {
public:
using FilterLifetime::last_update_;
using FilterLifetime::runtime_seconds_;
};
static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static constexpr uint32_t MAX_MINUTES = 600; // 10 hours
static constexpr uint32_t FILTER_HASH = 0x104001;
class FilterLifetimeTest : public ::testing::Test {
protected:
TestableFilterLifetime filter_;
sensor::Sensor runtime_hours_;
sensor::Sensor remaining_days_;
float last_state_{NAN};
unsigned publications_{0};
void SetUp() override {
new (&App) Application;
App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21);
host::setup_preferences();
host::host_preferences->reset();
g_is_on = true;
g_speed = 100.0f;
this->attach(this->filter_);
this->filter_.add_on_state_callback([this](float value) {
this->last_state_ = value;
++this->publications_;
});
}
void TearDown() override {
host::host_preferences->reset();
App.~Application();
}
void attach(FilterLifetime &filter) {
EntityIdentity::configure(filter, "Filter", FILTER_HASH);
filter.set_max_lifetime(MAX_MINUTES);
filter.set_is_on_lambda([]() { return g_is_on; });
filter.set_current_speed_lambda([]() { return g_speed; });
filter.set_runtime_hours_sensor(&this->runtime_hours_);
filter.set_remaining_days_sensor(&this->remaining_days_);
}
// Run one update() as if elapsed_ms passed since the previous one
void elapse(uint32_t elapsed_ms) {
this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms;
this->filter_.update();
}
bool stored(uint32_t &value) { return this->filter_.make_entity_preference<uint32_t>().load(&value); }
static float expected_percent(uint32_t runtime_seconds) {
return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES;
}
};
TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) {
this->filter_.setup();
EXPECT_EQ(this->publications_, 1u);
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f));
}
TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) {
this->filter_.setup();
g_speed = 50.0f;
this->elapse(60000);
EXPECT_EQ(this->filter_.runtime_seconds_, 30u);
EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30));
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f);
EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f));
}
TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) {
this->filter_.setup();
g_speed = 25.0f;
for (int i = 0; i < 3; i++)
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) {
this->filter_.setup();
g_is_on = false;
this->elapse(60000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
EXPECT_EQ(this->publications_, 2u);
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
}
TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) {
this->filter_.setup();
g_speed = 250.0f;
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
g_speed = NAN;
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
g_speed = -5.0f;
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) {
sensor::Sensor speed;
this->filter_.set_current_speed_lambda(nullptr);
this->filter_.set_current_speed_sensor(&speed);
this->filter_.setup();
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
speed.state = 50.0f;
this->elapse(2000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) {
binary_sensor::BinarySensor running;
this->filter_.set_is_on_lambda(nullptr);
this->filter_.set_is_on_sensor(&running);
this->filter_.setup();
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
running.publish_state(true);
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
running.publish_state(false);
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) {
this->filter_.set_is_on_lambda(nullptr);
this->filter_.set_current_speed_lambda(nullptr);
this->filter_.setup();
this->elapse(1000);
EXPECT_EQ(this->filter_.runtime_seconds_, 1u);
}
TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) {
this->filter_.setup();
uint32_t value = 0;
this->elapse(3599000);
EXPECT_FALSE(this->stored(value));
this->elapse(1000);
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 3600u);
this->elapse(1000);
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 3600u);
}
TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) {
this->filter_.setup();
this->elapse(1000);
this->filter_.on_shutdown();
uint32_t value = 0;
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 1u);
}
TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) {
this->filter_.setup();
this->elapse(1234000);
this->filter_.on_shutdown();
TestableFilterLifetime restored;
this->attach(restored);
restored.setup();
EXPECT_EQ(restored.runtime_seconds_, 1234u);
EXPECT_FLOAT_EQ(restored.state, expected_percent(1234));
}
TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) {
this->filter_.setup();
this->elapse(5000);
this->filter_.reset_filter();
EXPECT_EQ(this->filter_.runtime_seconds_, 0u);
EXPECT_FLOAT_EQ(this->last_state_, 100.0f);
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f);
uint32_t value = 1;
ASSERT_TRUE(this->stored(value));
EXPECT_EQ(value, 0u);
}
TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) {
this->filter_.set_max_lifetime(1);
this->filter_.setup();
this->elapse(120000);
EXPECT_FLOAT_EQ(this->last_state_, 0.0f);
EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f);
EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f);
}
} // namespace esphome::filter_lifetime::testing
#endif // USE_HOST
+15
View File
@@ -0,0 +1,15 @@
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
from esphome.components.remote_base import request_protocol
# The harness walks the component manifest, not the climate platform, so declare its auto-load here
manifest.auto_load = ["climate_ir", "climate"]
async def to_code_testing(config: ConfigType) -> None:
# The AEHA source is compiled only behind its define; climate.py requests it in the real build.
request_protocol("aeha")
manifest.to_code = to_code_testing
+5
View File
@@ -0,0 +1,5 @@
climate:
- platform: friedrich
name: Friedrich Climate
transmitter_id: xmitr
receiver_id: rcvr
@@ -0,0 +1,215 @@
#include <gtest/gtest.h>
#include <functional>
#include "esphome/components/friedrich/friedrich.h"
#include "esphome/components/remote_base/aeha_protocol.h"
#include "esphome/core/helpers.h"
namespace esphome::friedrich::testing {
using remote_base::AEHAData;
using remote_base::AEHAProtocol;
// Records the last transmission instead of sending it
class CapturingTransmitter : public remote_base::RemoteTransmitterBase {
public:
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
int sends{0};
remote_base::RawTimings last;
protected:
void send_internal(uint32_t, uint32_t) override {
this->sends++;
this->last = this->temp_.get_data();
}
};
class TestFriedrich : public FriedrichClimate {
public:
using FriedrichClimate::on_receive;
using FriedrichClimate::setup;
using FriedrichClimate::traits;
using FriedrichClimate::transmit_state;
};
constexpr uint16_t ADDRESS = 0x28C6;
constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH;
// Byte 6 holds the temperature code (bit 7 is the power flag), byte 7 the mode, byte 8 the fan
constexpr uint8_t TEMP_72_CODE = 0x06;
struct Fixture {
TestFriedrich sut;
CapturingTransmitter transmitter;
Fixture() { this->sut.set_transmitter(&this->transmitter); }
// The last transmission decoded, or an empty frame if it could not be decoded
AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); }
bool receive(const AEHAData &frame) {
remote_base::RemoteTransmitData tx;
AEHAProtocol().encode(&tx, frame);
return this->sut.on_receive(rx_of(tx.get_data()));
}
static remote_base::RemoteReceiveData rx_of(const remote_base::RawTimings &timings) {
return remote_base::RemoteReceiveData(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
}
};
// Independent implementation of the 14 byte frame checksum
static AEHAData state_frame(uint8_t temp_code, uint8_t mode, uint8_t fan = 0x00) {
AEHAData f{ADDRESS, {0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, (uint8_t) (temp_code | 0x80), mode, fan, 0, 0, 0, 0x04, 0}};
uint8_t sum = 0;
for (int i = 6; i < 13; i++)
sum += reverse_bits(f.data[i]);
f.data[13] = reverse_bits(static_cast<uint8_t>(208 - sum));
return f;
}
static AEHAData off_frame() { return AEHAData{ADDRESS, {0x00, 0x08, 0x08, 0x40, 0xBF}}; }
TEST(FriedrichTests, TraitsReportFahrenheit) {
Fixture f;
auto traits = f.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 60.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 2.0f);
}
TEST(FriedrichTests, OffIsAlwaysSentAsFiveByteFrame) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_OFF;
f.sut.transmit_state();
ASSERT_EQ(f.transmitter.sends, 1);
EXPECT_EQ(f.sent(), off_frame());
}
TEST(FriedrichTests, StateFrameEncodesFahrenheitTarget) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 72;
f.sut.fan_mode = climate::CLIMATE_FAN_AUTO;
f.sut.swing_mode = climate::CLIMATE_SWING_OFF;
f.sut.transmit_state();
EXPECT_EQ(f.sent(), state_frame(TEMP_72_CODE, 0x80));
}
TEST(FriedrichTests, OddTargetStepsUpToEven) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 71;
f.sut.transmit_state();
auto data = f.sent();
ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH);
EXPECT_EQ(data.data[6], TEMP_72_CODE | 0x80);
EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); // stored value matches what was sent
}
// Codes captured from the remote
TEST(FriedrichTests, EveryTemperatureEncodesToRemoteCode) {
static constexpr uint8_t CODES[] = {0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E,
0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07};
for (size_t i = 0; i < sizeof(CODES); i++) {
const auto fahrenheit = static_cast<uint8_t>(60 + i * 2);
SCOPED_TRACE(fahrenheit);
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_HEAT;
f.sut.target_temperature = fahrenheit;
f.sut.transmit_state();
EXPECT_EQ(f.sent(), state_frame(CODES[i], 0x20));
Fixture g;
EXPECT_TRUE(g.receive(state_frame(CODES[i], 0x20)));
EXPECT_FLOAT_EQ(g.sut.target_temperature, fahrenheit);
}
}
TEST(FriedrichTests, NonHeatModesAreRaisedToSixtyFour) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 60;
f.sut.transmit_state();
EXPECT_FLOAT_EQ(f.sut.target_temperature, 64.0f);
auto data = f.sent();
ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH);
EXPECT_EQ(data.data[6], 0x04 | 0x80); // 64 F
f.sut.mode = climate::CLIMATE_MODE_HEAT;
f.sut.target_temperature = 60;
f.sut.transmit_state();
EXPECT_FLOAT_EQ(f.sut.target_temperature, 60.0f);
}
TEST(FriedrichTests, ReceivesStateFrame) {
Fixture f;
EXPECT_TRUE(f.receive(state_frame(TEMP_72_CODE, 0x20, 0x80)));
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT);
EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f);
EXPECT_EQ(f.sut.fan_mode, climate::CLIMATE_FAN_HIGH);
}
TEST(FriedrichTests, ReceivesOffFrame) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
EXPECT_TRUE(f.receive(off_frame()));
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF);
}
TEST(FriedrichTests, IgnoresUnknownModeWithoutChangingState) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
f.sut.target_temperature = 80;
EXPECT_FALSE(f.receive(state_frame(TEMP_72_CODE, 0xD0))); // MIN_HEAT is not exposed
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL);
EXPECT_FLOAT_EQ(f.sut.target_temperature, 80.0f);
}
TEST(FriedrichTests, IgnoresCorruptedFrames) {
struct Case {
const char *name;
AEHAData frame;
std::function<void(AEHAData &)> corrupt;
};
const Case cases[] = {
{"other address", off_frame(), [](AEHAData &f) { f.address = 0x1234; }},
{"bad header byte", off_frame(), [](AEHAData &f) { f.data[1] = 0x09; }},
{"bad off checksum", off_frame(), [](AEHAData &f) { f.data[4] ^= 0xFF; }},
{"bad state checksum", state_frame(TEMP_72_CODE, 0x20), [](AEHAData &f) { f.data[13] ^= 0xFF; }},
};
for (const auto &c : cases) {
SCOPED_TRACE(c.name);
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_COOL;
AEHAData frame = c.frame;
c.corrupt(frame);
EXPECT_FALSE(f.receive(frame));
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL);
}
}
TEST(FriedrichTests, OwnTransmissionIsAccepted) {
Fixture f;
f.sut.mode = climate::CLIMATE_MODE_FAN_ONLY;
f.sut.target_temperature = 76;
f.sut.fan_mode = climate::CLIMATE_FAN_LOW;
f.sut.swing_mode = climate::CLIMATE_SWING_VERTICAL;
f.sut.transmit_state();
Fixture g;
EXPECT_TRUE(g.sut.on_receive(Fixture::rx_of(f.transmitter.last)));
EXPECT_EQ(g.sut.mode, climate::CLIMATE_MODE_FAN_ONLY);
EXPECT_FLOAT_EQ(g.sut.target_temperature, 76.0f);
EXPECT_EQ(g.sut.fan_mode, climate::CLIMATE_FAN_LOW);
EXPECT_EQ(g.sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(FriedrichTests, SetupDefaultsTargetToSeventyTwoFahrenheit) {
Fixture f;
f.sut.setup();
EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f);
EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF);
}
} // namespace esphome::friedrich::testing
@@ -0,0 +1,4 @@
packages:
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
friedrich: !include common.yaml
@@ -0,0 +1,4 @@
packages:
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
friedrich: !include common.yaml
+4
View File
@@ -0,0 +1,4 @@
fusb302b:
id: pd
interrupt_pin: ${interrupt_pin}
request_voltage: 20
+481
View File
@@ -0,0 +1,481 @@
#include <gtest/gtest.h>
#include <vector>
#include "esphome/components/fusb302b/pd.h"
namespace esphome::fusb302b::testing {
static constexpr uint32_t fixed_pdo(uint16_t mv, uint16_t ma) { return ((mv / 50u) << 10) | (ma / 10u); }
// An augmented (PPS) object; only its type matters to the sink
static constexpr uint32_t AUGMENTED_PDO = static_cast<uint32_t>(PD_PDO_TYPE_AUGMENTED) << 30;
// Unknown to the sink, so it must be answered with Reject
static constexpr uint8_t PD_CNTRL_DR_SWAP = 0x09;
class FakePhy : public PowerDelivery {
public:
using PowerDelivery::ams_start_;
using PowerDelivery::check_ams_;
using PowerDelivery::check_transition_timeout_;
using PowerDelivery::check_request_retry_;
using PowerDelivery::contract_;
using PowerDelivery::handle_message_;
using PowerDelivery::handle_transmit_failed_;
using PowerDelivery::retry_request_pending_;
using PowerDelivery::set_state_and_contract_;
using PowerDelivery::wait_received_ms_;
std::vector<PdMsg> sent;
bool send_ok{true};
int send_attempts{0};
int state_changes{0};
int soft_resets{0};
int source_caps{0};
int transition_timeouts{0};
void receive(uint8_t type, uint8_t id, std::vector<uint32_t> objects = {}) {
PdMsg msg;
msg.type = type;
msg.id = id;
msg.num_of_obj = objects.size();
for (size_t i = 0; i < objects.size(); i++)
msg.data_objects[i] = objects[i];
this->handle_message_(msg);
}
void receive_source_caps(uint8_t id) {
this->receive(PD_DATA_SOURCE_CAP, id,
{fixed_pdo(5000, 3000), fixed_pdo(9000, 3000), fixed_pdo(15000, 3000), fixed_pdo(20000, 2250)});
}
PackedContract contract() const { return PackedContract{this->contract_}; }
// What the PHY does when a source is attached
void attach() { this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); }
// Moves the timestamps back instead of sleeping, since millis() is real time on the host
void advance_ms(uint32_t ms) {
this->ams_start_ -= ms;
this->wait_received_ms_ -= ms;
}
protected:
bool send_message(const PdMsg &msg) override {
this->send_attempts++;
if (!this->send_ok)
return false;
this->sent.push_back(msg);
return true;
}
void on_state_changed() override { this->state_changes++; }
void on_soft_reset_received() override { this->soft_resets++; }
void on_source_caps_received() override {
// The hook runs before the request is sent
EXPECT_EQ(this->sent.size(), static_cast<size_t>(this->source_caps));
this->source_caps++;
}
void on_transition_timeout() override { this->transition_timeouts++; }
};
TEST(FUSB302BPd, HeaderRoundTrip) {
PdMsg msg;
msg.type = PD_DATA_REQUEST;
msg.spec_rev = PD_SPEC_REV_2;
msg.id = 5;
msg.num_of_obj = 1;
// Type 2, spec revision 1 at bit 6, MessageID 5 at bit 9, one object at bit 12
EXPECT_EQ(msg.get_coded_header(), 0x1A42);
PdMsg decoded;
decoded.set_header(0x1A42 | (1u << 15));
EXPECT_EQ(decoded.type, PD_DATA_REQUEST);
EXPECT_EQ(decoded.spec_rev, PD_SPEC_REV_2);
EXPECT_EQ(decoded.id, 5);
EXPECT_EQ(decoded.num_of_obj, 1);
EXPECT_TRUE(decoded.extended);
}
TEST(FUSB302BPd, RequestsHighestVoltageNotAboveLimit) {
FakePhy pd;
pd.set_request_voltage(18);
pd.receive_source_caps(0);
EXPECT_EQ(pd.source_caps, 1);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_DATA_REQUEST);
EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); // 15 V is object 3
EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 300u);
}
TEST(FUSB302BPd, LowestRequestPicksFirstObject) {
FakePhy pd;
pd.set_request_voltage(5);
pd.receive_source_caps(0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 1u);
}
TEST(FUSB302BPd, NonFixedObjectKeepsItsPosition) {
FakePhy pd;
pd.set_request_voltage(9);
pd.receive(PD_DATA_SOURCE_CAP, 0, {fixed_pdo(5000, 3000), AUGMENTED_PDO, fixed_pdo(9000, 2000)});
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u);
EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 200u);
}
TEST(FUSB302BPd, NoFixedObjectFallsBackToObjectOne) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive(PD_DATA_SOURCE_CAP, 0, {AUGMENTED_PDO, AUGMENTED_PDO});
ASSERT_EQ(pd.sent.size(), 1u);
const uint32_t rdo = pd.sent[0].data_objects[0];
EXPECT_EQ(rdo >> 28, 1u);
EXPECT_EQ(rdo & 0x3FF, 30u); // 300 mA maximum
EXPECT_EQ((rdo >> 10) & 0x3FF, 10u); // 100 mA operating
}
TEST(FUSB302BPd, AcceptThenPsRdyReachesExplicitContract) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f);
EXPECT_FLOAT_EQ(pd.contract().current(), 2.25f);
}
TEST(FUSB302BPd, AcceptPublishesStateAndContractOnce) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.state_changes = 0;
pd.receive(PD_CNTRL_ACCEPT, 1);
EXPECT_EQ(pd.state_changes, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f);
}
TEST(FUSB302BPd, AcceptForSameContractSkipsTransition) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.receive(PD_CNTRL_PS_RDY, 2);
pd.receive_source_caps(3);
pd.receive(PD_CNTRL_ACCEPT, 4);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
pd.receive(PD_CNTRL_PS_RDY, 5);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
EXPECT_FALSE(pd.check_ams_());
}
TEST(FUSB302BPd, PsRdyWithoutAcceptIsIgnored) {
FakePhy pd;
pd.receive(PD_CNTRL_PS_RDY, 0);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
pd.receive_source_caps(1);
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
}
TEST(FUSB302BPd, PsRdyAfterRejectIsIgnored) {
FakePhy pd;
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_REJECT, 1);
EXPECT_FALSE(pd.check_ams_());
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
}
TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.advance_ms(2001);
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
}
TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.check_transition_timeout_();
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION);
EXPECT_EQ(pd.transition_timeouts, 0);
pd.advance_ms(2001);
pd.state_changes = 0;
pd.check_transition_timeout_();
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f);
EXPECT_EQ(pd.state_changes, 1);
EXPECT_EQ(pd.transition_timeouts, 1);
// Reported once, and a late PS_RDY does not bring back the unconfirmed contract
pd.check_transition_timeout_();
pd.receive(PD_CNTRL_PS_RDY, 2);
EXPECT_EQ(pd.transition_timeouts, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
}
TEST(FUSB302BPd, RequestVoltageIsClamped) {
FakePhy pd;
pd.set_request_voltage(255);
EXPECT_EQ(pd.get_request_voltage(), MAX_REQUEST_VOLTAGE);
pd.set_request_voltage(0);
EXPECT_EQ(pd.get_request_voltage(), MIN_REQUEST_VOLTAGE);
}
TEST(FUSB302BPd, WaitRepeatsRequestAfterSinkRequestTime) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_WAIT, 1);
EXPECT_FALSE(pd.check_ams_());
pd.check_request_retry_();
EXPECT_EQ(pd.sent.size(), 1u);
pd.advance_ms(101);
pd.check_request_retry_();
ASSERT_EQ(pd.sent.size(), 2u);
EXPECT_EQ(pd.sent[1].type, PD_DATA_REQUEST);
EXPECT_EQ(pd.sent[1].data_objects[0], pd.sent[0].data_objects[0]);
EXPECT_TRUE(pd.check_ams_());
}
TEST(FUSB302BPd, WaitRetriesStopAtLimit) {
FakePhy pd;
pd.attach();
pd.receive_source_caps(0);
for (uint8_t i = 1; i <= 10; i++) {
pd.receive(PD_CNTRL_WAIT, i % 8);
pd.advance_ms(101);
pd.check_request_retry_();
}
EXPECT_EQ(pd.sent.size(), 11u);
pd.receive(PD_CNTRL_WAIT, 3);
EXPECT_FALSE(pd.retry_request_pending_);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
pd.advance_ms(101);
pd.check_request_retry_();
EXPECT_EQ(pd.sent.size(), 11u);
}
TEST(FUSB302BPd, FailedRequestEndsNegotiation) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.send_ok = false;
pd.receive_source_caps(0);
EXPECT_EQ(pd.send_attempts, 1);
EXPECT_FALSE(pd.check_ams_());
EXPECT_FALSE(pd.retry_request_pending_);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
pd.receive(PD_CNTRL_ACCEPT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
}
TEST(FUSB302BPd, FailedRetryIsTriedAgain) {
FakePhy pd;
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_WAIT, 1);
pd.send_ok = false;
pd.advance_ms(101);
pd.check_request_retry_();
EXPECT_EQ(pd.send_attempts, 2);
EXPECT_TRUE(pd.retry_request_pending_);
EXPECT_FALSE(pd.check_ams_());
pd.send_ok = true;
pd.check_request_retry_();
EXPECT_EQ(pd.send_attempts, 2); // waits another tSinkRequest first
pd.advance_ms(101);
pd.check_request_retry_();
EXPECT_EQ(pd.sent.size(), 2u);
EXPECT_FALSE(pd.retry_request_pending_);
}
TEST(FUSB302BPd, FailedRetriesStopAtLimit) {
FakePhy pd;
pd.attach();
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_WAIT, 1);
pd.send_ok = false;
for (int i = 0; i < 20 && pd.retry_request_pending_; i++) {
pd.advance_ms(101);
pd.check_request_retry_();
}
EXPECT_FALSE(pd.retry_request_pending_);
// The first request, then one attempt for each of the 10 allowed repeats
EXPECT_EQ(pd.send_attempts, 11);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
}
TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_REJECT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
}
TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) {
FakePhy pd;
pd.attach();
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.receive(PD_CNTRL_PS_RDY, 2);
pd.set_request_voltage(9);
pd.receive_source_caps(3);
pd.receive(PD_CNTRL_REJECT, 4);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f);
}
TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) {
FakePhy pd;
pd.attach();
pd.receive(PD_CNTRL_REJECT, 0);
pd.receive(PD_CNTRL_WAIT, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
EXPECT_FALSE(pd.retry_request_pending_);
}
TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) {
FakePhy pd;
pd.receive(PD_DATA_REQUEST, 0, {0});
EXPECT_TRUE(pd.sent.empty());
EXPECT_EQ(pd.source_caps, 0);
}
TEST(FUSB302BPd, AcceptOutsideSequenceIsIgnored) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_REJECT, 1);
pd.receive(PD_CNTRL_ACCEPT, 2);
pd.receive(PD_CNTRL_PS_RDY, 3);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED);
EXPECT_FALSE(pd.contract().is_set());
}
TEST(FUSB302BPd, GoodCrcMustMatchMessageId) {
FakePhy pd;
pd.receive(PD_CNTRL_DR_SWAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].id, 0);
pd.receive(PD_CNTRL_GOODCRC, 1);
pd.receive(PD_CNTRL_DR_SWAP, 1);
ASSERT_EQ(pd.sent.size(), 2u);
EXPECT_EQ(pd.sent[1].id, 0);
pd.receive(PD_CNTRL_GOODCRC, 0);
pd.receive(PD_CNTRL_GOODCRC, 0); // duplicate
pd.receive(PD_CNTRL_DR_SWAP, 2);
ASSERT_EQ(pd.sent.size(), 3u);
EXPECT_EQ(pd.sent[2].id, 1);
}
TEST(FUSB302BPd, FailedTransmitAdvancesMessageId) {
FakePhy pd;
pd.receive_source_caps(0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].id, 0);
pd.handle_transmit_failed_();
// A late GoodCRC for the failed message must not advance the counter a second time
pd.receive(PD_CNTRL_GOODCRC, 0);
pd.receive(PD_CNTRL_DR_SWAP, 1);
ASSERT_EQ(pd.sent.size(), 2u);
EXPECT_EQ(pd.sent[1].id, 1);
}
TEST(FUSB302BPd, RetransmissionIsIgnored) {
FakePhy pd;
pd.receive_source_caps(0);
pd.receive_source_caps(0);
EXPECT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.source_caps, 1);
}
TEST(FUSB302BPd, SoftResetIsAccepted) {
FakePhy pd;
pd.receive(PD_CNTRL_SOFT_RESET, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_CNTRL_ACCEPT);
EXPECT_EQ(pd.soft_resets, 1);
}
TEST(FUSB302BPd, SoftResetHookRunsWhenAcceptFails) {
FakePhy pd;
pd.send_ok = false;
pd.receive(PD_CNTRL_SOFT_RESET, 0);
EXPECT_EQ(pd.send_attempts, 1);
EXPECT_EQ(pd.soft_resets, 1);
}
TEST(FUSB302BPd, SoftResetDuringTransitionFallsBackToDefault) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive_source_caps(0);
pd.receive(PD_CNTRL_ACCEPT, 1);
pd.state_changes = 0;
pd.receive(PD_CNTRL_SOFT_RESET, 0);
EXPECT_EQ(pd.state_changes, 1);
EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT);
EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f);
EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f);
}
TEST(FUSB302BPd, UnknownControlMessageIsRejected) {
FakePhy pd;
pd.receive(PD_CNTRL_DR_SWAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_CNTRL_REJECT);
}
TEST(FUSB302BPd, SinkCapAt5VHasOneObject) {
FakePhy pd;
pd.receive(PD_CNTRL_GET_SINK_CAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
EXPECT_EQ(pd.sent[0].type, PD_DATA_SINK_CAP);
ASSERT_EQ(pd.sent[0].num_of_obj, 1);
const uint32_t pdo = pd.sent[0].data_objects[0];
EXPECT_EQ((pdo >> 10) & 0x3FF, 100u);
EXPECT_EQ(pdo & 0x3FF, 300u);
EXPECT_FALSE(pdo & (1u << 28));
EXPECT_TRUE(pdo & (1u << 26));
}
TEST(FUSB302BPd, SinkCapListsRequestedVoltage) {
FakePhy pd;
pd.set_request_voltage(20);
pd.receive(PD_CNTRL_GET_SINK_CAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
ASSERT_EQ(pd.sent[0].num_of_obj, 2);
EXPECT_TRUE(pd.sent[0].data_objects[0] & (1u << 28));
const uint32_t pdo = pd.sent[0].data_objects[1];
EXPECT_EQ(pdo >> 30, 0u);
EXPECT_EQ((pdo >> 10) & 0x3FF, 400u);
EXPECT_EQ(pdo & 0x3FF, 300u);
EXPECT_EQ(pdo >> 25, 0u); // capability flags are only valid in the first object
}
TEST(FUSB302BPd, SinkCapForOutOfRangeVoltageStaysValid) {
FakePhy pd;
pd.set_request_voltage(255);
pd.receive(PD_CNTRL_GET_SINK_CAP, 0);
ASSERT_EQ(pd.sent.size(), 1u);
ASSERT_EQ(pd.sent[0].num_of_obj, 2);
const uint32_t pdo = pd.sent[0].data_objects[1];
EXPECT_EQ((pdo >> 10) & 0x3FF, 400u);
EXPECT_EQ(pdo >> 20, 0u); // nothing spills into the peak current or flag bits
}
} // namespace esphome::fusb302b::testing
@@ -0,0 +1,6 @@
substitutions:
interrupt_pin: GPIO15
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
fusb302b: !include common.yaml
+8
View File
@@ -121,3 +121,11 @@ switch:
name: Haier health mode
quiet_mode:
name: Haier quiet mode
select:
- platform: haier
haier_id: haier_ac
horizontal_airflow:
name: Haier airflow horizontal
vertical_airflow:
name: Haier airflow vertical
+45
View File
@@ -0,0 +1,45 @@
climate:
- platform: haier_tundra
name: Haier AC A
id: my_haier_ac_a
transmitter_id: xmitr
- platform: haier_tundra
name: Haier AC B
id: my_haier_ac_b
transmitter_id: xmitr
unit: b
switch:
- platform: haier_tundra
haier_tundra_id: my_haier_ac_a
quiet_mode:
name: "Quiet Mode (Unit A)"
turbo_mode:
name: "Turbo Mode (Unit A)"
health_mode:
name: "Health Mode (Unit A)"
- platform: haier_tundra
haier_tundra_id: my_haier_ac_b
quiet_mode:
name: "Quiet Mode (Unit B)"
turbo_mode:
name: "Turbo Mode (Unit B)"
health_mode:
name: "Health Mode (Unit B)"
button:
- platform: haier_tundra
haier_tundra_id: my_haier_ac_a
light:
name: "Toggle Light (Unit A)"
self_clean:
name: "Start Self Clean (Unit A)"
- platform: haier_tundra
haier_tundra_id: my_haier_ac_b
light:
name: "Toggle Light (Unit B)"
self_clean:
name: "Start Self Clean (Unit B)"
@@ -0,0 +1,3 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
haier_tundra: !include common.yaml
@@ -0,0 +1,3 @@
packages:
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
haier_tundra: !include common.yaml

Some files were not shown because too many files have changed in this diff Show More