mirror of
https://github.com/esphome/esphome.git
synced 2026-10-18 04:02:54 +00:00
Merge remote-tracking branch 'origin/esp8266-entity-names-flash' into esp8266-entity-names-components
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,6 @@ button:
|
||||
|
||||
emontx:
|
||||
id: test_emontx
|
||||
lock_on_reboot: true
|
||||
on_json:
|
||||
- then:
|
||||
- logger.log: "Got JSON"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
fusb302b:
|
||||
id: pd
|
||||
interrupt_pin: ${interrupt_pin}
|
||||
request_voltage: 20
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user