Merge remote-tracking branch 'origin/shelly-dimmer-update-test' into shelly_dimmer-tables-progmem

This commit is contained in:
J. Nick Koston
2026-10-13 11:43:01 -10:00
21 changed files with 807 additions and 29 deletions
@@ -1,4 +1,6 @@
#pragma once
#include <array>
#include <cstdint>
#include "esphome/core/color.h"
namespace esphome::display {
@@ -154,4 +156,54 @@ class ColorUtil {
return color;
}
};
/** Rec.601 luma weights (0.299/0.587/0.114), scaled by 256 */
static constexpr uint16_t REC601_LUMA_R = 77;
static constexpr uint16_t REC601_LUMA_G = 150;
static constexpr uint16_t REC601_LUMA_B = 29;
/** Rec.601 luma of an RGB color, for optimum perceptual brightness */
constexpr uint8_t rec601_luma(Color color) {
return (uint8_t) ((REC601_LUMA_R * color.r + REC601_LUMA_G * color.g + REC601_LUMA_B * color.b + 128u) >> 8);
}
/** Map an RGB color to a single monochrome bit: 1 = white, 0 = black */
constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; }
/** color_to_mono() of an RGB565 value as ColorUtil::to_color() reads it, from table lookups instead of a division
* per channel.
*/
class Rgb565ToMono {
public:
explicit Rgb565ToMono(ColorOrder order) {
// Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order
uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B;
if (order == COLOR_ORDER_BGR) {
high = REC601_LUMA_B;
low = REC601_LUMA_R;
} else if (order == COLOR_ORDER_GRB) {
high = REC601_LUMA_G;
middle = REC601_LUMA_R;
}
// Each field value scaled to 0..255, then weighted
for (unsigned i = 0; i != 32; i++) {
this->high_[i] = high * esp_scale(i, 31);
this->low_[i] = low * esp_scale(i, 31);
}
for (unsigned i = 0; i != 64; i++)
this->middle_[i] = middle * esp_scale(i, 63);
}
uint8_t operator()(uint16_t value) const {
const uint32_t weighted = this->high_[value >> 11] + this->middle_[(value >> 5) & 0x3F] + this->low_[value & 0x1F];
return ((weighted + 128u) >> 8) >= 128 ? 1 : 0;
}
protected:
// Weighted contribution of each value of each field
std::array<uint16_t, 32> high_{};
std::array<uint16_t, 64> middle_{};
std::array<uint16_t, 32> low_{};
};
} // namespace esphome::display
+7 -12
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include <algorithm>
#include "esphome/components/display/display_color_utils.h"
#include "esphome/core/color.h"
/* Utility for converting internal \a Color RGB representation to supported IC hardware color keys
@@ -13,21 +14,15 @@
namespace esphome::epaper_spi {
// The Rec.601 luma helpers and the RGB565 mono lookup live in the shared display utilities, so other
// drivers (e.g. it8951 in its 1 bit per pixel mode) can use them too.
using display::color_to_mono;
using display::rec601_luma;
using display::Rgb565ToMono;
/** Delta for when to regard as gray */
static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */
constexpr uint8_t rec601_luma(Color color) {
return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8);
}
/** Map RGB color to a single monochrome bit
*
* @param color RGB color to convert from
* @return 1 = white, 0 = black
*/
constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; }
/** Map RGB color to one of 4 discrete gray levels (2 bits per pixel)
*
* @param color RGB color to convert from
+133 -2
View File
@@ -131,9 +131,7 @@ void EPaperBase::update() {
}
void EPaperBase::wait_for_idle_(bool should_wait) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
this->waiting_for_idle_start_ = millis();
#endif
this->waiting_for_idle_ = should_wait;
}
@@ -156,6 +154,10 @@ void EPaperBase::loop() {
ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_));
#endif
} else {
if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) {
this->abandon_update_();
return;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
if (now - this->waiting_for_idle_last_print_ >= 1000) {
ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_());
@@ -168,6 +170,20 @@ void EPaperBase::loop() {
this->process_state_();
}
// The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full
void EPaperBase::abandon_update_() {
ESP_LOGE(TAG, "Display stayed busy for %u s in state %s; check the busy pin", (unsigned) this->busy_timeout_seconds_,
this->epaper_state_to_string_());
this->status_set_warning(LOG_STR("Busy pin timeout"));
if (this->state_ == EPaperState::TRANSFER_DATA)
this->abort_transfer();
// Leave the panel powered down rather than with its high-voltage drivers enabled
this->power_off();
this->full_update_requested_ = true;
this->current_data_index_ = 0;
this->set_state_(EPaperState::IDLE);
}
/**
* Process the state machine.
* Typical state sequence:
@@ -242,6 +258,7 @@ void EPaperBase::process_state_() {
break;
case EPaperState::DEEP_SLEEP:
this->deep_sleep();
this->status_clear_warning();
this->set_state_(EPaperState::IDLE);
ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_);
break;
@@ -345,6 +362,120 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) {
}
}
namespace {
// One source row of an RGB565 block, written into a contiguous 1 bit per pixel buffer. Without SwapAxes the row lies
// along a buffer row and the bit moves by step; with it the row lies along a bit column and the byte moves by step
// rows.
template<bool SwapAxes, bool BigEndianSource>
void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_width, const uint8_t *source, int count,
const Rgb565ToMono &to_mono) {
const ptrdiff_t byte_step = SwapAxes ? step * static_cast<ptrdiff_t>(row_width) : 0;
for (; count != 0; count--, source += 2) {
const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8);
if (to_mono(value)) {
*byte |= mask;
} else {
*byte &= ~mask;
}
if constexpr (SwapAxes) {
byte += byte_step;
} else if (step > 0) {
mask >>= 1;
if (mask == 0) {
mask = 0x80;
byte++;
}
} else {
mask <<= 1;
if (mask == 0) {
mask = 0x01;
byte--;
}
}
}
}
} // namespace
// Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so
// the visible part of a block and its dirty area are worked out once for the whole block.
void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr,
ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) {
const bool swap = this->effective_transform_ & SWAP_XY;
const bool mirror_x = this->effective_transform_ & MIRROR_X;
const bool mirror_y = this->effective_transform_ & MIRROR_Y;
// The buffer axis a source column moves along, and the one a source row moves along
const int column_limit = swap ? this->height_ : this->width_;
const int row_limit = swap ? this->width_ : this->height_;
const bool column_mirrored = swap ? mirror_y : mirror_x;
const bool row_mirrored = swap ? mirror_x : mirror_y;
const int first_column = std::max(0, -x_start);
const int end_column = std::min(w, column_limit - x_start);
const int first_row = std::max(0, -y_start);
const int end_row = std::min(h, row_limit - y_start);
if (first_column >= end_column || first_row >= end_row)
return;
auto column_to_buffer = [&](int column) {
const int u = x_start + column;
return column_mirrored ? column_limit - 1 - u : u;
};
auto row_to_buffer = [&](int row) {
const int v = y_start + row;
return row_mirrored ? row_limit - 1 - v : v;
};
const int column_low = std::min(column_to_buffer(first_column), column_to_buffer(end_column - 1));
const int row_low = std::min(row_to_buffer(first_row), row_to_buffer(end_row - 1));
const int columns = end_column - first_column;
const int rows = end_row - first_row;
const int x_low = swap ? row_low : column_low;
const int y_low = swap ? column_low : row_low;
const int x_high = x_low + (swap ? rows : columns);
const int y_high = y_low + (swap ? columns : rows);
const Rgb565ToMono to_mono(order);
const size_t line_stride = x_offset + w + x_pad;
const size_t row_width = this->row_width_;
const int step = column_mirrored ? -1 : 1;
for (int row = first_row; row != end_row; row++) {
const uint8_t *source = ptr + ((y_offset + row) * line_stride + x_offset + first_column) * 2;
const int along = column_to_buffer(first_column);
const int across = row_to_buffer(row);
const int x = swap ? across : along;
const int y = swap ? along : across;
uint8_t *byte = buffer + y * row_width + x / 8;
const uint8_t mask = 0x80u >> (x % 8);
if (swap) {
if (big_endian) {
write_rgb565_row<true, true>(byte, mask, step, row_width, source, columns, to_mono);
} else {
write_rgb565_row<true, false>(byte, mask, step, row_width, source, columns, to_mono);
}
} else if (big_endian) {
write_rgb565_row<false, true>(byte, mask, step, row_width, source, columns, to_mono);
} else {
write_rgb565_row<false, false>(byte, mask, step, row_width, source, columns, to_mono);
}
}
this->x_low_ = clamp_at_most(this->x_low_, x_low);
this->x_high_ = clamp_at_least(this->x_high_, x_high);
this->y_low_ = clamp_at_most(this->y_low_, y_low);
this->y_high_ = clamp_at_least(this->y_high_, y_high);
}
// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the
// usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at().
void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) {
size_t contiguous_length;
uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length);
if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 ||
contiguous_length != this->buffer_length_) {
Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad);
return;
}
this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad);
}
void EPaperBase::dump_config() {
LOG_DISPLAY("", "E-Paper SPI", this);
ESP_LOGCONFIG(TAG,
+13 -2
View File
@@ -107,6 +107,8 @@ class EPaperBase : public Display,
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
void draw_pixel_at(int x, int y, Color color) override;
void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override;
// Make the next update a full one. Applied when that update starts, so one in progress is not affected.
void request_full_update() { this->full_update_requested_ = true; }
// True from the start of an update until the display has refreshed and gone back to sleep.
@@ -125,9 +127,14 @@ class EPaperBase : public Display,
virtual bool initialise(bool partial);
void send_init_sequence_(const uint8_t *sequence, size_t length);
void wait_for_idle_(bool should_wait);
void abandon_update_();
bool init_buffer_(size_t buffer_length);
void update_effective_transform_();
bool rotate_coordinates_(int &x, int &y);
void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
bool big_endian, int x_offset, int y_offset, int x_pad);
// Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly
virtual bool buffer_is_mono() const { return false; }
/**
* Methods that must be implemented by concrete classes to control the display
@@ -137,6 +144,8 @@ class EPaperBase : public Display,
* @return true if done, false if it should be called next loop
*/
virtual bool transfer_data() = 0;
// Called when an update is abandoned in the middle of transfer_data(), to release anything it left open
virtual void abort_transfer() {}
/**
* Refresh the screen after data transfer
*/
@@ -187,18 +196,20 @@ class EPaperBase : public Display,
// point to the pixel past the last one updated, i.e. may range up to width/height.
uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{};
uint32_t waiting_for_idle_start_{};
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
uint32_t waiting_for_idle_last_print_{};
uint32_t waiting_for_idle_start_{};
#endif
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
uint32_t update_start_time_{};
#endif
// properties with specific initialisers go last
EPaperState state_{EPaperState::IDLE};
uint32_t reset_duration_{10};
// Longer than any panel takes to refresh; past it the busy pin is assumed stuck
uint16_t busy_timeout_seconds_{120};
uint8_t full_update_every_{1};
EPaperState state_{EPaperState::IDLE};
};
} // namespace esphome::epaper_spi
@@ -15,6 +15,7 @@ class EPaperMono : public EPaperBase {
}
protected:
bool buffer_is_mono() const override { return true; }
void refresh_screen(bool partial) override;
void power_on() override {}
void power_off() override{};
@@ -58,7 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() {
// A full update ignores 0x26, and the copy may not hold a real frame yet (first update after
// boot): send the new frame to both planes.
const bool send_copy = this->plane_ == 0 && this->update_count_ != 0;
const bool direct = send_copy || this->buffer_is_plane();
// A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper
// buffer (the four-level model) returns false and packs the plane in plane_row().
const bool direct = send_copy || this->buffer_is_mono();
const auto &source = send_copy ? this->sent_ : this->buffer_;
const size_t row_length = this->plane_row_length_();
const size_t plane_length = row_length * this->height_;
@@ -34,9 +34,6 @@ class EPaperSSD1677 : public EPaperMono {
void init_comparison_frame_();
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
// Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is.
// A subclass with a deeper buffer returns false and overrides plane_row().
virtual bool buffer_is_plane() const { return true; }
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
virtual void plane_row(size_t y, uint8_t *out);
bool reset() override;
@@ -38,9 +38,9 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 {
void draw_pixel_at(int x, int y, Color color) override;
protected:
bool buffer_is_mono() const override { return false; }
// A partial update if partial updates are enabled and this is not a full one.
bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); }
bool buffer_is_plane() const override { return false; }
void plane_row(size_t y, uint8_t *out) override;
void refresh_screen(bool partial) override;
bool transfer_data() override;
@@ -364,6 +364,15 @@ bool HOT EPaperT133A01::transfer_data() {
return true;
}
void EPaperT133A01::abort_transfer() {
// A data stream is open, with one of the chip selects held low, whenever transfer_data() has started sending rows
if (this->current_data_index_ == 0)
return;
this->disable();
this->cs_pin_->digital_write(true);
this->cs1_pin_->digital_write(true);
}
void EPaperT133A01::dump_config() {
EPaperBase::dump_config();
LOG_PIN(" CS Pin: ", this->cs_pin_);
@@ -46,6 +46,7 @@ class EPaperT133A01 : public EPaperBase {
void deep_sleep() override;
bool transfer_data() override;
void abort_transfer() override;
/**
* Send a command (and optional data) selecting one or both controllers.
@@ -37,6 +37,7 @@ class EPaperUC8179 final : public EPaperBase {
}
protected:
bool buffer_is_mono() const override { return true; }
bool initialise(bool partial) override;
bool transfer_data() override;
void refresh_screen(bool partial) override;
+4 -6
View File
@@ -891,12 +891,10 @@ static uint8_t color_to_nibble(const Color &color) {
if (color.raw_32 == 0xFFFFFFFF)
return 0x0F; // white
// Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by
// 256). Rec.601 is the standard for converting SDR images to grayscale and
// spreads saturated colours across the mid-range; Rec.709 instead crams them
// against white/black where the 16 panel levels are hard to tell apart.
auto luma = static_cast<uint8_t>((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8);
return quantize_8bit_to_nibble(luma);
// Rec.601 luma is the standard for converting SDR images to grayscale and spreads saturated colours
// across the mid-range; Rec.709 instead crams them against white/black where the 16 panel levels are
// hard to tell apart.
return quantize_8bit_to_nibble(display::rec601_luma(color));
}
// 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535).
+4 -2
View File
@@ -1040,11 +1040,13 @@ def run_compile(args, config: ConfigType) -> bool:
framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
bootloader = zephyr_data()[KEY_BOOTLOADER]
# (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes
# (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes.
# The bootloader resets on a package whose sd_req does not list its SoftDevice,
# so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840).
_GENPKG_PARAMS = {
BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"),
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"),
}
# UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant
_UF2_FAMILY_IDS = {
@@ -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,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
@@ -0,0 +1,15 @@
light:
- platform: shelly_dimmer
name: Shelly Dimmer Light
power:
name: Shelly Dimmer Power
voltage:
name: Shelly Dimmer Voltage
current:
name: Shelly Dimmer Current
max_brightness: 500
firmware:
version: "51.6"
update: true
nrst_pin: 13
boot0_pin: 14
@@ -0,0 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
shelly_dimmer: !include common-update.yaml
@@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building(
app_hex = str(build_dir / "zephyr" / "zephyr.hex")
assert _uf2_hex(run_cmd) == app_hex
assert _dfu_hex(run_cmd) == app_hex
def test_sd140_v7_dfu_accepts_all_s140_7_softdevices(
build_dir: Path, run_cmd: Mock
) -> None:
"""Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1."""
_build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd)
cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0])
sd_req = cmd[cmd.index("--sd-req") + 1].split(",")
assert sd_req == ["0x00CA", "0x0100", "0x0123"]