Merge pull request #9518 from esphome/bump-2025.7.0b4

2025.7.0b4
This commit is contained in:
Jesse Hills 2025-07-16 07:34:42 +12:00 committed by GitHub
commit 18e2f41424
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 385 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 = 2025.7.0b3
PROJECT_NUMBER = 2025.7.0b4
# 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

@ -31,7 +31,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config):
if CORE.is_esp32 or CORE.is_libretiny:
# https://github.com/ESP32Async/AsyncTCP
cg.add_library("ESP32Async/AsyncTCP", "3.4.4")
cg.add_library("ESP32Async/AsyncTCP", "3.4.5")
elif CORE.is_esp8266:
# https://github.com/ESP32Async/ESPAsyncTCP
cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0")

View File

@ -177,6 +177,10 @@ optional<FanRestoreState> Fan::restore_state_() {
return {};
}
void Fan::save_state_() {
if (this->restore_mode_ == FanRestoreMode::NO_RESTORE) {
return;
}
FanRestoreState state{};
state.state = this->state;
state.oscillating = this->oscillating;

View File

@ -83,7 +83,7 @@ void HttpRequestUpdate::update_task(void *params) {
container.reset(); // Release ownership of the container's shared_ptr
valid = json::parse_json(response, [this_update](JsonObject root) -> bool {
if (!root.containsKey("name") || !root.containsKey("version") || !root.containsKey("builds")) {
if (!root["name"].is<const char *>() || !root["version"].is<const char *>() || !root["builds"].is<JsonArray>()) {
ESP_LOGE(TAG, "Manifest does not contain required fields");
return false;
}
@ -91,26 +91,26 @@ void HttpRequestUpdate::update_task(void *params) {
this_update->update_info_.latest_version = root["version"].as<std::string>();
for (auto build : root["builds"].as<JsonArray>()) {
if (!build.containsKey("chipFamily")) {
if (!build["chipFamily"].is<const char *>()) {
ESP_LOGE(TAG, "Manifest does not contain required fields");
return false;
}
if (build["chipFamily"] == ESPHOME_VARIANT) {
if (!build.containsKey("ota")) {
if (!build["ota"].is<JsonObject>()) {
ESP_LOGE(TAG, "Manifest does not contain required fields");
return false;
}
auto ota = build["ota"];
if (!ota.containsKey("path") || !ota.containsKey("md5")) {
JsonObject ota = build["ota"].as<JsonObject>();
if (!ota["path"].is<const char *>() || !ota["md5"].is<const char *>()) {
ESP_LOGE(TAG, "Manifest does not contain required fields");
return false;
}
this_update->update_info_.firmware_url = ota["path"].as<std::string>();
this_update->update_info_.md5 = ota["md5"].as<std::string>();
if (ota.containsKey("summary"))
if (ota["summary"].is<const char *>())
this_update->update_info_.summary = ota["summary"].as<std::string>();
if (ota.containsKey("release_url"))
if (ota["release_url"].is<const char *>())
this_update->update_info_.release_url = ota["release_url"].as<std::string>();
return true;

View File

@ -12,6 +12,6 @@ CONFIG_SCHEMA = cv.All(
@coroutine_with_priority(1.0)
async def to_code(config):
cg.add_library("bblanchon/ArduinoJson", "6.18.5")
cg.add_library("bblanchon/ArduinoJson", "7.4.2")
cg.add_define("USE_JSON")
cg.add_global(json_ns.using)

View File

@ -1,83 +1,76 @@
#include "json_util.h"
#include "esphome/core/log.h"
// ArduinoJson::Allocator is included via ArduinoJson.h in json_util.h
namespace esphome {
namespace json {
static const char *const TAG = "json";
static std::vector<char> global_json_build_buffer; // NOLINT
static const auto ALLOCATOR = RAMAllocator<uint8_t>(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
// Build an allocator for the JSON Library using the RAMAllocator class
struct SpiRamAllocator : ArduinoJson::Allocator {
void *allocate(size_t size) override { return this->allocator_.allocate(size); }
void deallocate(void *pointer) override {
// ArduinoJson's Allocator interface doesn't provide the size parameter in deallocate.
// RAMAllocator::deallocate() requires the size, which we don't have access to here.
// RAMAllocator::deallocate implementation just calls free() regardless of whether
// the memory was allocated with heap_caps_malloc or malloc.
// This is safe because ESP-IDF's heap implementation internally tracks the memory region
// and routes free() to the appropriate heap.
free(pointer); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc)
}
void *reallocate(void *ptr, size_t new_size) override {
return this->allocator_.reallocate(static_cast<uint8_t *>(ptr), new_size);
}
protected:
RAMAllocator<uint8_t> allocator_{RAMAllocator<uint8_t>(RAMAllocator<uint8_t>::NONE)};
};
std::string build_json(const json_build_t &f) {
// Here we are allocating up to 5kb of memory,
// with the heap size minus 2kb to be safe if less than 5kb
// as we can not have a true dynamic sized document.
// The excess memory is freed below with `shrinkToFit()`
auto free_heap = ALLOCATOR.get_max_free_block_size();
size_t request_size = std::min(free_heap, (size_t) 512);
while (true) {
ESP_LOGV(TAG, "Attempting to allocate %zu bytes for JSON serialization", request_size);
DynamicJsonDocument json_document(request_size);
if (json_document.capacity() == 0) {
ESP_LOGE(TAG, "Could not allocate memory for document! Requested %zu bytes, largest free heap block: %zu bytes",
request_size, free_heap);
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
auto doc_allocator = SpiRamAllocator();
JsonDocument json_document(&doc_allocator);
if (json_document.overflowed()) {
ESP_LOGE(TAG, "Could not allocate memory for JSON document!");
return "{}";
}
JsonObject root = json_document.to<JsonObject>();
f(root);
if (json_document.overflowed()) {
if (request_size == free_heap) {
ESP_LOGE(TAG, "Could not allocate memory for document! Overflowed largest free heap block: %zu bytes",
free_heap);
ESP_LOGE(TAG, "Could not allocate memory for JSON document!");
return "{}";
}
request_size = std::min(request_size * 2, free_heap);
continue;
}
json_document.shrinkToFit();
ESP_LOGV(TAG, "Size after shrink %zu bytes", json_document.capacity());
std::string output;
serializeJson(json_document, output);
return output;
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
bool parse_json(const std::string &data, const json_parse_t &f) {
// Here we are allocating 1.5 times the data size,
// with the heap size minus 2kb to be safe if less than that
// as we can not have a true dynamic sized document.
// The excess memory is freed below with `shrinkToFit()`
auto free_heap = ALLOCATOR.get_max_free_block_size();
size_t request_size = std::min(free_heap, (size_t) (data.size() * 1.5));
while (true) {
DynamicJsonDocument json_document(request_size);
if (json_document.capacity() == 0) {
ESP_LOGE(TAG, "Could not allocate memory for document! Requested %zu bytes, free heap: %zu", request_size,
free_heap);
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
auto doc_allocator = SpiRamAllocator();
JsonDocument json_document(&doc_allocator);
if (json_document.overflowed()) {
ESP_LOGE(TAG, "Could not allocate memory for JSON document!");
return false;
}
DeserializationError err = deserializeJson(json_document, data);
json_document.shrinkToFit();
JsonObject root = json_document.as<JsonObject>();
if (err == DeserializationError::Ok) {
return f(root);
} else if (err == DeserializationError::NoMemory) {
if (request_size * 2 >= free_heap) {
ESP_LOGE(TAG, "Can not allocate more memory for deserialization. Consider making source string smaller");
return false;
}
ESP_LOGV(TAG, "Increasing memory allocation.");
request_size *= 2;
continue;
} else {
ESP_LOGE(TAG, "Parse error: %s", err.c_str());
return false;
}
};
return false;
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
} // namespace json

View File

@ -9,6 +9,7 @@ namespace light {
// See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema
void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (state.supports_effects())
root["effect"] = state.get_effect_name();
@ -52,7 +53,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
if (values.get_color_mode() & ColorCapability::BRIGHTNESS)
root["brightness"] = uint8_t(values.get_brightness() * 255);
JsonObject color = root.createNestedObject("color");
JsonObject color = root["color"].to<JsonObject>();
if (values.get_color_mode() & ColorCapability::RGB) {
color["r"] = uint8_t(values.get_color_brightness() * values.get_red() * 255);
color["g"] = uint8_t(values.get_color_brightness() * values.get_green() * 255);
@ -73,7 +74,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
}
void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonObject root) {
if (root.containsKey("state")) {
if (root["state"].is<const char *>()) {
auto val = parse_on_off(root["state"]);
switch (val) {
case PARSE_ON:
@ -90,40 +91,40 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO
}
}
if (root.containsKey("brightness")) {
if (root["brightness"].is<uint8_t>()) {
call.set_brightness(float(root["brightness"]) / 255.0f);
}
if (root.containsKey("color")) {
if (root["color"].is<JsonObject>()) {
JsonObject color = root["color"];
// HA also encodes brightness information in the r, g, b values, so extract that and set it as color brightness.
float max_rgb = 0.0f;
if (color.containsKey("r")) {
if (color["r"].is<uint8_t>()) {
float r = float(color["r"]) / 255.0f;
max_rgb = fmaxf(max_rgb, r);
call.set_red(r);
}
if (color.containsKey("g")) {
if (color["g"].is<uint8_t>()) {
float g = float(color["g"]) / 255.0f;
max_rgb = fmaxf(max_rgb, g);
call.set_green(g);
}
if (color.containsKey("b")) {
if (color["b"].is<uint8_t>()) {
float b = float(color["b"]) / 255.0f;
max_rgb = fmaxf(max_rgb, b);
call.set_blue(b);
}
if (color.containsKey("r") || color.containsKey("g") || color.containsKey("b")) {
if (color["r"].is<uint8_t>() || color["g"].is<uint8_t>() || color["b"].is<uint8_t>()) {
call.set_color_brightness(max_rgb);
}
if (color.containsKey("c")) {
if (color["c"].is<uint8_t>()) {
call.set_cold_white(float(color["c"]) / 255.0f);
}
if (color.containsKey("w")) {
if (color["w"].is<uint8_t>()) {
// the HA scheme is ambiguous here, the same key is used for white channel in RGBW and warm
// white channel in RGBWW.
if (color.containsKey("c")) {
if (color["c"].is<uint8_t>()) {
call.set_warm_white(float(color["w"]) / 255.0f);
} else {
call.set_white(float(color["w"]) / 255.0f);
@ -131,11 +132,11 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO
}
}
if (root.containsKey("white_value")) { // legacy API
if (root["white_value"].is<uint8_t>()) { // legacy API
call.set_white(float(root["white_value"]) / 255.0f);
}
if (root.containsKey("color_temp")) {
if (root["color_temp"].is<uint16_t>()) {
call.set_color_temperature(float(root["color_temp"]));
}
}
@ -143,17 +144,17 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO
void LightJSONSchema::parse_json(LightState &state, LightCall &call, JsonObject root) {
LightJSONSchema::parse_color_json(state, call, root);
if (root.containsKey("flash")) {
if (root["flash"].is<uint32_t>()) {
auto length = uint32_t(float(root["flash"]) * 1000);
call.set_flash_length(length);
}
if (root.containsKey("transition")) {
if (root["transition"].is<uint16_t>()) {
auto length = uint32_t(float(root["transition"]) * 1000);
call.set_transition_length(length);
}
if (root.containsKey("effect")) {
if (root["effect"].is<const char *>()) {
const char *effect = root["effect"];
call.set_effect(effect);
}

View File

@ -55,7 +55,8 @@ void MQTTAlarmControlPanelComponent::dump_config() {
}
void MQTTAlarmControlPanelComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
JsonArray supported_features = root.createNestedArray(MQTT_SUPPORTED_FEATURES);
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
JsonArray supported_features = root[MQTT_SUPPORTED_FEATURES].to<JsonArray>();
const uint32_t acp_supported_features = this->alarm_control_panel_->get_supported_features();
if (acp_supported_features & ACP_FEAT_ARM_AWAY) {
supported_features.add("arm_away");

View File

@ -30,6 +30,7 @@ MQTTBinarySensorComponent::MQTTBinarySensorComponent(binary_sensor::BinarySensor
}
void MQTTBinarySensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (!this->binary_sensor_->get_device_class().empty())
root[MQTT_DEVICE_CLASS] = this->binary_sensor_->get_device_class();
if (this->binary_sensor_->is_status_binary_sensor())

View File

@ -31,10 +31,13 @@ void MQTTButtonComponent::dump_config() {
}
void MQTTButtonComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
config.state_topic = false;
if (!this->button_->get_device_class().empty())
if (!this->button_->get_device_class().empty()) {
root[MQTT_DEVICE_CLASS] = this->button_->get_device_class();
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
std::string MQTTButtonComponent::component_type() const { return "button"; }
const EntityBase *MQTTButtonComponent::get_entity() const { return this->button_; }

View File

@ -92,6 +92,7 @@ void MQTTClientComponent::send_device_info_() {
std::string topic = "esphome/discover/";
topic.append(App.get_name());
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
this->publish_json(
topic,
[](JsonObject root) {
@ -147,6 +148,7 @@ void MQTTClientComponent::send_device_info_() {
#endif
},
2, this->discovery_info_.retain);
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
void MQTTClientComponent::dump_config() {

View File

@ -14,6 +14,7 @@ static const char *const TAG = "mqtt.climate";
using namespace esphome::climate;
void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
auto traits = this->device_->get_traits();
// current_temperature_topic
if (traits.get_supports_current_temperature()) {
@ -28,7 +29,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
// mode_state_topic
root[MQTT_MODE_STATE_TOPIC] = this->get_mode_state_topic();
// modes
JsonArray modes = root.createNestedArray(MQTT_MODES);
JsonArray modes = root[MQTT_MODES].to<JsonArray>();
// sort array for nice UI in HA
if (traits.supports_mode(CLIMATE_MODE_AUTO))
modes.add("auto");
@ -89,7 +90,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
// preset_mode_state_topic
root[MQTT_PRESET_MODE_STATE_TOPIC] = this->get_preset_state_topic();
// presets
JsonArray presets = root.createNestedArray("preset_modes");
JsonArray presets = root["preset_modes"].to<JsonArray>();
if (traits.supports_preset(CLIMATE_PRESET_HOME))
presets.add("home");
if (traits.supports_preset(CLIMATE_PRESET_AWAY))
@ -119,7 +120,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
// fan_mode_state_topic
root[MQTT_FAN_MODE_STATE_TOPIC] = this->get_fan_mode_state_topic();
// fan_modes
JsonArray fan_modes = root.createNestedArray("fan_modes");
JsonArray fan_modes = root["fan_modes"].to<JsonArray>();
if (traits.supports_fan_mode(CLIMATE_FAN_ON))
fan_modes.add("on");
if (traits.supports_fan_mode(CLIMATE_FAN_OFF))
@ -150,7 +151,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
// swing_mode_state_topic
root[MQTT_SWING_MODE_STATE_TOPIC] = this->get_swing_mode_state_topic();
// swing_modes
JsonArray swing_modes = root.createNestedArray("swing_modes");
JsonArray swing_modes = root["swing_modes"].to<JsonArray>();
if (traits.supports_swing_mode(CLIMATE_SWING_OFF))
swing_modes.add("off");
if (traits.supports_swing_mode(CLIMATE_SWING_BOTH))
@ -163,6 +164,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo
config.state_topic = false;
config.command_topic = false;
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
void MQTTClimateComponent::setup() {
auto traits = this->device_->get_traits();

View File

@ -70,6 +70,7 @@ bool MQTTComponent::send_discovery_() {
ESP_LOGV(TAG, "'%s': Sending discovery", this->friendly_name().c_str());
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
return global_mqtt_client->publish_json(
this->get_discovery_topic_(discovery_info),
[this](JsonObject root) {
@ -155,7 +156,7 @@ bool MQTTComponent::send_discovery_() {
}
std::string node_area = App.get_area();
JsonObject device_info = root.createNestedObject(MQTT_DEVICE);
JsonObject device_info = root[MQTT_DEVICE].to<JsonObject>();
const auto mac = get_mac_address();
device_info[MQTT_DEVICE_IDENTIFIERS] = mac;
device_info[MQTT_DEVICE_NAME] = node_friendly_name;
@ -192,6 +193,7 @@ bool MQTTComponent::send_discovery_() {
device_info[MQTT_DEVICE_CONNECTIONS][0][1] = mac;
},
this->qos_, discovery_info.retain);
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
uint8_t MQTTComponent::get_qos() const { return this->qos_; }

View File

@ -67,6 +67,7 @@ void MQTTCoverComponent::dump_config() {
}
}
void MQTTCoverComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (!this->cover_->get_device_class().empty())
root[MQTT_DEVICE_CLASS] = this->cover_->get_device_class();

View File

@ -20,13 +20,13 @@ MQTTDateComponent::MQTTDateComponent(DateEntity *date) : date_(date) {}
void MQTTDateComponent::setup() {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
auto call = this->date_->make_call();
if (root.containsKey("year")) {
if (root["year"].is<uint16_t>()) {
call.set_year(root["year"]);
}
if (root.containsKey("month")) {
if (root["month"].is<uint8_t>()) {
call.set_month(root["month"]);
}
if (root.containsKey("day")) {
if (root["day"].is<uint8_t>()) {
call.set_day(root["day"]);
}
call.perform();
@ -55,6 +55,7 @@ bool MQTTDateComponent::send_initial_state() {
}
bool MQTTDateComponent::publish_state(uint16_t year, uint8_t month, uint8_t day) {
return this->publish_json(this->get_state_topic_(), [year, month, day](JsonObject root) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root["year"] = year;
root["month"] = month;
root["day"] = day;

View File

@ -20,22 +20,22 @@ MQTTDateTimeComponent::MQTTDateTimeComponent(DateTimeEntity *datetime) : datetim
void MQTTDateTimeComponent::setup() {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
auto call = this->datetime_->make_call();
if (root.containsKey("year")) {
if (root["year"].is<uint16_t>()) {
call.set_year(root["year"]);
}
if (root.containsKey("month")) {
if (root["month"].is<uint8_t>()) {
call.set_month(root["month"]);
}
if (root.containsKey("day")) {
if (root["day"].is<uint8_t>()) {
call.set_day(root["day"]);
}
if (root.containsKey("hour")) {
if (root["hour"].is<uint8_t>()) {
call.set_hour(root["hour"]);
}
if (root.containsKey("minute")) {
if (root["minute"].is<uint8_t>()) {
call.set_minute(root["minute"]);
}
if (root.containsKey("second")) {
if (root["second"].is<uint8_t>()) {
call.set_second(root["second"]);
}
call.perform();
@ -68,6 +68,7 @@ bool MQTTDateTimeComponent::send_initial_state() {
bool MQTTDateTimeComponent::publish_state(uint16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute,
uint8_t second) {
return this->publish_json(this->get_state_topic_(), [year, month, day, hour, minute, second](JsonObject root) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root["year"] = year;
root["month"] = month;
root["day"] = day;

View File

@ -16,7 +16,8 @@ using namespace esphome::event;
MQTTEventComponent::MQTTEventComponent(event::Event *event) : event_(event) {}
void MQTTEventComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
JsonArray event_types = root.createNestedArray(MQTT_EVENT_TYPES);
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
JsonArray event_types = root[MQTT_EVENT_TYPES].to<JsonArray>();
for (const auto &event_type : this->event_->get_event_types())
event_types.add(event_type);
@ -40,8 +41,10 @@ void MQTTEventComponent::dump_config() {
}
bool MQTTEventComponent::publish_event_(const std::string &event_type) {
return this->publish_json(this->get_state_topic_(),
[event_type](JsonObject root) { root[MQTT_EVENT_TYPE] = event_type; });
return this->publish_json(this->get_state_topic_(), [event_type](JsonObject root) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root[MQTT_EVENT_TYPE] = event_type;
});
}
std::string MQTTEventComponent::component_type() const { return "event"; }

View File

@ -143,6 +143,7 @@ void MQTTFanComponent::dump_config() {
bool MQTTFanComponent::send_initial_state() { return this->publish_state(); }
void MQTTFanComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (this->state_->get_traits().supports_direction()) {
root[MQTT_DIRECTION_COMMAND_TOPIC] = this->get_direction_command_topic();
root[MQTT_DIRECTION_STATE_TOPIC] = this->get_direction_state_topic();

View File

@ -32,17 +32,21 @@ void MQTTJSONLightComponent::setup() {
MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {}
bool MQTTJSONLightComponent::publish_state_() {
return this->publish_json(this->get_state_topic_(),
[this](JsonObject root) { LightJSONSchema::dump_json(*this->state_, root); });
return this->publish_json(this->get_state_topic_(), [this](JsonObject root) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
LightJSONSchema::dump_json(*this->state_, root);
});
}
LightState *MQTTJSONLightComponent::get_state() const { return this->state_; }
void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root["schema"] = "json";
auto traits = this->state_->get_traits();
root[MQTT_COLOR_MODE] = true;
JsonArray color_modes = root.createNestedArray("supported_color_modes");
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
JsonArray color_modes = root["supported_color_modes"].to<JsonArray>();
if (traits.supports_color_mode(ColorMode::ON_OFF))
color_modes.add("onoff");
if (traits.supports_color_mode(ColorMode::BRIGHTNESS))
@ -67,7 +71,7 @@ void MQTTJSONLightComponent::send_discovery(JsonObject root, mqtt::SendDiscovery
if (this->state_->supports_effects()) {
root["effect"] = true;
JsonArray effect_list = root.createNestedArray(MQTT_EFFECT_LIST);
JsonArray effect_list = root[MQTT_EFFECT_LIST].to<JsonArray>();
for (auto *effect : this->state_->get_effects())
effect_list.add(effect->get_name());
effect_list.add("None");

View File

@ -38,8 +38,10 @@ void MQTTLockComponent::dump_config() {
std::string MQTTLockComponent::component_type() const { return "lock"; }
const EntityBase *MQTTLockComponent::get_entity() const { return this->lock_; }
void MQTTLockComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
if (this->lock_->traits.get_assumed_state())
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (this->lock_->traits.get_assumed_state()) {
root[MQTT_OPTIMISTIC] = true;
}
if (this->lock_->traits.get_supports_open())
root[MQTT_PAYLOAD_OPEN] = "OPEN";
}

View File

@ -40,6 +40,7 @@ const EntityBase *MQTTNumberComponent::get_entity() const { return this->number_
void MQTTNumberComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
const auto &traits = number_->traits;
// https://www.home-assistant.io/integrations/number.mqtt/
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root[MQTT_MIN] = traits.get_min_value();
root[MQTT_MAX] = traits.get_max_value();
root[MQTT_STEP] = traits.get_step();

View File

@ -35,7 +35,8 @@ const EntityBase *MQTTSelectComponent::get_entity() const { return this->select_
void MQTTSelectComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
const auto &traits = select_->traits;
// https://www.home-assistant.io/integrations/select.mqtt/
JsonArray options = root.createNestedArray(MQTT_OPTIONS);
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
JsonArray options = root[MQTT_OPTIONS].to<JsonArray>();
for (const auto &option : traits.get_options())
options.add(option);

View File

@ -44,8 +44,10 @@ void MQTTSensorComponent::set_expire_after(uint32_t expire_after) { this->expire
void MQTTSensorComponent::disable_expire_after() { this->expire_after_ = 0; }
void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
if (!this->sensor_->get_device_class().empty())
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (!this->sensor_->get_device_class().empty()) {
root[MQTT_DEVICE_CLASS] = this->sensor_->get_device_class();
}
if (!this->sensor_->get_unit_of_measurement().empty())
root[MQTT_UNIT_OF_MEASUREMENT] = this->sensor_->get_unit_of_measurement();

View File

@ -45,9 +45,11 @@ void MQTTSwitchComponent::dump_config() {
std::string MQTTSwitchComponent::component_type() const { return "switch"; }
const EntityBase *MQTTSwitchComponent::get_entity() const { return this->switch_; }
void MQTTSwitchComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
if (this->switch_->assumed_state())
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (this->switch_->assumed_state()) {
root[MQTT_OPTIMISTIC] = true;
}
}
bool MQTTSwitchComponent::send_initial_state() { return this->publish_state(this->switch_->state); }
bool MQTTSwitchComponent::publish_state(bool state) {

View File

@ -34,6 +34,7 @@ std::string MQTTTextComponent::component_type() const { return "text"; }
const EntityBase *MQTTTextComponent::get_entity() const { return this->text_; }
void MQTTTextComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
switch (this->text_->traits.get_mode()) {
case TEXT_MODE_TEXT:
root[MQTT_MODE] = "text";

View File

@ -15,8 +15,10 @@ using namespace esphome::text_sensor;
MQTTTextSensor::MQTTTextSensor(TextSensor *sensor) : sensor_(sensor) {}
void MQTTTextSensor::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
if (!this->sensor_->get_device_class().empty())
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (!this->sensor_->get_device_class().empty()) {
root[MQTT_DEVICE_CLASS] = this->sensor_->get_device_class();
}
config.command_topic = false;
}
void MQTTTextSensor::setup() {

View File

@ -20,13 +20,13 @@ MQTTTimeComponent::MQTTTimeComponent(TimeEntity *time) : time_(time) {}
void MQTTTimeComponent::setup() {
this->subscribe_json(this->get_command_topic_(), [this](const std::string &topic, JsonObject root) {
auto call = this->time_->make_call();
if (root.containsKey("hour")) {
if (root["hour"].is<uint8_t>()) {
call.set_hour(root["hour"]);
}
if (root.containsKey("minute")) {
if (root["minute"].is<uint8_t>()) {
call.set_minute(root["minute"]);
}
if (root.containsKey("second")) {
if (root["second"].is<uint8_t>()) {
call.set_second(root["second"]);
}
call.perform();
@ -55,6 +55,7 @@ bool MQTTTimeComponent::send_initial_state() {
}
bool MQTTTimeComponent::publish_state(uint8_t hour, uint8_t minute, uint8_t second) {
return this->publish_json(this->get_state_topic_(), [hour, minute, second](JsonObject root) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root["hour"] = hour;
root["minute"] = minute;
root["second"] = second;

View File

@ -41,6 +41,7 @@ bool MQTTUpdateComponent::publish_state() {
}
void MQTTUpdateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
root["schema"] = "json";
root[MQTT_PAYLOAD_INSTALL] = "INSTALL";
}

View File

@ -49,8 +49,10 @@ void MQTTValveComponent::dump_config() {
}
}
void MQTTValveComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) {
if (!this->valve_->get_device_class().empty())
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
if (!this->valve_->get_device_class().empty()) {
root[MQTT_DEVICE_CLASS] = this->valve_->get_device_class();
}
auto traits = this->valve_->get_traits();
if (traits.get_is_assumed_state()) {

View File

@ -356,7 +356,7 @@ void MS8607Component::read_humidity_(float temperature_float) {
// map 16 bit humidity value into range [-6%, 118%]
float const humidity_partial = double(humidity) / (1 << 16);
float const humidity_percentage = lerp(humidity_partial, -6.0, 118.0);
float const humidity_percentage = std::lerp(-6.0, 118.0, humidity_partial);
float const compensated_humidity_percentage =
humidity_percentage + (20 - temperature_float) * MS8607_H_TEMP_COEFFICIENT;
ESP_LOGD(TAG, "Compensated for temperature, humidity=%.2f%%", compensated_humidity_percentage);

View File

@ -2,7 +2,7 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_REQUEST_HEADERS
from esphome.components.const import CONF_BYTE_ORDER, CONF_REQUEST_HEADERS
from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent
from esphome.components.image import (
CONF_INVERT_ALPHA,
@ -11,6 +11,7 @@ from esphome.components.image import (
Image_,
get_image_type_enum,
get_transparency_enum,
validate_settings,
)
import esphome.config_validation as cv
from esphome.const import (
@ -161,6 +162,7 @@ CONFIG_SCHEMA = cv.Schema(
rp2040_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
),
validate_settings,
)
)
@ -213,6 +215,7 @@ async def to_code(config):
get_image_type_enum(config[CONF_TYPE]),
transparent,
config[CONF_BUFFER_SIZE],
config.get(CONF_BYTE_ORDER) != "LITTLE_ENDIAN",
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID])

View File

@ -35,14 +35,15 @@ inline bool is_color_on(const Color &color) {
}
OnlineImage::OnlineImage(const std::string &url, int width, int height, ImageFormat format, ImageType type,
image::Transparency transparency, uint32_t download_buffer_size)
image::Transparency transparency, uint32_t download_buffer_size, bool is_big_endian)
: Image(nullptr, 0, 0, type, transparency),
buffer_(nullptr),
download_buffer_(download_buffer_size),
download_buffer_initial_size_(download_buffer_size),
format_(format),
fixed_width_(width),
fixed_height_(height) {
fixed_height_(height),
is_big_endian_(is_big_endian) {
this->set_url(url);
}
@ -296,7 +297,7 @@ void OnlineImage::draw_pixel_(int x, int y, Color color) {
break;
}
case ImageType::IMAGE_TYPE_GRAYSCALE: {
uint8_t gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
@ -314,8 +315,13 @@ void OnlineImage::draw_pixel_(int x, int y, Color color) {
case ImageType::IMAGE_TYPE_RGB565: {
this->map_chroma_key(color);
uint16_t col565 = display::ColorUtil::color_to_565(color);
if (this->is_big_endian_) {
this->buffer_[pos + 0] = static_cast<uint8_t>((col565 >> 8) & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>(col565 & 0xFF);
} else {
this->buffer_[pos + 0] = static_cast<uint8_t>(col565 & 0xFF);
this->buffer_[pos + 1] = static_cast<uint8_t>((col565 >> 8) & 0xFF);
}
if (this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
this->buffer_[pos + 2] = color.w;
}

View File

@ -50,7 +50,7 @@ class OnlineImage : public PollingComponent,
* @param buffer_size Size of the buffer used to download the image.
*/
OnlineImage(const std::string &url, int width, int height, ImageFormat format, image::ImageType type,
image::Transparency transparency, uint32_t buffer_size);
image::Transparency transparency, uint32_t buffer_size, bool is_big_endian);
void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override;
@ -164,6 +164,11 @@ class OnlineImage : public PollingComponent,
const int fixed_width_;
/** height requested on configuration, or 0 if non specified. */
const int fixed_height_;
/**
* Whether the image is stored in big-endian format.
* This is used to determine how to store 16 bit colors in the buffer.
*/
bool is_big_endian_;
/**
* Actual width of the current image. If fixed_width_ is specified,
* this will be equal to it; otherwise it will be set once the decoding

View File

@ -10,7 +10,7 @@ void opentherm::OpenthermOutput::write_state(float state) {
ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_);
this->state = state < 0.003 && this->zero_means_zero_
? 0.0
: clamp(lerp(state, min_value_, max_value_), min_value_, max_value_);
: clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_);
this->has_state_ = true;
ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state);
}

View File

@ -88,9 +88,9 @@ void Servo::internal_write(float value) {
value = clamp(value, -1.0f, 1.0f);
float level;
if (value < 0.0) {
level = lerp(-value, this->idle_level_, this->min_level_);
level = std::lerp(this->idle_level_, this->min_level_, -value);
} else {
level = lerp(value, this->idle_level_, this->max_level_);
level = std::lerp(this->idle_level_, this->max_level_, value);
}
this->output_->set_level(level);
this->current_value_ = value;

View File

@ -792,7 +792,7 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi
light::LightJSONSchema::dump_json(*obj, root);
if (start_config == DETAIL_ALL) {
JsonArray opt = root.createNestedArray("effects");
JsonArray opt = root["effects"].to<JsonArray>();
opt.add("None");
for (auto const &option : obj->get_effects()) {
opt.add(option->get_name());
@ -1238,7 +1238,7 @@ std::string WebServer::select_json(select::Select *obj, const std::string &value
return json::build_json([this, obj, value, start_config](JsonObject root) {
set_json_icon_state_value(root, obj, "select-" + obj->get_object_id(), value, value, start_config);
if (start_config == DETAIL_ALL) {
JsonArray opt = root.createNestedArray("option");
JsonArray opt = root["option"].to<JsonArray>();
for (auto &option : obj->traits.get_options()) {
opt.add(option);
}
@ -1322,6 +1322,7 @@ std::string WebServer::climate_all_json_generator(WebServer *web_server, void *s
return web_server->climate_json((climate::Climate *) (source), DETAIL_ALL);
}
std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_config) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
return json::build_json([this, obj, start_config](JsonObject root) {
set_json_id(root, obj, "climate-" + obj->get_object_id(), start_config);
const auto traits = obj->get_traits();
@ -1330,32 +1331,32 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf
char buf[16];
if (start_config == DETAIL_ALL) {
JsonArray opt = root.createNestedArray("modes");
JsonArray opt = root["modes"].to<JsonArray>();
for (climate::ClimateMode m : traits.get_supported_modes())
opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m)));
if (!traits.get_supported_custom_fan_modes().empty()) {
JsonArray opt = root.createNestedArray("fan_modes");
JsonArray opt = root["fan_modes"].to<JsonArray>();
for (climate::ClimateFanMode m : traits.get_supported_fan_modes())
opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m)));
}
if (!traits.get_supported_custom_fan_modes().empty()) {
JsonArray opt = root.createNestedArray("custom_fan_modes");
JsonArray opt = root["custom_fan_modes"].to<JsonArray>();
for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes())
opt.add(custom_fan_mode);
}
if (traits.get_supports_swing_modes()) {
JsonArray opt = root.createNestedArray("swing_modes");
JsonArray opt = root["swing_modes"].to<JsonArray>();
for (auto swing_mode : traits.get_supported_swing_modes())
opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode)));
}
if (traits.get_supports_presets() && obj->preset.has_value()) {
JsonArray opt = root.createNestedArray("presets");
JsonArray opt = root["presets"].to<JsonArray>();
for (climate::ClimatePreset m : traits.get_supported_presets())
opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m)));
}
if (!traits.get_supported_custom_presets().empty() && obj->custom_preset.has_value()) {
JsonArray opt = root.createNestedArray("custom_presets");
JsonArray opt = root["custom_presets"].to<JsonArray>();
for (auto const &custom_preset : traits.get_supported_custom_presets())
opt.add(custom_preset);
}
@ -1407,6 +1408,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf
root["state"] = root["target_temperature"];
}
});
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
#endif
@ -1635,7 +1637,7 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty
root["event_type"] = event_type;
}
if (start_config == DETAIL_ALL) {
JsonArray event_types = root.createNestedArray("event_types");
JsonArray event_types = root["event_types"].to<JsonArray>();
for (auto const &event_type : obj->get_event_types()) {
event_types.add(event_type);
}
@ -1682,6 +1684,7 @@ std::string WebServer::update_all_json_generator(WebServer *web_server, void *so
return web_server->update_json((update::UpdateEntity *) (source), DETAIL_STATE);
}
std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_config) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
return json::build_json([this, obj, start_config](JsonObject root) {
set_json_id(root, obj, "update-" + obj->get_object_id(), start_config);
root["value"] = obj->update_info.latest_version;
@ -1707,6 +1710,7 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c
this->add_sorting_info_(root, obj);
}
});
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
}
#endif

View File

@ -40,4 +40,4 @@ async def to_code(config):
if CORE.is_esp8266:
cg.add_library("ESP8266WiFi", None)
# https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json
cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.8")
cg.add_library("ESP32Async/ESPAsyncWebServer", "3.7.10")

View File

@ -389,10 +389,12 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
#ifdef USE_WEBSERVER_SORTING
for (auto &group : ws->sorting_groups_) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
message = json::build_json([group](JsonObject root) {
root["name"] = group.second.name;
root["sorting_weight"] = group.second.weight;
});
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
// a (very) large number of these should be able to be queued initially without defer
// since the only thing in the send buffer at this point is the initial ping/config

View File

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

View File

@ -264,6 +264,7 @@ void Component::set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std:
bool Component::is_failed() const { return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED; }
bool Component::is_ready() const {
return (this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP ||
(this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE ||
(this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_SETUP;
}
bool Component::can_proceed() { return true; }

View File

@ -78,6 +78,8 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ.setdefault(
"PLATFORMIO_LIBDEPS_DIR", os.path.abspath(CORE.relative_piolibdeps_path())
)
# Suppress Python syntax warnings from third-party scripts during compilation
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
cmd = ["platformio"] + list(args)
if not CORE.verbose:

View File

@ -162,6 +162,9 @@ def get_ini_content():
# Sort to avoid changing build unflags order
CORE.add_platformio_option("build_unflags", sorted(CORE.build_unflags))
# Add extra script for C++ flags
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
content = "[platformio]\n"
content += f"description = ESPHome {__version__}\n"
@ -222,6 +225,9 @@ def write_platformio_project():
write_gitignore()
write_platformio_ini(content)
# Write extra script for C++ specific flags
write_cxx_flags_script()
DEFINES_H_FORMAT = ESPHOME_H_FORMAT = """\
#pragma once
@ -394,3 +400,20 @@ def write_gitignore():
if not os.path.isfile(path):
with open(file=path, mode="w", encoding="utf-8") as f:
f.write(GITIGNORE_CONTENT)
CXX_FLAGS_FILE_NAME = "cxx_flags.py"
CXX_FLAGS_FILE_CONTENTS = """# Auto-generated ESPHome script for C++ specific compiler flags
Import("env")
# Add C++ specific flags
"""
def write_cxx_flags_script() -> None:
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
contents = CXX_FLAGS_FILE_CONTENTS
if not CORE.is_host:
contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
contents += "\n"
write_file_if_changed(path, contents)

View File

@ -35,7 +35,7 @@ build_flags =
lib_deps =
esphome/noise-c@0.1.10 ; api
improv/Improv@1.2.4 ; improv_serial / esp32_improv
bblanchon/ArduinoJson@6.18.5 ; json
bblanchon/ArduinoJson@7.4.2 ; json
wjtje/qr-code-generator-library@1.7.0 ; qr_code
functionpointer/arduino-MLX90393@1.0.2 ; mlx90393
pavlodn/HaierProtocol@0.9.31 ; haier
@ -235,7 +235,7 @@ build_flags =
-DUSE_ZEPHYR
-DUSE_NRF52
lib_deps =
bblanchon/ArduinoJson@7.0.0 ; json
bblanchon/ArduinoJson@7.4.2 ; json
wjtje/qr-code-generator-library@1.7.0 ; qr_code
pavlodn/HaierProtocol@0.9.31 ; haier
functionpointer/arduino-MLX90393@1.0.2 ; mlx90393

View File

@ -0,0 +1 @@
<<: !include common.yaml

View File

@ -1,7 +1,7 @@
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COMPONENTS, CONF_ID, CONF_NAME
from esphome.const import CONF_COMPONENTS, CONF_ID, CONF_NAME, CONF_UPDATE_INTERVAL
CODEOWNERS = ["@esphome/tests"]
@ -10,10 +10,15 @@ LoopTestComponent = loop_test_component_ns.class_("LoopTestComponent", cg.Compon
LoopTestISRComponent = loop_test_component_ns.class_(
"LoopTestISRComponent", cg.Component
)
LoopTestUpdateComponent = loop_test_component_ns.class_(
"LoopTestUpdateComponent", cg.PollingComponent
)
CONF_DISABLE_AFTER = "disable_after"
CONF_TEST_REDUNDANT_OPERATIONS = "test_redundant_operations"
CONF_ISR_COMPONENTS = "isr_components"
CONF_UPDATE_COMPONENTS = "update_components"
CONF_DISABLE_LOOP_AFTER = "disable_loop_after"
COMPONENT_CONFIG_SCHEMA = cv.Schema(
{
@ -31,11 +36,23 @@ ISR_COMPONENT_CONFIG_SCHEMA = cv.Schema(
}
)
UPDATE_COMPONENT_CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestUpdateComponent),
cv.Required(CONF_NAME): cv.string,
cv.Optional(CONF_DISABLE_LOOP_AFTER, default=0): cv.int_,
cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval,
}
)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LoopTestComponent),
cv.Required(CONF_COMPONENTS): cv.ensure_list(COMPONENT_CONFIG_SCHEMA),
cv.Optional(CONF_ISR_COMPONENTS): cv.ensure_list(ISR_COMPONENT_CONFIG_SCHEMA),
cv.Optional(CONF_UPDATE_COMPONENTS): cv.ensure_list(
UPDATE_COMPONENT_CONFIG_SCHEMA
),
}
).extend(cv.COMPONENT_SCHEMA)
@ -94,3 +111,12 @@ async def to_code(config):
var = cg.new_Pvariable(isr_config[CONF_ID])
await cg.register_component(var, isr_config)
cg.add(var.set_name(isr_config[CONF_NAME]))
# Create update test components
for update_config in config.get(CONF_UPDATE_COMPONENTS, []):
var = cg.new_Pvariable(update_config[CONF_ID])
await cg.register_component(var, update_config)
cg.add(var.set_name(update_config[CONF_NAME]))
cg.add(var.set_disable_loop_after(update_config[CONF_DISABLE_LOOP_AFTER]))
cg.add(var.set_update_interval(update_config[CONF_UPDATE_INTERVAL]))

View File

@ -39,5 +39,29 @@ void LoopTestComponent::service_disable() {
this->disable_loop();
}
// LoopTestUpdateComponent implementation
void LoopTestUpdateComponent::setup() {
ESP_LOGI(TAG, "[%s] LoopTestUpdateComponent setup called", this->name_.c_str());
}
void LoopTestUpdateComponent::loop() {
this->loop_count_++;
ESP_LOGI(TAG, "[%s] LoopTestUpdateComponent loop count: %d", this->name_.c_str(), this->loop_count_);
// Disable loop after specified count to test component.update when loop is disabled
if (this->disable_loop_after_ > 0 && this->loop_count_ == this->disable_loop_after_) {
ESP_LOGI(TAG, "[%s] Disabling loop after %d iterations", this->name_.c_str(), this->disable_loop_after_);
this->disable_loop();
}
}
void LoopTestUpdateComponent::update() {
this->update_count_++;
// Check if loop is disabled by testing component state
bool loop_disabled = this->component_state_ == COMPONENT_STATE_LOOP_DONE;
ESP_LOGI(TAG, "[%s] LoopTestUpdateComponent update() called, count: %d, loop_disabled: %s", this->name_.c_str(),
this->update_count_, loop_disabled ? "YES" : "NO");
}
} // namespace loop_test_component
} // namespace esphome

View File

@ -4,6 +4,7 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
namespace esphome {
namespace loop_test_component {
@ -54,5 +55,29 @@ template<typename... Ts> class DisableAction : public Action<Ts...> {
LoopTestComponent *parent_;
};
// Component with update() method to test component.update action
class LoopTestUpdateComponent : public PollingComponent {
public:
LoopTestUpdateComponent() : PollingComponent(1000) {} // Default 1s update interval
void set_name(const std::string &name) { this->name_ = name; }
void set_disable_loop_after(int count) { this->disable_loop_after_ = count; }
void setup() override;
void loop() override;
void update() override;
int get_update_count() const { return this->update_count_; }
int get_loop_count() const { return this->loop_count_; }
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
std::string name_;
int loop_count_{0};
int update_count_{0};
int disable_loop_after_{0};
};
} // namespace loop_test_component
} // namespace esphome

View File

@ -40,6 +40,13 @@ loop_test_component:
- id: isr_test
name: "isr_test"
# Update test component to test component.update when loop is disabled
update_components:
- id: update_test_component
name: "update_test"
disable_loop_after: 3 # Disable loop after 3 iterations
update_interval: 0.1s # Fast update interval for testing
# Interval to re-enable the self_disable_10 component after some time
interval:
- interval: 0.5s
@ -51,3 +58,28 @@ interval:
- logger.log: "Re-enabling self_disable_10 via service"
- loop_test_component.enable:
id: self_disable_10
# Test component.update on a component with disabled loop
- interval: 0.1s
then:
- lambda: |-
static bool manual_update_done = false;
if (!manual_update_done &&
id(update_test_component).get_loop_count() == 3 &&
id(update_test_component).get_update_count() >= 3) {
ESP_LOGI("main", "Manually calling component.update on update_test_component with disabled loop");
manual_update_done = true;
}
- if:
condition:
lambda: |-
static bool manual_update_triggered = false;
if (!manual_update_triggered &&
id(update_test_component).get_loop_count() == 3 &&
id(update_test_component).get_update_count() >= 3) {
manual_update_triggered = true;
return true;
}
return false;
then:
- component.update: update_test_component

View File

@ -45,11 +45,18 @@ async def test_loop_disable_enable(
isr_component_disabled = asyncio.Event()
isr_component_re_enabled = asyncio.Event()
isr_component_pure_re_enabled = asyncio.Event()
# Events for update component testing
update_component_loop_disabled = asyncio.Event()
update_component_manual_update_called = asyncio.Event()
# Track loop counts for components
self_disable_10_counts: list[int] = []
normal_component_counts: list[int] = []
isr_component_counts: list[int] = []
# Track update component behavior
update_component_loop_count = 0
update_component_update_count = 0
update_component_manual_update_count = 0
def on_log_line(line: str) -> None:
"""Process each log line from the process output."""
@ -59,6 +66,7 @@ async def test_loop_disable_enable(
if (
"loop_test_component" not in clean_line
and "loop_test_isr_component" not in clean_line
and "Manually calling component.update" not in clean_line
):
return
@ -112,6 +120,23 @@ async def test_loop_disable_enable(
elif "Running after pure ISR re-enable!" in clean_line:
isr_component_pure_re_enabled.set()
# Update component events
elif "[update_test]" in clean_line:
if "LoopTestUpdateComponent loop count:" in clean_line:
nonlocal update_component_loop_count
update_component_loop_count = int(
clean_line.split("LoopTestUpdateComponent loop count: ")[1]
)
elif "LoopTestUpdateComponent update() called" in clean_line:
nonlocal update_component_update_count
update_component_update_count += 1
if "Manually calling component.update" in " ".join(log_messages[-5:]):
nonlocal update_component_manual_update_count
update_component_manual_update_count += 1
update_component_manual_update_called.set()
elif "Disabling loop after" in clean_line:
update_component_loop_disabled.set()
# Write, compile and run the ESPHome device with log callback
async with (
run_compiled(yaml_config, line_callback=on_log_line),
@ -205,3 +230,28 @@ async def test_loop_disable_enable(
assert final_count > 10, (
f"Component didn't run after pure ISR enable: got {final_count} counts total"
)
# Test component.update functionality when loop is disabled
# Wait for update component to disable its loop
try:
await asyncio.wait_for(update_component_loop_disabled.wait(), timeout=3.0)
except asyncio.TimeoutError:
pytest.fail("Update component did not disable its loop within 3 seconds")
# Verify it ran exactly 3 loops before disabling
assert update_component_loop_count == 3, (
f"Expected 3 loop iterations before disable, got {update_component_loop_count}"
)
# Wait for manual component.update to be called
try:
await asyncio.wait_for(
update_component_manual_update_called.wait(), timeout=5.0
)
except asyncio.TimeoutError:
pytest.fail("Manual component.update was not called within 5 seconds")
# The key test: verify that manual component.update worked after loop was disabled
assert update_component_manual_update_count >= 1, (
"component.update did not fire after loop was disabled"
)