Merge pull request #14303 from esphome/bump-2026.2.2

2026.2.2
This commit is contained in:
Jesse Hills
2026-02-26 15:16:33 +13:00
committed by GitHub
31 changed files with 460 additions and 146 deletions

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.2.1
PROJECT_NUMBER = 2026.2.2
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -1334,9 +1334,8 @@ uint16_t APIConnection::try_send_water_heater_state(EntityBase *entity, APIConne
resp.target_temperature_low = wh->get_target_temperature_low();
resp.target_temperature_high = wh->get_target_temperature_high();
resp.state = wh->get_state();
resp.key = wh->get_object_id_hash();
return encode_message_to_buffer(resp, WaterHeaterStateResponse::MESSAGE_TYPE, conn, remaining_size);
return fill_and_encode_entity_state(wh, resp, WaterHeaterStateResponse::MESSAGE_TYPE, conn, remaining_size);
}
uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *wh = static_cast<water_heater::WaterHeater *>(entity);

View File

@@ -36,6 +36,8 @@ template<typename... X> class TemplatableStringValue : public TemplatableValue<s
static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str()
static std::string value_to_string(const std::string &val) { return val; }
static std::string value_to_string(std::string &&val) { return std::move(val); }
static std::string value_to_string(const StringRef &val) { return val.str(); }
static std::string value_to_string(StringRef &&val) { return val.str(); }
public:
TemplatableStringValue() : TemplatableValue<std::string, X...>() {}

View File

@@ -178,8 +178,11 @@ async def to_code_base(config):
bsec2_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
cg.add(var.set_bsec2_configuration(bsec2_arr, len(rhs)))
# Although this component does not use SPI, the BSEC2 Arduino library requires the SPI library
# The BSEC2 and BME68x Arduino libraries unconditionally include Wire.h and
# SPI.h in their source files, so these libraries must be available even though
# ESPHome uses its own I2C/SPI abstractions instead of the Arduino ones.
if core.CORE.using_arduino:
cg.add_library("Wire", None)
cg.add_library("SPI", None)
cg.add_library(
"BME68x Sensor library",

View File

@@ -64,6 +64,9 @@ class Dsmr : public Component, public uart::UARTDevice {
void dump_config() override;
void set_decryption_key(const char *decryption_key);
// Remove before 2026.8.0
ESPDEPRECATED("Pass .c_str() - e.g. set_decryption_key(key.c_str()). Removed in 2026.8.0", "2026.2.0")
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key(decryption_key.c_str()); }
void set_max_telegram_length(size_t length) { this->max_telegram_len_ = length; }
void set_request_pin(GPIOPin *request_pin) { this->request_pin_ = request_pin; }
void set_request_interval(uint32_t interval) { this->request_interval_ = interval; }

View File

@@ -29,10 +29,10 @@ enum class CleaningState : uint8_t {
enum class HonControlMethod { MONITOR_ONLY = 0, SET_GROUP_PARAMETERS, SET_SINGLE_PARAMETER };
struct HonSettings {
hon_protocol::VerticalSwingMode last_vertiacal_swing;
hon_protocol::HorizontalSwingMode last_horizontal_swing;
bool beeper_state;
bool quiet_mode_state;
hon_protocol::VerticalSwingMode last_vertiacal_swing{hon_protocol::VerticalSwingMode::CENTER};
hon_protocol::HorizontalSwingMode last_horizontal_swing{hon_protocol::HorizontalSwingMode::CENTER};
bool beeper_state{true};
bool quiet_mode_state{false};
};
class HonClimate : public HaierClimateBase {
@@ -189,7 +189,7 @@ class HonClimate : public HaierClimateBase {
int big_data_sensors_{0};
esphome::optional<hon_protocol::VerticalSwingMode> current_vertical_swing_{};
esphome::optional<hon_protocol::HorizontalSwingMode> current_horizontal_swing_{};
HonSettings settings_;
HonSettings settings_{};
ESPPreferenceObject hon_rtc_;
SwitchState quiet_mode_state_{SwitchState::OFF};
};

View File

@@ -95,7 +95,7 @@ void HMC5883LComponent::update() {
float mg_per_bit;
switch (this->range_) {
case HMC5883L_RANGE_88_UT:
mg_per_bit = 0.073f;
mg_per_bit = 0.73f;
break;
case HMC5883L_RANGE_130_UT:
mg_per_bit = 0.92f;

View File

@@ -1,5 +1,7 @@
#include "ota_http_request.h"
#include <cctype>
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/core/log.h"
@@ -210,6 +212,26 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
return ota::OTA_RESPONSE_OK;
}
// URL-encode characters that are not unreserved per RFC 3986 section 2.3.
// This is needed for embedding userinfo (username/password) in URLs safely.
static std::string url_encode(const std::string &str) {
std::string result;
result.reserve(str.size());
for (char c : str) {
if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_' || c == '.' || c == '~') {
result += c;
} else {
result += '%';
result += format_hex_pretty_char((static_cast<uint8_t>(c) >> 4) & 0x0F);
result += format_hex_pretty_char(static_cast<uint8_t>(c) & 0x0F);
}
}
return result;
}
void OtaHttpRequestComponent::set_password(const std::string &password) { this->password_ = url_encode(password); }
void OtaHttpRequestComponent::set_username(const std::string &username) { this->username_ = url_encode(username); }
std::string OtaHttpRequestComponent::get_url_with_auth_(const std::string &url) {
if (this->username_.empty() || this->password_.empty()) {
return url;

View File

@@ -29,9 +29,9 @@ class OtaHttpRequestComponent : public ota::OTAComponent, public Parented<HttpRe
void set_md5_url(const std::string &md5_url);
void set_md5(const std::string &md5) { this->md5_expected_ = md5; }
void set_password(const std::string &password) { this->password_ = password; }
void set_password(const std::string &password);
void set_url(const std::string &url);
void set_username(const std::string &username) { this->username_ = username; }
void set_username(const std::string &username);
std::string md5_computed() { return this->md5_computed_; }
std::string md5_expected() { return this->md5_expected_; }

View File

@@ -590,7 +590,7 @@ void LD2420Component::handle_ack_data_(uint8_t *buffer, int len) {
for (uint16_t index = 0; index < (CMD_REG_DATA_REPLY_SIZE * // NOLINT
((buffer[CMD_FRAME_DATA_LENGTH] - 4) / CMD_REG_DATA_REPLY_SIZE));
index += CMD_REG_DATA_REPLY_SIZE) {
memcpy(&this->cmd_reply_.data[reg_element], &buffer[data_pos + index], sizeof(CMD_REG_DATA_REPLY_SIZE));
memcpy(&this->cmd_reply_.data[reg_element], &buffer[data_pos + index], CMD_REG_DATA_REPLY_SIZE);
byteswap(this->cmd_reply_.data[reg_element]);
reg_element++;
}
@@ -729,9 +729,9 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) {
cmd_frame.data_length = 0;
cmd_frame.header = CMD_FRAME_HEADER;
cmd_frame.command = CMD_WRITE_REGISTER;
memcpy(&cmd_frame.data[cmd_frame.data_length], &reg, sizeof(CMD_REG_DATA_REPLY_SIZE));
memcpy(&cmd_frame.data[cmd_frame.data_length], &reg, CMD_REG_DATA_REPLY_SIZE);
cmd_frame.data_length += 2;
memcpy(&cmd_frame.data[cmd_frame.data_length], &value, sizeof(CMD_REG_DATA_REPLY_SIZE));
memcpy(&cmd_frame.data[cmd_frame.data_length], &value, CMD_REG_DATA_REPLY_SIZE);
cmd_frame.data_length += 2;
cmd_frame.footer = CMD_FRAME_FOOTER;
ESP_LOGV(TAG, "Sending write register %4X command: %2X data = %4X", reg, cmd_frame.command, value);

View File

@@ -133,12 +133,12 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id(
@automation.register_action(
"max7129digit.invert_off", DisplayInvertAction, MAX7219_OFF_ACTION_SCHEMA
"max7219digit.invert_off", DisplayInvertAction, MAX7219_OFF_ACTION_SCHEMA
)
@automation.register_action(
"max7129digit.invert_on", DisplayInvertAction, MAX7219_ON_ACTION_SCHEMA
"max7219digit.invert_on", DisplayInvertAction, MAX7219_ON_ACTION_SCHEMA
)
async def max7129digit_invert_to_code(config, action_id, template_arg, args):
async def max7219digit_invert_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
cg.add(var.set_state(config[CONF_STATE]))
@@ -146,12 +146,12 @@ async def max7129digit_invert_to_code(config, action_id, template_arg, args):
@automation.register_action(
"max7129digit.turn_off", DisplayVisibilityAction, MAX7219_OFF_ACTION_SCHEMA
"max7219digit.turn_off", DisplayVisibilityAction, MAX7219_OFF_ACTION_SCHEMA
)
@automation.register_action(
"max7129digit.turn_on", DisplayVisibilityAction, MAX7219_ON_ACTION_SCHEMA
"max7219digit.turn_on", DisplayVisibilityAction, MAX7219_ON_ACTION_SCHEMA
)
async def max7129digit_visible_to_code(config, action_id, template_arg, args):
async def max7219digit_visible_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
cg.add(var.set_state(config[CONF_STATE]))
@@ -159,12 +159,12 @@ async def max7129digit_visible_to_code(config, action_id, template_arg, args):
@automation.register_action(
"max7129digit.reverse_off", DisplayReverseAction, MAX7219_OFF_ACTION_SCHEMA
"max7219digit.reverse_off", DisplayReverseAction, MAX7219_OFF_ACTION_SCHEMA
)
@automation.register_action(
"max7129digit.reverse_on", DisplayReverseAction, MAX7219_ON_ACTION_SCHEMA
"max7219digit.reverse_on", DisplayReverseAction, MAX7219_ON_ACTION_SCHEMA
)
async def max7129digit_reverse_to_code(config, action_id, template_arg, args):
async def max7219digit_reverse_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
cg.add(var.set_state(config[CONF_STATE]))
@@ -183,9 +183,9 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value(
@automation.register_action(
"max7129digit.intensity", DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA
"max7219digit.intensity", DisplayIntensityAction, MAX7219_INTENSITY_SCHEMA
)
async def max7129digit_intensity_to_code(config, action_id, template_arg, args):
async def max7219digit_intensity_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8)

View File

@@ -39,6 +39,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_COLOR_ORDER,
CONF_DIMENSIONS,
CONF_DISABLED,
CONF_ENABLE_PIN,
CONF_ID,
CONF_INIT_SEQUENCE,
@@ -87,38 +88,27 @@ COLOR_DEPTHS = {
def model_schema(config):
model = MODELS[config[CONF_MODEL].upper()]
transform = cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
}
)
if model.get_default(CONF_SWAP_XY) != cv.UNDEFINED:
transform = transform.extend(
model.defaults[CONF_SWAP_XY] = cv.UNDEFINED
transform = cv.Any(
cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
cv.Required(CONF_MIRROR_Y): cv.boolean,
cv.Optional(CONF_SWAP_XY): cv.invalid(
"Axis swapping not supported by this model"
)
"Axis swapping not supported by DSI displays"
),
}
)
else:
transform = transform.extend(
{
cv.Required(CONF_SWAP_XY): cv.boolean,
}
)
),
cv.one_of(CONF_DISABLED, lower=True),
)
# CUSTOM model will need to provide a custom init sequence
iseqconf = (
cv.Required(CONF_INIT_SEQUENCE)
if model.initsequence is None
else cv.Optional(CONF_INIT_SEQUENCE)
)
swap_xy = config.get(CONF_TRANSFORM, {}).get(CONF_SWAP_XY, False)
# Dimensions are optional if the model has a default width and the swap_xy transform is not overridden
cv_dimensions = (
cv.Optional if model.get_default(CONF_WIDTH) and not swap_xy else cv.Required
)
# Dimensions are optional if the model has a default width
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
pixel_modes = (PIXEL_MODE_16BIT, PIXEL_MODE_24BIT, "16", "24")
schema = display.FULL_DISPLAY_SCHEMA.extend(
{
@@ -213,9 +203,9 @@ async def to_code(config):
cg.add(var.set_vsync_pulse_width(config[CONF_VSYNC_PULSE_WIDTH]))
cg.add(var.set_vsync_back_porch(config[CONF_VSYNC_BACK_PORCH]))
cg.add(var.set_vsync_front_porch(config[CONF_VSYNC_FRONT_PORCH]))
cg.add(var.set_pclk_frequency(int(config[CONF_PCLK_FREQUENCY] / 1e6)))
cg.add(var.set_pclk_frequency(config[CONF_PCLK_FREQUENCY] / 1.0e6))
cg.add(var.set_lanes(int(config[CONF_LANES])))
cg.add(var.set_lane_bit_rate(int(config[CONF_LANE_BIT_RATE] / 1e6)))
cg.add(var.set_lane_bit_rate(config[CONF_LANE_BIT_RATE] / 1.0e6))
if reset_pin := config.get(CONF_RESET_PIN):
reset = await cg.gpio_pin_expression(reset_pin)
cg.add(var.set_reset_pin(reset))

View File

@@ -374,7 +374,7 @@ void MIPI_DSI::dump_config() {
"\n Swap X/Y: %s"
"\n Rotation: %d degrees"
"\n DSI Lanes: %u"
"\n Lane Bit Rate: %uMbps"
"\n Lane Bit Rate: %.0fMbps"
"\n HSync Pulse Width: %u"
"\n HSync Back Porch: %u"
"\n HSync Front Porch: %u"
@@ -385,7 +385,7 @@ void MIPI_DSI::dump_config() {
"\n Display Pixel Mode: %d bit"
"\n Color Order: %s"
"\n Invert Colors: %s"
"\n Pixel Clock: %dMHz",
"\n Pixel Clock: %.1fMHz",
this->model_, this->width_, this->height_, YESNO(this->madctl_ & (MADCTL_XFLIP | MADCTL_MX)),
YESNO(this->madctl_ & (MADCTL_YFLIP | MADCTL_MY)), YESNO(this->madctl_ & MADCTL_MV), this->rotation_,
this->lanes_, this->lane_bit_rate_, this->hsync_pulse_width_, this->hsync_back_porch_,

View File

@@ -47,7 +47,7 @@ class MIPI_DSI : public display::Display {
void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; }
void set_enable_pins(std::vector<GPIOPin *> enable_pins) { this->enable_pins_ = std::move(enable_pins); }
void set_pclk_frequency(uint32_t pclk_frequency) { this->pclk_frequency_ = pclk_frequency; }
void set_pclk_frequency(float pclk_frequency) { this->pclk_frequency_ = pclk_frequency; }
int get_width_internal() override { return this->width_; }
int get_height_internal() override { return this->height_; }
void set_hsync_back_porch(uint16_t hsync_back_porch) { this->hsync_back_porch_ = hsync_back_porch; }
@@ -58,7 +58,7 @@ class MIPI_DSI : public display::Display {
void set_vsync_front_porch(uint16_t vsync_front_porch) { this->vsync_front_porch_ = vsync_front_porch; }
void set_init_sequence(const std::vector<uint8_t> &init_sequence) { this->init_sequence_ = init_sequence; }
void set_model(const char *model) { this->model_ = model; }
void set_lane_bit_rate(uint16_t lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; }
void set_lane_bit_rate(float lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; }
void set_lanes(uint8_t lanes) { this->lanes_ = lanes; }
void set_madctl(uint8_t madctl) { this->madctl_ = madctl; }
@@ -95,9 +95,9 @@ class MIPI_DSI : public display::Display {
uint16_t vsync_front_porch_ = 10;
const char *model_{"Unknown"};
std::vector<uint8_t> init_sequence_{};
uint16_t pclk_frequency_ = 16; // in MHz
uint16_t lane_bit_rate_{1500}; // in Mbps
uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes
float pclk_frequency_ = 16; // in MHz
float lane_bit_rate_{1500}; // in Mbps
uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes
bool invert_colors_{};
display::ColorOrder color_mode_{display::COLOR_ORDER_BGR};

View File

@@ -61,7 +61,9 @@ struct IPAddress {
IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); }
IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; }
// Remove before 2026.8.0
ESPDEPRECATED("Use str_to() instead. Removed in 2026.8.0", "2026.2.0")
ESPDEPRECATED(
"str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0",
"2026.2.0")
std::string str() const {
char buf[IP_ADDRESS_BUFFER_SIZE];
this->str_to(buf);
@@ -150,7 +152,9 @@ struct IPAddress {
bool is_ip6() const { return IP_IS_V6(&ip_addr_); }
bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); }
// Remove before 2026.8.0
ESPDEPRECATED("Use str_to() instead. Removed in 2026.8.0", "2026.2.0")
ESPDEPRECATED(
"str() is deprecated: use 'char buf[IP_ADDRESS_BUFFER_SIZE]; ip.str_to(buf);' instead. Removed in 2026.8.0",
"2026.2.0")
std::string str() const {
char buf[IP_ADDRESS_BUFFER_SIZE];
this->str_to(buf);

View File

@@ -50,8 +50,8 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_HEAT_OUTPUT): cv.use_id(output.FloatOutput),
cv.Optional(CONF_DEADBAND_PARAMETERS): cv.Schema(
{
cv.Required(CONF_THRESHOLD_HIGH): cv.temperature,
cv.Required(CONF_THRESHOLD_LOW): cv.temperature,
cv.Required(CONF_THRESHOLD_HIGH): cv.temperature_delta,
cv.Required(CONF_THRESHOLD_LOW): cv.temperature_delta,
cv.Optional(CONF_KP_MULTIPLIER, default=0.1): cv.float_,
cv.Optional(CONF_KI_MULTIPLIER, default=0.0): cv.float_,
cv.Optional(CONF_KD_MULTIPLIER, default=0.0): cv.float_,

View File

@@ -139,9 +139,10 @@ void Rtttl::loop() {
x++;
}
if (x > 0) {
int send = this->speaker_->play((uint8_t *) (&sample), x * 2);
if (send != x * 4) {
this->samples_sent_ -= (x - (send / 2));
size_t bytes_to_send = x * sizeof(SpeakerSample);
size_t send = this->speaker_->play((uint8_t *) (&sample), bytes_to_send);
if (send != bytes_to_send) {
this->samples_sent_ -= (x - (send / sizeof(SpeakerSample)));
}
return;
}
@@ -201,9 +202,9 @@ void Rtttl::loop() {
bool need_note_gap = false;
if (note) {
auto note_index = (scale - 4) * 12 + note;
if (note_index < 0 || note_index >= (int) sizeof(NOTES)) {
if (note_index < 0 || note_index >= (int) (sizeof(NOTES) / sizeof(NOTES[0]))) {
ESP_LOGE(TAG, "Note out of range (note: %d, scale: %d, index: %d, max: %d)", note, scale, note_index,
(int) sizeof(NOTES));
(int) (sizeof(NOTES) / sizeof(NOTES[0])));
this->finish_();
return;
}
@@ -221,7 +222,7 @@ void Rtttl::loop() {
#ifdef USE_OUTPUT
if (this->output_ != nullptr) {
if (need_note_gap) {
if (need_note_gap && this->note_duration_ > DOUBLE_NOTE_GAP_MS) {
this->output_->set_level(0.0);
delay(DOUBLE_NOTE_GAP_MS);
this->note_duration_ -= DOUBLE_NOTE_GAP_MS;
@@ -240,9 +241,9 @@ void Rtttl::loop() {
this->samples_sent_ = 0;
this->samples_gap_ = 0;
this->samples_per_wave_ = 0;
this->samples_count_ = (this->sample_rate_ * this->note_duration_) / 1600; //(ms);
this->samples_count_ = (this->sample_rate_ * this->note_duration_) / 1000;
if (need_note_gap) {
this->samples_gap_ = (this->sample_rate_ * DOUBLE_NOTE_GAP_MS) / 1600; //(ms);
this->samples_gap_ = (this->sample_rate_ * DOUBLE_NOTE_GAP_MS) / 1000;
}
if (this->output_freq_ != 0) {
// make sure there is enough samples to add a full last sinus.
@@ -279,7 +280,7 @@ void Rtttl::play(std::string rtttl) {
this->note_duration_ = 0;
int bpm = 63;
uint8_t num;
uint16_t num;
// Get name
this->position_ = this->rtttl_.find(':');
@@ -395,7 +396,7 @@ void Rtttl::finish_() {
sample[0].right = 0;
sample[1].left = 0;
sample[1].right = 0;
this->speaker_->play((uint8_t *) (&sample), 8);
this->speaker_->play((uint8_t *) (&sample), sizeof(sample));
this->speaker_->finish();
this->set_state_(State::STOPPING);
}

View File

@@ -46,8 +46,8 @@ class Rtttl : public Component {
}
protected:
inline uint8_t get_integer_() {
uint8_t ret = 0;
inline uint16_t get_integer_() {
uint16_t ret = 0;
while (isdigit(this->rtttl_[this->position_])) {
ret = (ret * 10) + (this->rtttl_[this->position_++] - '0');
}
@@ -87,7 +87,7 @@ class Rtttl : public Component {
#ifdef USE_OUTPUT
/// The output to write the sound to.
output::FloatOutput *output_;
output::FloatOutput *output_{nullptr};
#endif // USE_OUTPUT
#ifdef USE_SPEAKER

View File

@@ -603,7 +603,7 @@ DELTA_SCHEMA = cv.Any(
def _get_delta(value):
if isinstance(value, str):
assert value.endswith("%")
return 0.0, float(value[:-1])
return 0.0, float(value[:-1]) / 100.0
return value, 0.0

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.2.1"
__version__ = "2026.2.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -132,7 +132,7 @@ void Application::setup() {
this->after_loop_tasks_();
this->app_state_ = new_app_state;
yield();
} while (!component->can_proceed());
} while (!component->can_proceed() && !component->is_failed());
}
ESP_LOGI(TAG, "setup() finished successfully!");

View File

@@ -15,8 +15,30 @@ esp_ldo:
display:
- platform: mipi_dsi
id: p4_nano
model: WAVESHARE-P4-NANO-10.1
rotation: 90
- platform: mipi_dsi
id: p4_86
model: "WAVESHARE-P4-86-PANEL"
rotation: 180
- platform: mipi_dsi
model: custom
id: custom_id
dimensions:
width: 400
height: 1280
hsync_back_porch: 40
hsync_pulse_width: 30
hsync_front_porch: 40
vsync_back_porch: 20
vsync_pulse_width: 10
vsync_front_porch: 20
pclk_frequency: 48Mhz
lane_bit_rate: 1.2Gbps
rotation: 180
transform: disabled
init_sequence:
i2c:
sda: GPIO7
scl: GPIO8

View File

@@ -119,9 +119,12 @@ def test_code_generation(
main_cpp = generate_main(component_fixture_path("mipi_dsi.yaml"))
assert (
"mipi_dsi_mipi_dsi_id = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);"
"p4_nano = new mipi_dsi::MIPI_DSI(800, 1280, display::COLOR_BITNESS_565, 16);"
in main_cpp
)
assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp
assert "mipi_dsi_mipi_dsi_id->set_lane_bit_rate(1500);" in main_cpp
assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp
assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp
assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp
assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp
# assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common.yaml

View File

@@ -1,4 +1,13 @@
esphome:
on_boot:
then:
- lambda: |-
// Test deprecated std::string overload still compiles
std::string key = "00112233445566778899aabbccddeeff";
id(dsmr_instance).set_decryption_key(key);
dsmr:
id: dsmr_instance
decryption_key: 00112233445566778899aabbccddeeff
max_telegram_length: 1000
request_pin: ${request_pin}

View File

@@ -90,6 +90,19 @@ text_sensor:
id: ha_hello_world_text2
attribute: some_attribute
event:
- platform: template
name: Test Event
id: test_event
event_types:
- test_event_type
on_event:
- homeassistant.event:
event: esphome.test_event
data:
event_name: !lambda |-
return event_type;
time:
- platform: homeassistant
on_time:

View File

@@ -13,10 +13,10 @@ esphome:
on_boot:
- priority: 100
then:
- max7129digit.invert_off:
- max7129digit.invert_on:
- max7129digit.turn_on:
- max7129digit.turn_off:
- max7129digit.reverse_on:
- max7129digit.reverse_off:
- max7129digit.intensity: 10
- max7219digit.invert_off:
- max7219digit.invert_on:
- max7219digit.turn_on:
- max7219digit.turn_off:
- max7219digit.reverse_on:
- max7219digit.reverse_off:
- max7219digit.intensity: 10

View File

@@ -46,6 +46,7 @@ sensor:
binary_sensor:
- platform: template
id: motion_detected
name: Motion Detected
device_id: motion_sensor
lambda: return true;
@@ -82,3 +83,117 @@ output:
write_action:
- lambda: |-
ESP_LOGD("test", "Light output: %d", state);
cover:
- platform: template
name: Garage Door
device_id: motion_sensor
optimistic: true
fan:
- platform: template
name: Ceiling Fan
device_id: humidity_monitor
speed_count: 3
has_oscillating: false
has_direction: false
lock:
- platform: template
name: Front Door Lock
device_id: motion_sensor
optimistic: true
number:
- platform: template
name: Target Temperature
device_id: temperature_monitor
optimistic: true
min_value: 0
max_value: 100
step: 1
select:
- platform: template
name: Mode Select
device_id: humidity_monitor
optimistic: true
options:
- "Auto"
- "Manual"
text:
- platform: template
name: Device Label
device_id: temperature_monitor
optimistic: true
mode: text
valve:
- platform: template
name: Water Valve
device_id: humidity_monitor
optimistic: true
globals:
- id: global_away
type: bool
initial_value: "false"
- id: global_is_on
type: bool
initial_value: "true"
water_heater:
- platform: template
name: Test Boiler
device_id: temperature_monitor
optimistic: true
current_temperature: !lambda "return 45.0f;"
target_temperature: !lambda "return 60.0f;"
away: !lambda "return id(global_away);"
is_on: !lambda "return id(global_is_on);"
supported_modes:
- "off"
- electric
visual:
min_temperature: 30.0
max_temperature: 85.0
target_temperature_step: 0.5
set_action:
- lambda: |-
ESP_LOGD("test", "Water heater set");
alarm_control_panel:
- platform: template
name: House Alarm
device_id: motion_sensor
codes:
- "1234"
restore_mode: ALWAYS_DISARMED
binary_sensors:
- input: motion_detected
datetime:
- platform: template
name: Schedule Date
device_id: temperature_monitor
type: date
optimistic: true
- platform: template
name: Schedule Time
device_id: humidity_monitor
type: time
optimistic: true
- platform: template
name: Schedule DateTime
device_id: motion_sensor
type: datetime
optimistic: true
event:
- platform: template
name: Doorbell
device_id: motion_sensor
event_types:
- "press"
- "double_press"

View File

@@ -28,6 +28,11 @@ sensor:
id: source_sensor_4
accuracy_decimals: 1
- platform: template
name: "Source Sensor 5"
id: source_sensor_5
accuracy_decimals: 1
- platform: copy
source_id: source_sensor_1
name: "Filter Min"
@@ -69,6 +74,13 @@ sensor:
filters:
- delta: 0
- platform: copy
source_id: source_sensor_5
name: "Filter Percentage"
id: filter_percentage
filters:
- delta: 50%
script:
- id: test_filter_min
then:
@@ -154,6 +166,28 @@ script:
id: source_sensor_4
state: 2.0
- id: test_filter_percentage
then:
- sensor.template.publish:
id: source_sensor_5
state: 100.0
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 120.0 # Filtered out (delta=20, need >50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 160.0 # Passes (delta=60 > 50% of 100=50)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 200.0 # Filtered out (delta=40, need >50% of 160=80)
- delay: 20ms
- sensor.template.publish:
id: source_sensor_5
state: 250.0 # Passes (delta=90 > 80)
button:
- platform: template
name: "Test Filter Min"
@@ -178,3 +212,9 @@ button:
id: btn_filter_zero_delta
on_press:
- script.execute: test_filter_zero_delta
- platform: template
name: "Test Filter Percentage"
id: btn_filter_percentage
on_press:
- script.execute: test_filter_percentage

View File

@@ -4,11 +4,80 @@ from __future__ import annotations
import asyncio
from aioesphomeapi import BinarySensorState, EntityState, SensorState, TextSensorState
from aioesphomeapi import (
AlarmControlPanelEntityState,
BinarySensorState,
CoverState,
DateState,
DateTimeState,
EntityState,
FanState,
LightState,
LockEntityState,
NumberState,
SelectState,
SensorState,
SwitchState,
TextSensorState,
TextState,
TimeState,
ValveState,
WaterHeaterState,
)
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# Mapping of entity name to device name for all entities with device_id
ENTITY_TO_DEVICE = {
# Original entities
"Temperature": "Temperature Monitor",
"Humidity": "Humidity Monitor",
"Motion Detected": "Motion Sensor",
"Temperature Monitor Power": "Temperature Monitor",
"Temperature Status": "Temperature Monitor",
"Motion Light": "Motion Sensor",
# New entity types
"Garage Door": "Motion Sensor",
"Ceiling Fan": "Humidity Monitor",
"Front Door Lock": "Motion Sensor",
"Target Temperature": "Temperature Monitor",
"Mode Select": "Humidity Monitor",
"Device Label": "Temperature Monitor",
"Water Valve": "Humidity Monitor",
"Test Boiler": "Temperature Monitor",
"House Alarm": "Motion Sensor",
"Schedule Date": "Temperature Monitor",
"Schedule Time": "Humidity Monitor",
"Schedule DateTime": "Motion Sensor",
"Doorbell": "Motion Sensor",
}
# Entities without device_id (should have device_id 0)
NO_DEVICE_ENTITIES = {"No Device Sensor"}
# State types that should have non-zero device_id, mapped by their aioesphomeapi class
EXPECTED_STATE_TYPES = [
(SensorState, "sensor"),
(BinarySensorState, "binary_sensor"),
(SwitchState, "switch"),
(TextSensorState, "text_sensor"),
(LightState, "light"),
(CoverState, "cover"),
(FanState, "fan"),
(LockEntityState, "lock"),
(NumberState, "number"),
(SelectState, "select"),
(TextState, "text"),
(ValveState, "valve"),
(WaterHeaterState, "water_heater"),
(AlarmControlPanelEntityState, "alarm_control_panel"),
(DateState, "date"),
(TimeState, "time"),
(DateTimeState, "datetime"),
# Event is stateless (no initial state sent on subscribe)
]
@pytest.mark.asyncio
async def test_device_id_in_state(
@@ -40,34 +109,35 @@ async def test_device_id_in_state(
entity_device_mapping: dict[int, int] = {}
for entity in all_entities:
# All entities have name and key attributes
if entity.name == "Temperature":
entity_device_mapping[entity.key] = device_ids["Temperature Monitor"]
elif entity.name == "Humidity":
entity_device_mapping[entity.key] = device_ids["Humidity Monitor"]
elif entity.name == "Motion Detected":
entity_device_mapping[entity.key] = device_ids["Motion Sensor"]
elif entity.name in {"Temperature Monitor Power", "Temperature Status"}:
entity_device_mapping[entity.key] = device_ids["Temperature Monitor"]
elif entity.name == "Motion Light":
entity_device_mapping[entity.key] = device_ids["Motion Sensor"]
elif entity.name == "No Device Sensor":
# Entity without device_id should have device_id 0
if entity.name in ENTITY_TO_DEVICE:
expected_device = ENTITY_TO_DEVICE[entity.name]
entity_device_mapping[entity.key] = device_ids[expected_device]
elif entity.name in NO_DEVICE_ENTITIES:
entity_device_mapping[entity.key] = 0
assert len(entity_device_mapping) >= 6, (
f"Expected at least 6 mapped entities, got {len(entity_device_mapping)}"
expected_count = len(ENTITY_TO_DEVICE) + len(NO_DEVICE_ENTITIES)
assert len(entity_device_mapping) >= expected_count, (
f"Expected at least {expected_count} mapped entities, "
f"got {len(entity_device_mapping)}. "
f"Missing: {set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES - {e.name for e in all_entities}}"
)
# Subscribe to states and wait for all mapped entities
# Event entities are stateless (no initial state on subscribe),
# so exclude them from the expected count
stateless_keys = {e.key for e in all_entities if e.name == "Doorbell"}
stateful_count = len(entity_device_mapping) - len(
stateless_keys & entity_device_mapping.keys()
)
# Subscribe to states
loop = asyncio.get_running_loop()
states: dict[int, EntityState] = {}
states_future: asyncio.Future[bool] = loop.create_future()
def on_state(state: EntityState) -> None:
states[state.key] = state
# Check if we have states for all mapped entities
if len(states) >= len(entity_device_mapping) and not states_future.done():
if state.key in entity_device_mapping:
states[state.key] = state
if len(states) >= stateful_count and not states_future.done():
states_future.set_result(True)
client.subscribe_states(on_state)
@@ -76,9 +146,16 @@ async def test_device_id_in_state(
try:
await asyncio.wait_for(states_future, timeout=10.0)
except TimeoutError:
received_names = {e.name for e in all_entities if e.key in states}
missing_names = (
(set(ENTITY_TO_DEVICE) | NO_DEVICE_ENTITIES)
- received_names
- {"Doorbell"}
)
pytest.fail(
f"Did not receive all entity states within 10 seconds. "
f"Received {len(states)} states, expected {len(entity_device_mapping)}"
f"Received {len(states)} states. "
f"Missing: {missing_names}"
)
# Verify each state has the correct device_id
@@ -86,51 +163,33 @@ async def test_device_id_in_state(
for key, expected_device_id in entity_device_mapping.items():
if key in states:
state = states[key]
entity_name = next(
(e.name for e in all_entities if e.key == key), f"key={key}"
)
assert state.device_id == expected_device_id, (
f"State for key {key} has device_id {state.device_id}, "
f"expected {expected_device_id}"
f"State for '{entity_name}' (type={type(state).__name__}) "
f"has device_id {state.device_id}, expected {expected_device_id}"
)
verified_count += 1
assert verified_count >= 6, (
f"Only verified {verified_count} states, expected at least 6"
# All stateful entities should be verified (everything except Doorbell event)
expected_verified = expected_count - 1 # exclude Doorbell
assert verified_count >= expected_verified, (
f"Only verified {verified_count} states, expected at least {expected_verified}"
)
# Test specific state types to ensure device_id is present
# Find a sensor state with device_id
sensor_state = next(
(
# Verify each expected state type has at least one instance with non-zero device_id
for state_type, type_name in EXPECTED_STATE_TYPES:
matching = [
s
for s in states.values()
if isinstance(s, SensorState)
and isinstance(s.state, float)
and s.device_id != 0
),
None,
)
assert sensor_state is not None, "No sensor state with device_id found"
assert sensor_state.device_id > 0, "Sensor state should have non-zero device_id"
# Find a binary sensor state
binary_sensor_state = next(
(s for s in states.values() if isinstance(s, BinarySensorState)),
None,
)
assert binary_sensor_state is not None, "No binary sensor state found"
assert binary_sensor_state.device_id > 0, (
"Binary sensor state should have non-zero device_id"
)
# Find a text sensor state
text_sensor_state = next(
(s for s in states.values() if isinstance(s, TextSensorState)),
None,
)
assert text_sensor_state is not None, "No text sensor state found"
assert text_sensor_state.device_id > 0, (
"Text sensor state should have non-zero device_id"
)
if isinstance(s, state_type) and s.device_id != 0
]
assert matching, (
f"No {type_name} state (type={state_type.__name__}) "
f"with non-zero device_id found"
)
# Verify the "No Device Sensor" has device_id = 0
no_device_key = next(

View File

@@ -24,12 +24,14 @@ async def test_sensor_filters_delta(
"filter_max": [],
"filter_baseline_max": [],
"filter_zero_delta": [],
"filter_percentage": [],
}
filter_min_done = loop.create_future()
filter_max_done = loop.create_future()
filter_baseline_max_done = loop.create_future()
filter_zero_delta_done = loop.create_future()
filter_percentage_done = loop.create_future()
def on_state(state: EntityState) -> None:
if not isinstance(state, SensorState) or state.missing_state:
@@ -66,6 +68,12 @@ async def test_sensor_filters_delta(
and not filter_zero_delta_done.done()
):
filter_zero_delta_done.set_result(True)
elif (
sensor_name == "filter_percentage"
and len(sensor_values[sensor_name]) == 3
and not filter_percentage_done.done()
):
filter_percentage_done.set_result(True)
async with (
run_compiled(yaml_config),
@@ -80,6 +88,7 @@ async def test_sensor_filters_delta(
"filter_max": "Filter Max",
"filter_baseline_max": "Filter Baseline Max",
"filter_zero_delta": "Filter Zero Delta",
"filter_percentage": "Filter Percentage",
},
)
@@ -98,13 +107,14 @@ async def test_sensor_filters_delta(
"Test Filter Max": "filter_max",
"Test Filter Baseline Max": "filter_baseline_max",
"Test Filter Zero Delta": "filter_zero_delta",
"Test Filter Percentage": "filter_percentage",
}
buttons = {}
for entity in entities:
if isinstance(entity, ButtonInfo) and entity.name in button_name_map:
buttons[button_name_map[entity.name]] = entity.key
assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}"
assert len(buttons) == 5, f"Expected 5 buttons, found {len(buttons)}"
# Test 1: Min
sensor_values["filter_min"].clear()
@@ -161,3 +171,18 @@ async def test_sensor_filters_delta(
assert sensor_values["filter_zero_delta"] == pytest.approx(expected), (
f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}"
)
# Test 5: Percentage (delta: 50%)
sensor_values["filter_percentage"].clear()
client.button_command(buttons["filter_percentage"])
try:
await asyncio.wait_for(filter_percentage_done, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Test 5 timed out. Values: {sensor_values['filter_percentage']}"
)
expected = [100.0, 160.0, 250.0]
assert sensor_values["filter_percentage"] == pytest.approx(expected), (
f"Test 5 failed: expected {expected}, got {sensor_values['filter_percentage']}"
)