mirror of
https://github.com/esphome/esphome.git
synced 2026-09-17 01:58:39 +00:00
Merge remote-tracking branch 'upstream/dev' into app-loop-optimize-speed
# Conflicts: # esphome/core/application.h
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import add_idf_component, include_builtin_idf_component
|
||||
from esphome.components.esp32 import (
|
||||
add_idf_component,
|
||||
add_idf_sdkconfig_option,
|
||||
include_builtin_idf_component,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
|
||||
from esphome.core import CORE
|
||||
@@ -27,6 +31,7 @@ class AudioData:
|
||||
flac_support: bool = False
|
||||
mp3_support: bool = False
|
||||
opus_support: bool = False
|
||||
micro_decoder_support: bool = False
|
||||
|
||||
|
||||
def _get_data() -> AudioData:
|
||||
@@ -50,6 +55,11 @@ def request_opus_support() -> None:
|
||||
_get_data().opus_support = True
|
||||
|
||||
|
||||
def request_micro_decoder_support() -> None:
|
||||
"""Request micro-decoder library support for audio decoding."""
|
||||
_get_data().micro_decoder_support = True
|
||||
|
||||
|
||||
CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample"
|
||||
CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample"
|
||||
CONF_MIN_CHANNELS = "min_channels"
|
||||
@@ -208,6 +218,19 @@ async def to_code(config):
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
|
||||
if data.micro_decoder_support:
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.1.1")
|
||||
|
||||
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
|
||||
if not data.flac_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_FLAC", False)
|
||||
if not data.mp3_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
|
||||
if not data.opus_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
|
||||
|
||||
# Legacy audio_decoder.cpp support defines and components
|
||||
if data.flac_support:
|
||||
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-flac", ref="0.1.1")
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <nvs_flash.h>
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
@@ -12,9 +11,6 @@ namespace esphome::esp32 {
|
||||
|
||||
static const char *const TAG = "preferences";
|
||||
|
||||
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
|
||||
static constexpr size_t KEY_BUFFER_SIZE = 12;
|
||||
|
||||
struct NVSData {
|
||||
uint32_t key;
|
||||
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
|
||||
@@ -51,8 +47,8 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) {
|
||||
}
|
||||
}
|
||||
|
||||
char key_str[KEY_BUFFER_SIZE];
|
||||
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
|
||||
char key_str[UINT32_MAX_STR_SIZE];
|
||||
uint32_to_str(key_str, this->key);
|
||||
size_t actual_len;
|
||||
esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len);
|
||||
if (err != 0) {
|
||||
@@ -108,8 +104,8 @@ bool ESP32Preferences::sync() {
|
||||
uint32_t last_key = 0;
|
||||
|
||||
for (const auto &save : s_pending_save) {
|
||||
char key_str[KEY_BUFFER_SIZE];
|
||||
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
|
||||
char key_str[UINT32_MAX_STR_SIZE];
|
||||
uint32_to_str(key_str, save.key);
|
||||
ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str);
|
||||
if (this->is_changed_(this->nvs_handle, save, key_str)) {
|
||||
esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size());
|
||||
|
||||
@@ -78,6 +78,14 @@ def ota_esphome_final_validate(config):
|
||||
else:
|
||||
new_ota_conf.append(ota_conf)
|
||||
|
||||
if len(merged_ota_esphome_configs_by_port) > 1:
|
||||
raise cv.Invalid(
|
||||
f"Only a single port is supported for '{CONF_OTA}' "
|
||||
f"'{CONF_PLATFORM}: {CONF_ESPHOME}'. Got ports "
|
||||
f"{sorted(merged_ota_esphome_configs_by_port.keys())}. Consolidate "
|
||||
f"onto a single port; configs sharing a port are merged automatically."
|
||||
)
|
||||
|
||||
new_ota_conf.extend(merged_ota_esphome_configs_by_port.values())
|
||||
|
||||
full_conf[CONF_OTA] = new_ota_conf
|
||||
@@ -147,6 +155,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
|
||||
cg.add_define("USE_OTA_PASSWORD")
|
||||
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||
|
||||
await cg.register_component(var, config)
|
||||
await ota_to_code(var, config)
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/util.h"
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
#include "esphome/core/lwip_fast_select.h"
|
||||
#endif
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
@@ -28,6 +31,17 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
|
||||
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
|
||||
|
||||
// Single-instance pointer — multi-port configs are rejected in final_validate.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static ESPHomeOTAComponent *global_esphome_ota_component = nullptr;
|
||||
|
||||
// Called from any context (LwIP TCP/IP task, RP2040 user-IRQ).
|
||||
extern "C" void esphome_wake_ota_component_any_context() {
|
||||
if (global_esphome_ota_component != nullptr) {
|
||||
global_esphome_ota_component->enable_loop_soon_any_context();
|
||||
}
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::setup() {
|
||||
this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
|
||||
if (this->server_ == nullptr) {
|
||||
@@ -65,6 +79,14 @@ void ESPHomeOTAComponent::setup() {
|
||||
this->server_failed_(LOG_STR("listen"));
|
||||
return;
|
||||
}
|
||||
|
||||
// loop() self-disables on its first idle tick; no explicit disable_loop() needed here.
|
||||
global_esphome_ota_component = this;
|
||||
#ifdef USE_LWIP_FAST_SELECT
|
||||
// Filter fast-select wakes to this listener only. If the sock lookup returns nullptr,
|
||||
// no wakes fire and loop() falls back to the self-disable safety net.
|
||||
esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd()));
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::dump_config() {
|
||||
@@ -81,13 +103,15 @@ void ESPHomeOTAComponent::dump_config() {
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::loop() {
|
||||
// Skip handle_handshake_() call if no client connected and no incoming connections
|
||||
// This optimization reduces idle loop overhead when OTA is not active
|
||||
// Note: No need to check server_ for null as the component is marked failed in setup()
|
||||
// if server_ creation fails
|
||||
if (this->client_ != nullptr || this->server_->ready()) {
|
||||
this->handle_handshake_();
|
||||
// Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select
|
||||
// listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back
|
||||
// to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more
|
||||
// iteration so a connection queued mid-session is still caught here.
|
||||
if (this->client_ == nullptr && !this->server_->ready()) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
this->handle_handshake_();
|
||||
}
|
||||
|
||||
static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
@@ -566,6 +590,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
this->cleanup_auth_();
|
||||
#endif
|
||||
// Intentionally no disable_loop() — letting loop() run one more iteration catches
|
||||
// any connection that queued on the listener mid-session (otherwise the wake flag,
|
||||
// set while we were in LOOP state, would be lost to enable_pending_loops_()).
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::yield_and_feed_watchdog_() {
|
||||
|
||||
@@ -108,8 +108,13 @@ async def globals_set_to_code(config, action_id, template_arg, args):
|
||||
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
|
||||
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
# Use the global's value_type alias as the lambda return type so
|
||||
# TemplatableFn stores a direct function pointer instead of going through
|
||||
# the deprecated converting trampoline when the value expression deduces
|
||||
# to a different type (e.g. int literal assigned to a float global).
|
||||
value_type = cg.RawExpression(f"{full_id.type}::value_type")
|
||||
templ = await cg.templatable(
|
||||
config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True
|
||||
config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression
|
||||
)
|
||||
cg.add(var.set_value(templ))
|
||||
return var
|
||||
|
||||
@@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_(
|
||||
)
|
||||
|
||||
INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32]
|
||||
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3]
|
||||
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4]
|
||||
|
||||
|
||||
def _validate_esp32_variant(config):
|
||||
|
||||
@@ -360,8 +360,8 @@ void LD2410Component::handle_periodic_data_() {
|
||||
*/
|
||||
#ifdef USE_SENSOR
|
||||
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
|
||||
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
|
||||
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
|
||||
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
|
||||
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
|
||||
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
|
||||
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
|
||||
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
|
||||
@@ -375,26 +375,26 @@ void LD2410Component::handle_periodic_data_() {
|
||||
Moving energy: 20~28th bytes
|
||||
*/
|
||||
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
|
||||
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
|
||||
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
|
||||
}
|
||||
/*
|
||||
Still energy: 29~37th bytes
|
||||
*/
|
||||
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
|
||||
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
|
||||
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
|
||||
}
|
||||
/*
|
||||
Light sensor: 38th bytes
|
||||
*/
|
||||
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
|
||||
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
|
||||
} else {
|
||||
for (auto &gate_move_sensor : this->gate_move_sensors_) {
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
|
||||
}
|
||||
for (auto &gate_still_sensor : this->gate_still_sensors_) {
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
|
||||
}
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -786,13 +786,12 @@ void LD2410Component::set_light_out_control() {
|
||||
}
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
|
||||
void LD2410Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
|
||||
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
|
||||
this->gate_move_sensors_[gate].set_sensor(s);
|
||||
}
|
||||
|
||||
void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
|
||||
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
|
||||
this->gate_still_sensors_[gate].set_sensor(s);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice {
|
||||
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -397,12 +397,12 @@ void LD2412Component::handle_periodic_data_() {
|
||||
*/
|
||||
#ifdef USE_SENSOR
|
||||
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
|
||||
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
|
||||
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
|
||||
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
|
||||
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
|
||||
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
|
||||
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]))
|
||||
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY])
|
||||
if (this->detection_distance_sensor_ != nullptr) {
|
||||
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
|
||||
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
|
||||
if (this->detection_distance_sensor_.has_sensor()) {
|
||||
int new_detect_distance = 0;
|
||||
if (target_state != 0x00 && (target_state & MOVE_BITMASK)) {
|
||||
new_detect_distance =
|
||||
@@ -410,7 +410,7 @@ void LD2412Component::handle_periodic_data_() {
|
||||
} else if (target_state != 0x00) {
|
||||
new_detect_distance = encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]);
|
||||
}
|
||||
this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance);
|
||||
this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance);
|
||||
}
|
||||
if (engineering_mode) {
|
||||
// Engineering mode needs at least LIGHT_SENSOR + 1 bytes
|
||||
@@ -423,27 +423,27 @@ void LD2412Component::handle_periodic_data_() {
|
||||
Moving energy: 20~28th bytes
|
||||
*/
|
||||
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
|
||||
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
|
||||
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
|
||||
}
|
||||
/*
|
||||
Still energy: 29~37th bytes
|
||||
*/
|
||||
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
|
||||
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
|
||||
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
|
||||
}
|
||||
/*
|
||||
Light sensor value
|
||||
*/
|
||||
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
|
||||
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
|
||||
}
|
||||
} else {
|
||||
for (auto &gate_move_sensor : this->gate_move_sensors_) {
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
|
||||
}
|
||||
for (auto &gate_still_sensor : this->gate_still_sensors_) {
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
|
||||
}
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
|
||||
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
|
||||
}
|
||||
#endif
|
||||
// the radar module won't tell us when it's done, so we just have to keep polling...
|
||||
@@ -846,12 +846,11 @@ void LD2412Component::set_light_out_control() {
|
||||
}
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
|
||||
void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
|
||||
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
|
||||
this->gate_move_sensors_[gate].set_sensor(s);
|
||||
}
|
||||
void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
|
||||
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
|
||||
this->gate_still_sensors_[gate].set_sensor(s);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice {
|
||||
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -565,6 +565,7 @@ void LD2450Component::handle_periodic_data_() {
|
||||
SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count);
|
||||
// Moving Target Count
|
||||
SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count);
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
@@ -872,33 +873,32 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU
|
||||
void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); }
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
|
||||
void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) {
|
||||
this->move_x_sensors_[target] = new SensorWithDedup<int16_t>(s);
|
||||
this->move_x_sensors_[target].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) {
|
||||
this->move_y_sensors_[target] = new SensorWithDedup<int16_t>(s);
|
||||
this->move_y_sensors_[target].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) {
|
||||
this->move_speed_sensors_[target] = new SensorWithDedup<int16_t>(s);
|
||||
this->move_speed_sensors_[target].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) {
|
||||
this->move_angle_sensors_[target] = new SensorWithDedup<float>(s);
|
||||
this->move_angle_sensors_[target].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) {
|
||||
this->move_distance_sensors_[target] = new SensorWithDedup<uint16_t>(s);
|
||||
this->move_distance_sensors_[target].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) {
|
||||
this->move_resolution_sensors_[target] = new SensorWithDedup<uint16_t>(s);
|
||||
this->move_resolution_sensors_[target].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
|
||||
this->zone_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
|
||||
this->zone_target_count_sensors_[zone].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
|
||||
this->zone_still_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
|
||||
this->zone_still_target_count_sensors_[zone].set_sensor(s);
|
||||
}
|
||||
void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
|
||||
this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
|
||||
this->zone_moving_target_count_sensors_[zone].set_sensor(s);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
@@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice {
|
||||
ZoneOfNumbers zone_numbers_[MAX_ZONES];
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_x_sensors_{};
|
||||
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_y_sensors_{};
|
||||
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_speed_sensors_{};
|
||||
std::array<SensorWithDedup<float> *, MAX_TARGETS> move_angle_sensors_{};
|
||||
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_distance_sensors_{};
|
||||
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_resolution_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_target_count_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_still_target_count_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_moving_target_count_sensors_{};
|
||||
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_x_sensors_{};
|
||||
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_y_sensors_{};
|
||||
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_speed_sensors_{};
|
||||
std::array<SensorWithDedup<float>, MAX_TARGETS> move_angle_sensors_{};
|
||||
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_distance_sensors_{};
|
||||
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_resolution_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_target_count_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_still_target_count_sensors_{};
|
||||
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_moving_target_count_sensors_{};
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
|
||||
|
||||
@@ -11,28 +11,20 @@
|
||||
|
||||
#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \
|
||||
protected: \
|
||||
ld24xx::SensorWithDedup<dedup_type> *name##_sensor_{nullptr}; \
|
||||
ld24xx::SensorWithDedup<dedup_type> name##_sensor_{}; \
|
||||
\
|
||||
public: \
|
||||
void set_##name##_sensor(sensor::Sensor *sensor) { \
|
||||
this->name##_sensor_ = new ld24xx::SensorWithDedup<dedup_type>(sensor); \
|
||||
}
|
||||
void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); }
|
||||
#endif
|
||||
|
||||
#define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \
|
||||
if ((sensor) != nullptr) { \
|
||||
LOG_SENSOR(tag, name, (sensor)->sens); \
|
||||
if ((sensor).has_sensor()) { \
|
||||
LOG_SENSOR(tag, name, (sensor).get_sensor()); \
|
||||
}
|
||||
|
||||
#define SAFE_PUBLISH_SENSOR(sensor, value) \
|
||||
if ((sensor) != nullptr) { \
|
||||
(sensor)->publish_state_if_not_dup(value); \
|
||||
}
|
||||
#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value)
|
||||
|
||||
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \
|
||||
if ((sensor) != nullptr) { \
|
||||
(sensor)->publish_state_unknown(); \
|
||||
}
|
||||
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) (sensor).publish_state_unknown()
|
||||
|
||||
#define highbyte(val) (uint8_t)((val) >> 8)
|
||||
#define lowbyte(val) (uint8_t)((val) &0xff)
|
||||
@@ -70,25 +62,33 @@ inline void format_version_str(const uint8_t *version, std::span<char, 20> buffe
|
||||
}
|
||||
|
||||
#ifdef USE_SENSOR
|
||||
// Helper class to store a sensor with a deduplicator & publish state only when the value changes
|
||||
/// Sensor with deduplication — sensor may be null, null check is internal.
|
||||
/// Stored inline, no heap allocation. Does nothing when no sensor is set.
|
||||
template<typename T> class SensorWithDedup {
|
||||
public:
|
||||
SensorWithDedup(sensor::Sensor *sens) : sens(sens) {}
|
||||
void set_sensor(sensor::Sensor *sens) {
|
||||
this->sens_ = sens;
|
||||
this->dedup_ = {};
|
||||
}
|
||||
|
||||
void publish_state_if_not_dup(T state) {
|
||||
if (this->publish_dedup.next(state)) {
|
||||
this->sens->publish_state(static_cast<float>(state));
|
||||
if (this->sens_ != nullptr && this->dedup_.next(state)) {
|
||||
this->sens_->publish_state(static_cast<float>(state));
|
||||
}
|
||||
}
|
||||
|
||||
void publish_state_unknown() {
|
||||
if (this->publish_dedup.next_unknown()) {
|
||||
this->sens->publish_state(NAN);
|
||||
if (this->sens_ != nullptr && this->dedup_.next_unknown()) {
|
||||
this->sens_->publish_state(NAN);
|
||||
}
|
||||
}
|
||||
|
||||
sensor::Sensor *sens;
|
||||
Deduplicator<T> publish_dedup;
|
||||
bool has_sensor() const { return this->sens_ != nullptr; }
|
||||
sensor::Sensor *get_sensor() const { return this->sens_; }
|
||||
|
||||
protected:
|
||||
sensor::Sensor *sens_{nullptr};
|
||||
Deduplicator<T> dedup_;
|
||||
};
|
||||
#endif
|
||||
} // namespace esphome::ld24xx
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include "preferences.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
@@ -11,9 +10,6 @@ namespace esphome::libretiny {
|
||||
|
||||
static const char *const TAG = "preferences";
|
||||
|
||||
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
|
||||
static constexpr size_t KEY_BUFFER_SIZE = 12;
|
||||
|
||||
struct NVSData {
|
||||
uint32_t key;
|
||||
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
|
||||
@@ -50,8 +46,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) {
|
||||
}
|
||||
}
|
||||
|
||||
char key_str[KEY_BUFFER_SIZE];
|
||||
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
|
||||
char key_str[UINT32_MAX_STR_SIZE];
|
||||
uint32_to_str(key_str, this->key);
|
||||
fdb_blob_make(this->blob, data, len);
|
||||
size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob);
|
||||
if (actual_len != len) {
|
||||
@@ -92,8 +88,8 @@ bool LibreTinyPreferences::sync() {
|
||||
uint32_t last_key = 0;
|
||||
|
||||
for (const auto &save : s_pending_save) {
|
||||
char key_str[KEY_BUFFER_SIZE];
|
||||
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
|
||||
char key_str[UINT32_MAX_STR_SIZE];
|
||||
uint32_to_str(key_str, save.key);
|
||||
ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str);
|
||||
if (this->is_changed_(&this->db, save, key_str)) {
|
||||
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size());
|
||||
|
||||
@@ -58,6 +58,12 @@ void AddressableLightTransformer::start() {
|
||||
// our transition will handle brightness, disable brightness in correction.
|
||||
this->light_.correction_.set_local_brightness(255);
|
||||
this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state());
|
||||
|
||||
// Uniformity scan is deferred to the first apply() call. start() can run before the underlying
|
||||
// LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE
|
||||
// triggering a transition), and reading through ESPColorView would deref a null buffer.
|
||||
this->uniform_start_scanned_ = false;
|
||||
this->uniform_start_is_uniform_ = false;
|
||||
}
|
||||
|
||||
inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) {
|
||||
@@ -97,12 +103,57 @@ optional<LightColorValues> AddressableLightTransformer::apply() {
|
||||
// non-linear when applying small deltas.
|
||||
|
||||
if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
|
||||
int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
|
||||
for (auto led : this->light_) {
|
||||
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
|
||||
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
|
||||
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
|
||||
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
|
||||
// Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the
|
||||
// frame buffer is valid. When every LED already has the same color (the common case: plain
|
||||
// turn_on/turn_off on a uniform strip), interpolate math-only against a single start color.
|
||||
// Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip
|
||||
// quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27
|
||||
// round to stored 0, freezing progress).
|
||||
if (!this->uniform_start_scanned_) {
|
||||
this->uniform_start_scanned_ = true;
|
||||
if (this->light_.size() > 0) {
|
||||
Color first = this->light_[0].get();
|
||||
bool uniform = true;
|
||||
for (int32_t i = 1; i < this->light_.size(); i++) {
|
||||
if (this->light_[i].get() != first) {
|
||||
uniform = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (uniform) {
|
||||
this->uniform_start_color_ = first;
|
||||
this->uniform_start_is_uniform_ = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this->uniform_start_is_uniform_) {
|
||||
// All LEDs started at the same color: compute the interpolated value once and write it to
|
||||
// every LED. No read-back, so each LED's stored byte advances through every gamma threshold
|
||||
// as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values.
|
||||
//
|
||||
// Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be
|
||||
// overwritten on the next apply() here. The fallback path below would have respected them
|
||||
// via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we
|
||||
// support, so this is acceptable.
|
||||
// lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress).
|
||||
const Color &start = this->uniform_start_color_;
|
||||
int32_t remaining = int32_t(256.f * (1.f - smoothed_progress));
|
||||
uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining);
|
||||
uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining);
|
||||
uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining);
|
||||
uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining);
|
||||
for (auto led : this->light_) {
|
||||
led.set_rgbw(r, g, b, w);
|
||||
}
|
||||
} else {
|
||||
int32_t scale =
|
||||
int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
|
||||
for (auto led : this->light_) {
|
||||
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
|
||||
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
|
||||
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
|
||||
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
|
||||
}
|
||||
}
|
||||
this->last_transition_progress_ = smoothed_progress;
|
||||
this->light_.schedule_show();
|
||||
|
||||
@@ -115,6 +115,9 @@ class AddressableLightTransformer : public LightTransformer {
|
||||
AddressableLight &light_;
|
||||
float last_transition_progress_{0.0f};
|
||||
Color target_color_{};
|
||||
Color uniform_start_color_{};
|
||||
bool uniform_start_scanned_{false};
|
||||
bool uniform_start_is_uniform_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::light
|
||||
|
||||
@@ -22,4 +22,20 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const {
|
||||
return (target - a <= b - target) ? lo : lo + 1;
|
||||
}
|
||||
|
||||
Color ESPColorCorrection::color_uncorrect(Color color) const {
|
||||
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
|
||||
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
|
||||
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
|
||||
}
|
||||
|
||||
uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const {
|
||||
if (max_brightness == 0 || this->local_brightness_ == 0)
|
||||
return 0;
|
||||
// Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero,
|
||||
// (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs.
|
||||
uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL;
|
||||
uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_;
|
||||
return static_cast<uint8_t>(std::min(res, uint32_t(255)));
|
||||
}
|
||||
|
||||
} // namespace esphome::light
|
||||
|
||||
@@ -46,38 +46,18 @@ class ESPColorCorrection {
|
||||
uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_);
|
||||
return this->gamma_correct_(res);
|
||||
}
|
||||
inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE {
|
||||
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
|
||||
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
|
||||
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
|
||||
}
|
||||
Color color_uncorrect(Color color) const;
|
||||
inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE {
|
||||
if (this->max_brightness_.red == 0 || this->local_brightness_ == 0)
|
||||
return 0;
|
||||
uint16_t uncorrected = this->gamma_uncorrect_(red) * 255UL;
|
||||
uint16_t res = ((uncorrected / this->max_brightness_.red) * 255UL) / this->local_brightness_;
|
||||
return (uint8_t) std::min(res, uint16_t(255));
|
||||
return this->color_uncorrect_channel_(red, this->max_brightness_.red);
|
||||
}
|
||||
inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE {
|
||||
if (this->max_brightness_.green == 0 || this->local_brightness_ == 0)
|
||||
return 0;
|
||||
uint16_t uncorrected = this->gamma_uncorrect_(green) * 255UL;
|
||||
uint16_t res = ((uncorrected / this->max_brightness_.green) * 255UL) / this->local_brightness_;
|
||||
return (uint8_t) std::min(res, uint16_t(255));
|
||||
return this->color_uncorrect_channel_(green, this->max_brightness_.green);
|
||||
}
|
||||
inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE {
|
||||
if (this->max_brightness_.blue == 0 || this->local_brightness_ == 0)
|
||||
return 0;
|
||||
uint16_t uncorrected = this->gamma_uncorrect_(blue) * 255UL;
|
||||
uint16_t res = ((uncorrected / this->max_brightness_.blue) * 255UL) / this->local_brightness_;
|
||||
return (uint8_t) std::min(res, uint16_t(255));
|
||||
return this->color_uncorrect_channel_(blue, this->max_brightness_.blue);
|
||||
}
|
||||
inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE {
|
||||
if (this->max_brightness_.white == 0 || this->local_brightness_ == 0)
|
||||
return 0;
|
||||
uint16_t uncorrected = this->gamma_uncorrect_(white) * 255UL;
|
||||
uint16_t res = ((uncorrected / this->max_brightness_.white) * 255UL) / this->local_brightness_;
|
||||
return (uint8_t) std::min(res, uint16_t(255));
|
||||
return this->color_uncorrect_channel_(white, this->max_brightness_.white);
|
||||
}
|
||||
|
||||
protected:
|
||||
@@ -85,6 +65,9 @@ class ESPColorCorrection {
|
||||
uint8_t gamma_correct_(uint8_t value) const;
|
||||
/// Reverse gamma: binary search the forward PROGMEM table
|
||||
uint8_t gamma_uncorrect_(uint8_t value) const;
|
||||
/// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line
|
||||
/// to avoid duplicating two 16-bit divides at every call site.
|
||||
uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const;
|
||||
|
||||
const uint16_t *gamma_table_{nullptr};
|
||||
Color max_brightness_{255, 255, 255, 255};
|
||||
|
||||
@@ -36,8 +36,9 @@ bool Nextion::send_command_(const std::string &command) {
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) {
|
||||
ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str());
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send(now)) {
|
||||
ESP_LOGN(TAG, "Command spacing: delaying '%s'", command.c_str());
|
||||
return false;
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
@@ -48,6 +49,16 @@ bool Nextion::send_command_(const std::string &command) {
|
||||
const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF};
|
||||
this->write_array(to_send, sizeof(to_send));
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Mark sent immediately after writing to UART. The pacer enforces inter-command
|
||||
// spacing from the transmit side. Marking on ACK (0x01) would leave last_command_time_
|
||||
// at zero indefinitely, making can_send() always return true and spacing a no-op.
|
||||
// ignore_is_setup_ commands (setup/init sequence) bypass spacing intentionally.
|
||||
if (!this->connection_state_.ignore_is_setup_) {
|
||||
this->command_pacer_.mark_sent(now);
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -253,11 +264,8 @@ bool Nextion::send_command(const char *command) {
|
||||
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping())
|
||||
return false;
|
||||
|
||||
if (this->send_command_(command)) {
|
||||
this->add_no_result_to_queue_("command");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
this->add_no_result_to_queue_with_command_("command", command);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Nextion::send_command_printf(const char *format, ...) {
|
||||
@@ -274,11 +282,8 @@ bool Nextion::send_command_printf(const char *format, ...) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this->send_command_(buffer)) {
|
||||
this->add_no_result_to_queue_("command_printf");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
this->add_no_result_to_queue_with_command_("command_printf", buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef NEXTION_PROTOCOL_LOG
|
||||
@@ -349,25 +354,43 @@ void Nextion::loop() {
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Try to send any pending commands if spacing allows
|
||||
this->process_pending_in_queue_();
|
||||
#ifdef USE_NEXTION_WAVEFORM
|
||||
if (!this->waveform_queue_.empty()) {
|
||||
this->check_pending_waveform_();
|
||||
}
|
||||
#endif // USE_NEXTION_WAVEFORM
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
void Nextion::process_pending_in_queue_() {
|
||||
if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) {
|
||||
return;
|
||||
}
|
||||
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
size_t commands_sent = 0;
|
||||
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
|
||||
// Check if first item in queue has a pending command
|
||||
auto *front_item = this->nextion_queue_.front();
|
||||
if (front_item && !front_item->pending_command.empty()) {
|
||||
if (this->send_command_(front_item->pending_command)) {
|
||||
// Command sent successfully, clear the pending command
|
||||
front_item->pending_command.clear();
|
||||
ESP_LOGVV(TAG, "Pending command sent: %s", front_item->component->get_variable_name().c_str());
|
||||
for (auto *item : this->nextion_queue_) {
|
||||
if (item == nullptr || item->pending_command.empty()) {
|
||||
continue; // Already sent, waiting for ACK — skip, don't stop
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
if (++commands_sent > this->max_commands_per_loop_) {
|
||||
ESP_LOGV(TAG, "Pending cmds: loop limit reached, deferring");
|
||||
break;
|
||||
}
|
||||
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (!this->command_pacer_.can_send(now)) {
|
||||
break; // Spacing not elapsed, stop for this loop iteration
|
||||
}
|
||||
|
||||
if (!this->send_command_(item->pending_command)) {
|
||||
break; // Unexpected send failure, stop
|
||||
}
|
||||
item->pending_command.clear();
|
||||
ESP_LOGVV(TAG, "Pending cmd sent: %s", item->component->get_variable_name().c_str());
|
||||
}
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
@@ -470,10 +493,6 @@ void Nextion::process_nextion_commands_() {
|
||||
this->setup_callback_.call();
|
||||
}
|
||||
}
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
this->command_pacer_.mark_sent(); // Here is where we should mark the command as sent
|
||||
ESP_LOGN(TAG, "Command spacing: marked command sent");
|
||||
#endif
|
||||
break;
|
||||
case 0x02: // invalid Component ID or name was used
|
||||
ESP_LOGW(TAG, "Invalid component ID/name");
|
||||
@@ -1079,10 +1098,18 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief
|
||||
* @brief Send a command and enqueue it for response tracking.
|
||||
*
|
||||
* @param variable_name Variable name for the queue
|
||||
* @param command
|
||||
* Callers are responsible for checking is_sleeping() before calling this
|
||||
* method. The sleep guard is deliberately absent here because some callers
|
||||
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
|
||||
* sleep-safe and must bypass it.
|
||||
*
|
||||
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
|
||||
* the command is saved in the queue entry for retry rather than dropped.
|
||||
*
|
||||
* @param variable_name Name of the variable or component associated with the command.
|
||||
* @param command The raw command string to send.
|
||||
*/
|
||||
void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_name, const std::string &command) {
|
||||
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || command.empty())
|
||||
@@ -1263,9 +1290,22 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
|
||||
|
||||
std::string command = "get " + component->get_variable_name_to_send();
|
||||
|
||||
#ifdef USE_NEXTION_COMMAND_SPACING
|
||||
// Always enqueue first so the response handler is present when the command
|
||||
// is eventually sent. Store the command for retry if spacing blocked it;
|
||||
// process_pending_in_queue_() will transmit it when the pacer allows.
|
||||
nextion_queue->pending_command = command;
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
if (this->send_command_(command)) {
|
||||
nextion_queue->pending_command.clear();
|
||||
}
|
||||
#else // USE_NEXTION_COMMAND_SPACING
|
||||
if (this->send_command_(command)) {
|
||||
this->nextion_queue_.push_back(nextion_queue);
|
||||
} else {
|
||||
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
}
|
||||
#endif // USE_NEXTION_COMMAND_SPACING
|
||||
}
|
||||
|
||||
#ifdef USE_NEXTION_WAVEFORM
|
||||
@@ -1309,10 +1349,10 @@ void Nextion::check_pending_waveform_() {
|
||||
char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars
|
||||
buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(),
|
||||
component->get_wave_channel_id(), buffer_to_send);
|
||||
if (!this->send_command_(command)) {
|
||||
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
|
||||
this->waveform_queue_.pop();
|
||||
}
|
||||
// If spacing or setup state blocks the send, leave the entry at the front
|
||||
// of waveform_queue_ for retry on the next loop iteration via
|
||||
// check_pending_waveform_(). Only pop on a successful send.
|
||||
this->send_command_(command);
|
||||
}
|
||||
#endif // USE_NEXTION_WAVEFORM
|
||||
|
||||
|
||||
@@ -55,15 +55,20 @@ class NextionCommandPacer {
|
||||
uint8_t get_spacing() const { return spacing_ms_; }
|
||||
|
||||
/**
|
||||
* @brief Check if enough time has passed to send next command
|
||||
* @return true if enough time has passed since last command
|
||||
* @brief Check if enough time has passed to send the next command.
|
||||
* @param now Current timestamp in milliseconds (use App.get_loop_component_start_time()
|
||||
* for consistency with the rest of the queue timing).
|
||||
* @return true if the spacing interval has elapsed since the last command was sent.
|
||||
*/
|
||||
bool can_send() const { return (millis() - last_command_time_) >= spacing_ms_; }
|
||||
bool can_send(uint32_t now) const { return (now - last_command_time_) >= spacing_ms_; }
|
||||
|
||||
/**
|
||||
* @brief Mark a command as sent, updating the timing
|
||||
* @brief Record the transmit timestamp for the most recently sent command.
|
||||
* @param now Current timestamp in milliseconds, as returned by
|
||||
* App.get_loop_component_start_time(). Must use the same clock
|
||||
* source as can_send() to avoid unsigned underflow.
|
||||
*/
|
||||
void mark_sent() { last_command_time_ = millis(); }
|
||||
void mark_sent(uint32_t now) { last_command_time_ = now; }
|
||||
|
||||
private:
|
||||
uint8_t spacing_ms_;
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
#include "esphome/core/wake.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_OTA_PLATFORM_ESPHOME
|
||||
extern "C" void esphome_wake_ota_component_any_context();
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP8266
|
||||
#include <coredecls.h> // For esp_schedule()
|
||||
#elif defined(USE_RP2040)
|
||||
@@ -854,6 +858,10 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) {
|
||||
tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn);
|
||||
tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn);
|
||||
LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_);
|
||||
#ifdef USE_OTA_PLATFORM_ESPHOME
|
||||
// Must run before wake_loop_any_context() so flags are visible when the main task wakes.
|
||||
esphome_wake_ota_component_any_context();
|
||||
#endif
|
||||
// Wake the main loop immediately so it can accept the new connection.
|
||||
esphome::wake_loop_any_context();
|
||||
return ERR_OK;
|
||||
|
||||
@@ -85,8 +85,12 @@ void Application::setup() {
|
||||
if (component->can_proceed())
|
||||
continue;
|
||||
|
||||
// Force the status LED to blink WARNING while we wait for a slow
|
||||
// component to come up. Cleared after setup() finishes if no real
|
||||
// component has warning set.
|
||||
this->app_state_ |= STATUS_LED_WARNING;
|
||||
|
||||
do {
|
||||
uint8_t new_app_state = STATUS_LED_WARNING;
|
||||
uint32_t now = millis();
|
||||
|
||||
// Process pending loop enables to handle GPIO interrupts during setup
|
||||
@@ -96,17 +100,26 @@ void Application::setup() {
|
||||
// Update loop_component_start_time_ right before calling each component
|
||||
this->loop_component_start_time_ = millis();
|
||||
this->components_[j]->call();
|
||||
new_app_state |= this->components_[j]->get_component_state();
|
||||
this->app_state_ |= new_app_state;
|
||||
this->feed_wdt();
|
||||
}
|
||||
|
||||
this->after_loop_tasks_();
|
||||
this->app_state_ = new_app_state;
|
||||
yield();
|
||||
} while (!component->can_proceed() && !component->is_failed());
|
||||
}
|
||||
|
||||
// Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path
|
||||
// above may have forced it on, and any status_clear_warning() calls
|
||||
// from components during setup were intentional no-ops (gated by
|
||||
// APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the
|
||||
// real state. STATUS_LED_ERROR is never artificially forced, so its
|
||||
// clear path always works and needs no reconciliation. Finally, set
|
||||
// APP_STATE_SETUP_COMPLETE so subsequent warning clears go through
|
||||
// the normal walk-and-clear path.
|
||||
if (!this->any_component_has_status_flag_(STATUS_LED_WARNING))
|
||||
this->app_state_ &= ~STATUS_LED_WARNING;
|
||||
this->app_state_ |= APP_STATE_SETUP_COMPLETE;
|
||||
|
||||
ESP_LOGI(TAG, "setup() finished successfully!");
|
||||
|
||||
#ifdef USE_SETUP_PRIORITY_OVERRIDE
|
||||
@@ -196,21 +209,40 @@ void Application::process_dump_config_() {
|
||||
this->dump_config_at_++;
|
||||
}
|
||||
|
||||
void HOT Application::feed_wdt(uint32_t time) {
|
||||
static uint32_t last_feed = 0;
|
||||
// Use provided time if available, otherwise get current time
|
||||
uint32_t now = time ? time : millis();
|
||||
// Compare in milliseconds (3ms threshold)
|
||||
if (now - last_feed > 3) {
|
||||
arch_feed_wdt();
|
||||
last_feed = now;
|
||||
#ifdef USE_STATUS_LED
|
||||
if (status_led::global_status_led != nullptr) {
|
||||
status_led::global_status_led->call();
|
||||
}
|
||||
#endif
|
||||
void Application::feed_wdt() {
|
||||
// Cold entry: callers without a millis() timestamp in hand. Fetches the
|
||||
// time and takes the same rate-limit path as feed_wdt_with_time().
|
||||
uint32_t now = millis();
|
||||
if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) {
|
||||
this->feed_wdt_slow_(now);
|
||||
}
|
||||
}
|
||||
|
||||
void HOT Application::feed_wdt_slow_(uint32_t time) {
|
||||
// Callers (both feed_wdt() and feed_wdt_with_time()) have already
|
||||
// confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded.
|
||||
arch_feed_wdt();
|
||||
this->last_wdt_feed_ = time;
|
||||
#ifdef USE_STATUS_LED
|
||||
if (status_led::global_status_led != nullptr) {
|
||||
status_led::global_status_led->call();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Application::any_component_has_status_flag_(uint8_t flag) const {
|
||||
// Walk all components (not just looping ones) so non-looping components'
|
||||
// status bits are respected. Only called from the slow-path clear helpers
|
||||
// (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an
|
||||
// actual set→clear transition, so walking O(N) here is paid once per
|
||||
// transition — not once per loop iteration.
|
||||
for (auto *component : this->components_) {
|
||||
if ((component->get_component_state() & flag) != 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Application::reboot() {
|
||||
ESP_LOGI(TAG, "Forcing a reboot");
|
||||
for (auto &component : std::ranges::reverse_view(this->components_)) {
|
||||
@@ -299,7 +331,7 @@ void Application::teardown_components(uint32_t timeout_ms) {
|
||||
|
||||
while (pending_count > 0 && (now - start_time) < timeout_ms) {
|
||||
// Feed watchdog during teardown to prevent triggering
|
||||
this->feed_wdt(now);
|
||||
this->feed_wdt_with_time(now);
|
||||
|
||||
// Process components and compact the array, keeping only those still pending
|
||||
size_t still_pending = 0;
|
||||
|
||||
+47
-13
@@ -385,7 +385,24 @@ class Application {
|
||||
|
||||
void schedule_dump_config() { this->dump_config_at_ = 0; }
|
||||
|
||||
void feed_wdt(uint32_t time = 0);
|
||||
/// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the
|
||||
/// rate of HAL pokes low while still being small enough that any plausible
|
||||
/// watchdog timeout (seconds) has orders of magnitude of safety margin.
|
||||
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3;
|
||||
|
||||
/// Feed the task watchdog. Cold entry — callers without a millis()
|
||||
/// timestamp in hand. Out of line to keep call sites tiny.
|
||||
void feed_wdt();
|
||||
|
||||
/// Feed the task watchdog, hot entry. Callers that already have a
|
||||
/// millis() timestamp pay only a load + sub + branch on the common
|
||||
/// (no-op) path. The actual arch feed + status LED update live in
|
||||
/// feed_wdt_slow_.
|
||||
void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) {
|
||||
if (static_cast<uint32_t>(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] {
|
||||
this->feed_wdt_slow_(time);
|
||||
}
|
||||
}
|
||||
|
||||
void reboot();
|
||||
|
||||
@@ -401,7 +418,18 @@ class Application {
|
||||
*/
|
||||
void teardown_components(uint32_t timeout_ms);
|
||||
|
||||
uint8_t get_app_state() const { return this->app_state_; }
|
||||
/// Return the public app state status bits (STATUS_LED_* only).
|
||||
/// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked
|
||||
/// out so external readers (status_led components, etc.) never see them.
|
||||
uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; }
|
||||
|
||||
/// True once Application::setup() has finished walking all components
|
||||
/// and finalized the initial status flags. Before this point, the
|
||||
/// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and
|
||||
/// status_clear_* intentionally skips its walk-and-clear step so the
|
||||
/// forced bit doesn't get wiped. Stored as a free bit on app_state_
|
||||
/// (bit 6) to avoid costing additional RAM.
|
||||
bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; }
|
||||
|
||||
// Helper macro for entity getter method declarations
|
||||
#ifdef USE_DEVICES
|
||||
@@ -577,6 +605,12 @@ class Application {
|
||||
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
|
||||
#endif
|
||||
|
||||
/// Walk all registered components looking for any whose component_state_
|
||||
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
|
||||
/// (which is a friend) to decide whether to clear the corresponding bit on
|
||||
/// this->app_state_ (the app-wide "any component has this status" indicator).
|
||||
bool any_component_has_status_flag_(uint8_t flag) const;
|
||||
|
||||
/// Register a component, detecting loop() override at compile time.
|
||||
/// Uses HasLoopOverride<T> which handles ambiguous &T::loop from multiple inheritance.
|
||||
template<typename T> void register_component_(T *comp) {
|
||||
@@ -615,7 +649,10 @@ class Application {
|
||||
/// Caller must ensure dump_config_at_ < components_.size().
|
||||
void __attribute__((noinline)) process_dump_config_();
|
||||
|
||||
void feed_wdt_arch_();
|
||||
/// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates
|
||||
/// last_wdt_feed_, and re-dispatches the status LED. Out of line so the
|
||||
/// inline wrapper stays tiny.
|
||||
void feed_wdt_slow_(uint32_t time);
|
||||
|
||||
/// Perform a delay while also monitoring socket file descriptors for readiness
|
||||
#ifdef USE_HOST
|
||||
@@ -669,6 +706,7 @@ class Application {
|
||||
// 4-byte members
|
||||
uint32_t last_loop_{0};
|
||||
uint32_t loop_component_start_time_{0};
|
||||
uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path
|
||||
|
||||
#ifdef USE_HOST
|
||||
int max_fd_{-1}; // Highest file descriptor number for select()
|
||||
@@ -813,12 +851,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
|
||||
this->drain_wake_notifications_();
|
||||
#endif
|
||||
|
||||
// Process scheduled tasks
|
||||
// Process scheduled tasks. Scheduler::call now feeds the watchdog itself
|
||||
// after each scheduled item that actually runs, so we no longer need an
|
||||
// unconditional feed here — when Scheduler::call has no work to do, the
|
||||
// only elapsed time is a sleep wake + a few instructions, and when it does
|
||||
// have work, it fed the wdt as it went.
|
||||
this->scheduler.call(loop_start_time);
|
||||
|
||||
// Feed the watchdog timer
|
||||
this->feed_wdt(loop_start_time);
|
||||
|
||||
// Process any pending enable_loop requests from ISRs
|
||||
// This must be done before marking in_loop_ = true to avoid race conditions
|
||||
if (this->has_pending_enable_loop_requests_) {
|
||||
@@ -839,8 +878,6 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
|
||||
|
||||
// NOLINTNEXTLINE(clang-diagnostic-unknown-attributes)
|
||||
inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::loop() {
|
||||
uint8_t new_app_state = 0;
|
||||
|
||||
// Get the initial loop time at the start
|
||||
uint32_t last_op_end_time = millis();
|
||||
|
||||
@@ -860,13 +897,10 @@ inline void ESPHOME_ALWAYS_INLINE __attribute__((optimize("O2"))) Application::l
|
||||
// Use the finish method to get the current time as the end time
|
||||
last_op_end_time = guard.finish();
|
||||
}
|
||||
new_app_state |= component->get_component_state();
|
||||
this->app_state_ |= new_app_state;
|
||||
this->feed_wdt(last_op_end_time);
|
||||
this->feed_wdt_with_time(last_op_end_time);
|
||||
}
|
||||
|
||||
this->after_loop_tasks_();
|
||||
this->app_state_ = new_app_state;
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
// Process any pending runtime stats printing after all components have run
|
||||
|
||||
@@ -411,10 +411,23 @@ void Component::status_set_error(const LogString *message) {
|
||||
}
|
||||
void Component::status_clear_warning_slow_path_() {
|
||||
this->component_state_ &= ~STATUS_LED_WARNING;
|
||||
// Clear the app-wide STATUS_LED_WARNING bit only if setup has finished
|
||||
// AND no other component still has it set. During setup the forced
|
||||
// STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped
|
||||
// by a transient component clear — Application::setup() reconciles
|
||||
// the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE.
|
||||
// The set path is unchanged (set_status_flag_ still writes directly).
|
||||
if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING))
|
||||
App.app_state_ &= ~STATUS_LED_WARNING;
|
||||
ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str()));
|
||||
}
|
||||
void Component::status_clear_error_slow_path_() {
|
||||
this->component_state_ &= ~STATUS_LED_ERROR;
|
||||
// STATUS_LED_ERROR is never artificially forced — it only ever lands
|
||||
// in app_state_ via a real set_status_flag_ call. So the walk-and-clear
|
||||
// path is always safe, including during setup.
|
||||
if (!App.any_component_has_status_flag_(STATUS_LED_ERROR))
|
||||
App.app_state_ &= ~STATUS_LED_ERROR;
|
||||
ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str()));
|
||||
}
|
||||
void Component::status_momentary_warning(const char *name, uint32_t length) {
|
||||
|
||||
@@ -89,6 +89,11 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
|
||||
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
|
||||
// Component loop override flag uses bit 5 (set at registration time)
|
||||
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
|
||||
// Bit 6 on Application::app_state_ (ONLY) — set at the end of
|
||||
// Application::setup(). Component::status_clear_*_slow_path_() uses this to
|
||||
// decide whether to propagate clears to App.app_state_. Never set on a
|
||||
// Component's component_state_.
|
||||
inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40;
|
||||
// Remove before 2026.8.0
|
||||
enum class RetryResult { DONE, RETRY };
|
||||
|
||||
|
||||
@@ -347,17 +347,18 @@ std::string format_mac_address_pretty(const uint8_t *mac) {
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase
|
||||
// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase.
|
||||
// When separator is set, it is written unconditionally after each byte and the last
|
||||
// one is overwritten with '\0', eliminating the per-byte `i < length - 1` check.
|
||||
static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator,
|
||||
char base) {
|
||||
if (length == 0) {
|
||||
buffer[0] = '\0';
|
||||
if (length == 0 || buffer_size == 0) {
|
||||
if (buffer_size > 0)
|
||||
buffer[0] = '\0';
|
||||
return buffer;
|
||||
}
|
||||
// With separator: total length is 3*length (2*length hex chars, (length-1) separators, 1 null terminator)
|
||||
// Without separator: total length is 2*length + 1 (2*length hex chars, 1 null terminator)
|
||||
uint8_t stride = separator ? 3 : 2;
|
||||
size_t max_bytes = separator ? (buffer_size / stride) : ((buffer_size - 1) / stride);
|
||||
size_t max_bytes = separator ? (buffer_size / 3) : ((buffer_size - 1) / 2);
|
||||
if (max_bytes == 0) {
|
||||
buffer[0] = '\0';
|
||||
return buffer;
|
||||
@@ -369,14 +370,30 @@ static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t
|
||||
size_t pos = i * stride;
|
||||
buffer[pos] = format_hex_char(data[i] >> 4, base);
|
||||
buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base);
|
||||
if (separator && i < length - 1) {
|
||||
if (separator) {
|
||||
buffer[pos + 2] = separator;
|
||||
}
|
||||
}
|
||||
// With separator: overwrite last separator with '\0'
|
||||
// Without: write '\0' after last hex char
|
||||
buffer[length * stride - (separator ? 1 : 0)] = '\0';
|
||||
return buffer;
|
||||
}
|
||||
|
||||
char *uint32_to_str_unchecked(char *buf, uint32_t val) {
|
||||
if (val == 0) {
|
||||
*buf++ = '0';
|
||||
return buf;
|
||||
}
|
||||
char *start = buf;
|
||||
while (val > 0) {
|
||||
*buf++ = '0' + (val % 10);
|
||||
val /= 10;
|
||||
}
|
||||
std::reverse(start, buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) {
|
||||
return format_hex_internal(buffer, buffer_size, data, length, 0, 'a');
|
||||
}
|
||||
|
||||
+18
-3
@@ -1263,13 +1263,13 @@ constexpr uint8_t parse_hex_char(char c) {
|
||||
}
|
||||
|
||||
/// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase)
|
||||
inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; }
|
||||
ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; }
|
||||
|
||||
/// Convert a nibble (0-15) to lowercase hex char
|
||||
inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); }
|
||||
ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); }
|
||||
|
||||
/// Convert a nibble (0-15) to uppercase hex char (used for pretty printing)
|
||||
inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); }
|
||||
ESPHOME_ALWAYS_INLINE inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); }
|
||||
|
||||
/// Write int8 value to buffer without modulo operations.
|
||||
/// Buffer must have at least 4 bytes free. Returns pointer past last char written.
|
||||
@@ -1295,6 +1295,21 @@ inline char *int8_to_str(char *buf, int8_t val) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// Minimum buffer size for uint32_to_str: 10 digits + null terminator.
|
||||
static constexpr size_t UINT32_MAX_STR_SIZE = 11;
|
||||
|
||||
/// Write unsigned 32-bit integer to buffer (internal, no size check).
|
||||
/// Buffer must have at least 10 bytes free. Returns pointer past last char written.
|
||||
char *uint32_to_str_unchecked(char *buf, uint32_t val);
|
||||
|
||||
/// Write unsigned 32-bit integer to buffer with compile-time size check.
|
||||
/// Null-terminates the output. Returns number of chars written (excluding null).
|
||||
inline size_t uint32_to_str(std::span<char, UINT32_MAX_STR_SIZE> buf, uint32_t val) {
|
||||
char *end = uint32_to_str_unchecked(buf.data(), val);
|
||||
*end = '\0';
|
||||
return static_cast<size_t>(end - buf.data());
|
||||
}
|
||||
|
||||
/// Format byte array as lowercase hex to buffer (base implementation).
|
||||
char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length);
|
||||
|
||||
|
||||
@@ -157,6 +157,17 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN
|
||||
// Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task.
|
||||
static netconn_callback s_original_callback = NULL;
|
||||
|
||||
#ifdef USE_OTA_PLATFORM_ESPHOME
|
||||
static struct netconn *s_ota_listener_conn = NULL;
|
||||
extern void esphome_wake_ota_component_any_context(void);
|
||||
|
||||
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) {
|
||||
s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL;
|
||||
}
|
||||
#else
|
||||
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { (void) sock; }
|
||||
#endif
|
||||
|
||||
// Wrapper callback: calls original event_callback + notifies main loop task.
|
||||
// Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR).
|
||||
static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) {
|
||||
@@ -171,6 +182,13 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt
|
||||
// (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions
|
||||
// already wake the main loop through the RCVPLUS path.
|
||||
if (evt == NETCONN_EVT_RCVPLUS) {
|
||||
#ifdef USE_OTA_PLATFORM_ESPHOME
|
||||
// Mark OTA pending-enable only for events on its listen socket. MUST happen
|
||||
// before xTaskNotifyGive so the flags are visible when the main task wakes.
|
||||
if (conn == s_ota_listener_conn) {
|
||||
esphome_wake_ota_component_any_context();
|
||||
}
|
||||
#endif
|
||||
TaskHandle_t task = esphome_main_task_handle;
|
||||
if (task != NULL) {
|
||||
xTaskNotifyGive(task);
|
||||
|
||||
@@ -53,6 +53,12 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) {
|
||||
/// The sock pointer must have been obtained from esphome_lwip_get_sock().
|
||||
void esphome_lwip_hook_socket(struct lwip_sock *sock);
|
||||
|
||||
/// Set the listener netconn that the fast-select callback filters OTA wakes against.
|
||||
/// After this is called, the OTA wake hook only fires for RCVPLUS events whose `conn`
|
||||
/// matches this listener. Passing NULL disables OTA wakes (no event matches a NULL
|
||||
/// listener) — correct behavior before install and after teardown.
|
||||
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock);
|
||||
|
||||
/// Set or clear TCP_NODELAY on a socket's tcp_pcb directly.
|
||||
/// Must be called with the TCPIP core lock held (LwIPLock in C++).
|
||||
/// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade,
|
||||
|
||||
@@ -739,7 +739,13 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
|
||||
App.set_current_component(item->component);
|
||||
WarnIfComponentBlockingGuard guard{item->component, now};
|
||||
item->callback();
|
||||
return guard.finish();
|
||||
uint32_t end = guard.finish();
|
||||
// Feed the watchdog after each scheduled item (both main heap and defer
|
||||
// queue paths go through here). A run of back-to-back callbacks cannot
|
||||
// starve the wdt. The inline fast path is a load + sub + branch — nearly
|
||||
// free when the 3 ms rate limit hasn't elapsed.
|
||||
App.feed_wdt_with_time(end);
|
||||
return end;
|
||||
}
|
||||
|
||||
// Common implementation for cancel operations - handles locking
|
||||
|
||||
@@ -286,7 +286,7 @@ class Scheduler {
|
||||
// Extend a 32-bit millis() value to 64-bit. Use when the caller already has a fresh now.
|
||||
// On platforms with native 64-bit time, ignores now and uses millis_64() directly.
|
||||
// On other platforms, extends now to 64-bit using rollover tracking.
|
||||
uint64_t millis_64_from_(uint32_t now) {
|
||||
uint64_t ESPHOME_ALWAYS_INLINE millis_64_from_(uint32_t now) {
|
||||
#ifdef USE_NATIVE_64BIT_TIME
|
||||
(void) now;
|
||||
return millis_64();
|
||||
|
||||
+12
-30
@@ -20,6 +20,12 @@ namespace esphome {
|
||||
static const char *const TAG = "time_64";
|
||||
#endif
|
||||
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
// Storage for Millis64Impl inline compute() — defined here so all TUs share one copy.
|
||||
uint32_t Millis64Impl::last_millis_{0};
|
||||
uint16_t Millis64Impl::millis_major_{0};
|
||||
#else
|
||||
|
||||
uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
// Half the 32-bit range - used to detect rollovers vs normal time progression
|
||||
static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits<uint32_t>::max() / 2;
|
||||
@@ -44,51 +50,25 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
* to last_millis is provided by its release store and the corresponding acquire loads.
|
||||
*/
|
||||
static std::atomic<uint16_t> millis_major{0};
|
||||
#elif !defined(ESPHOME_THREAD_SINGLE) /* ESPHOME_THREAD_MULTI_NO_ATOMICS */
|
||||
#else /* ESPHOME_THREAD_MULTI_NO_ATOMICS */
|
||||
static Mutex lock;
|
||||
static uint32_t last_millis{0};
|
||||
static uint16_t millis_major{0};
|
||||
#else /* ESPHOME_THREAD_SINGLE */
|
||||
static uint32_t last_millis{0};
|
||||
static uint16_t millis_major{0};
|
||||
#endif
|
||||
|
||||
// THREAD SAFETY NOTE:
|
||||
// This function has three implementations, based on the precompiler flags
|
||||
// - ESPHOME_THREAD_SINGLE - Runs on single-threaded platforms (ESP8266, etc.)
|
||||
// This function has two out-of-line implementations, based on the preprocessor flags:
|
||||
// - ESPHOME_THREAD_MULTI_NO_ATOMICS - Runs on multi-threaded platforms without atomics (LibreTiny BK72xx)
|
||||
// - ESPHOME_THREAD_MULTI_ATOMICS - Runs on multi-threaded platforms with atomics (LibreTiny RTL87xx/LN882x, etc.)
|
||||
//
|
||||
// The ESPHOME_THREAD_SINGLE path is inlined in time_64.h.
|
||||
// Make sure all changes are synchronized if you edit this function.
|
||||
//
|
||||
// IMPORTANT: Always pass fresh millis() values to this function. The implementation
|
||||
// handles out-of-order timestamps between threads, but minimizing time differences
|
||||
// helps maintain accuracy.
|
||||
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
// Single-core platforms have no concurrency, so this is a simple implementation
|
||||
// that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics.
|
||||
|
||||
uint16_t major = millis_major;
|
||||
uint32_t last = last_millis;
|
||||
|
||||
// Check for rollover
|
||||
if (now < last && (last - now) > HALF_MAX_UINT32) {
|
||||
millis_major++;
|
||||
major++;
|
||||
last_millis = now;
|
||||
#ifdef ESPHOME_DEBUG_SCHEDULER
|
||||
ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last);
|
||||
#endif /* ESPHOME_DEBUG_SCHEDULER */
|
||||
} else if (now > last) {
|
||||
// Only update if time moved forward
|
||||
last_millis = now;
|
||||
}
|
||||
|
||||
// Combine major (high 32 bits) and now (low 32 bits) into 64-bit time
|
||||
return now + (static_cast<uint64_t>(major) << 32);
|
||||
|
||||
#elif defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS)
|
||||
// Without atomics, this implementation uses locks more aggressively:
|
||||
// 1. Always locks when near the rollover boundary (within 10 seconds)
|
||||
// 2. Always locks when detecting a large backwards jump
|
||||
@@ -202,6 +182,8 @@ uint64_t Millis64Impl::compute(uint32_t now) {
|
||||
#endif
|
||||
}
|
||||
|
||||
#endif // !ESPHOME_THREAD_SINGLE
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // !USE_NATIVE_64BIT_TIME
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
#ifndef USE_NATIVE_64BIT_TIME
|
||||
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -16,7 +19,36 @@ class Millis64Impl {
|
||||
friend uint64_t millis_64();
|
||||
friend class Scheduler;
|
||||
|
||||
#ifdef ESPHOME_THREAD_SINGLE
|
||||
// Storage defined in time_64.cpp — declared here so the inline body can access them.
|
||||
static uint32_t last_millis_;
|
||||
static uint16_t millis_major_;
|
||||
|
||||
static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) {
|
||||
// Half the 32-bit range - used to detect rollovers vs normal time progression
|
||||
static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits<uint32_t>::max() / 2;
|
||||
|
||||
// Single-core platforms have no concurrency, so this is a simple implementation
|
||||
// that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics.
|
||||
uint16_t major = millis_major_;
|
||||
uint32_t last = last_millis_;
|
||||
|
||||
// Check for rollover
|
||||
if (now < last && (last - now) > HALF_MAX_UINT32) {
|
||||
millis_major_++;
|
||||
major++;
|
||||
last_millis_ = now;
|
||||
} else if (now > last) {
|
||||
// Only update if time moved forward
|
||||
last_millis_ = now;
|
||||
}
|
||||
|
||||
// Combine major (high 32 bits) and now (low 32 bits) into 64-bit time
|
||||
return now + (static_cast<uint64_t>(major) << 32);
|
||||
}
|
||||
#else
|
||||
static uint64_t compute(uint32_t now);
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -3,6 +3,8 @@ dependencies:
|
||||
version: "7.4.2"
|
||||
esphome/esp-audio-libs:
|
||||
version: 2.0.4
|
||||
esphome/micro-decoder:
|
||||
version: 0.1.1
|
||||
esphome/micro-flac:
|
||||
version: 0.1.1
|
||||
esphome/micro-opus:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#include <benchmark/benchmark.h>
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
@@ -307,4 +309,58 @@ static void Base64Decode_32Bytes(benchmark::State &state) {
|
||||
}
|
||||
BENCHMARK(Base64Decode_32Bytes);
|
||||
|
||||
// --- uint32_to_str() vs snprintf ---
|
||||
|
||||
static void Uint32ToStr_Small(benchmark::State &state) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_to_str(buf, 12345);
|
||||
benchmark::DoNotOptimize(buf);
|
||||
benchmark::ClobberMemory();
|
||||
}
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * kInnerIterations);
|
||||
}
|
||||
BENCHMARK(Uint32ToStr_Small);
|
||||
|
||||
static void Snprintf_Uint32_Small(benchmark::State &state) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
snprintf(buf, sizeof(buf), "%" PRIu32, static_cast<uint32_t>(12345));
|
||||
benchmark::DoNotOptimize(buf);
|
||||
benchmark::ClobberMemory();
|
||||
}
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * kInnerIterations);
|
||||
}
|
||||
BENCHMARK(Snprintf_Uint32_Small);
|
||||
|
||||
static void Uint32ToStr_Large(benchmark::State &state) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
uint32_to_str(buf, 4294967295u);
|
||||
benchmark::DoNotOptimize(buf);
|
||||
benchmark::ClobberMemory();
|
||||
}
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * kInnerIterations);
|
||||
}
|
||||
BENCHMARK(Uint32ToStr_Large);
|
||||
|
||||
static void Snprintf_Uint32_Large(benchmark::State &state) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
for (auto _ : state) {
|
||||
for (int i = 0; i < kInnerIterations; i++) {
|
||||
snprintf(buf, sizeof(buf), "%" PRIu32, static_cast<uint32_t>(4294967295u));
|
||||
benchmark::DoNotOptimize(buf);
|
||||
benchmark::ClobberMemory();
|
||||
}
|
||||
}
|
||||
state.SetItemsProcessed(state.iterations() * kInnerIterations);
|
||||
}
|
||||
BENCHMARK(Snprintf_Uint32_Large);
|
||||
|
||||
} // namespace esphome::benchmarks
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for the esphome OTA platform final_validate logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esphome.ota import ota_esphome_final_validate
|
||||
from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
CONF_ID,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_VERSION,
|
||||
)
|
||||
from esphome.core import ID
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
def _make_ota_config(port: int = 3232, **kwargs: Any) -> dict[str, Any]:
|
||||
config: dict[str, Any] = {
|
||||
CONF_PLATFORM: CONF_ESPHOME,
|
||||
CONF_ID: ID(f"ota_esphome_{port}", is_manual=False),
|
||||
CONF_VERSION: 2,
|
||||
CONF_PORT: port,
|
||||
}
|
||||
config.update(kwargs)
|
||||
return config
|
||||
|
||||
|
||||
def test_single_esphome_ota_instance_accepted() -> None:
|
||||
"""A single ESPHome OTA config passes final_validate untouched."""
|
||||
full_conf = {CONF_OTA: [_make_ota_config(port=3232)]}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert len(updated[CONF_OTA]) == 1
|
||||
assert updated[CONF_OTA][0][CONF_PORT] == 3232
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_same_port_configs_merge(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Two ESPHome OTA configs on the same port merge into one instance."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"}),
|
||||
_make_ota_config(port=3232),
|
||||
]
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert len(updated[CONF_OTA]) == 1
|
||||
assert updated[CONF_OTA][0][CONF_PORT] == 3232
|
||||
assert any("Found and merged" in record.message for record in caplog.records), (
|
||||
"Expected merge warning not found in log"
|
||||
)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_multiple_ports_rejected() -> None:
|
||||
"""Two ESPHome OTA configs on different ports raise cv.Invalid."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232),
|
||||
_make_ota_config(port=3233),
|
||||
]
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=r"Only a single port is supported for 'ota' 'platform: esphome'",
|
||||
):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_non_esphome_ota_unaffected() -> None:
|
||||
"""Non-esphome OTA platforms are not subject to the single-instance rule."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232),
|
||||
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||
{CONF_PLATFORM: "http_request", CONF_ID: ID("ota_hr", is_manual=False)},
|
||||
]
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert len(updated[CONF_OTA]) == 3
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
@@ -0,0 +1,120 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
// --- format_hex_to() ---
|
||||
|
||||
TEST(FormatHexTo, Basic) {
|
||||
const uint8_t data[] = {0xAB, 0xCD, 0xEF};
|
||||
char buffer[7]; // 3 * 2 + 1
|
||||
format_hex_to(buffer, data, 3);
|
||||
EXPECT_STREQ(buffer, "abcdef");
|
||||
}
|
||||
|
||||
TEST(FormatHexTo, SingleByte) {
|
||||
const uint8_t data[] = {0x0F};
|
||||
char buffer[3];
|
||||
format_hex_to(buffer, data, 1);
|
||||
EXPECT_STREQ(buffer, "0f");
|
||||
}
|
||||
|
||||
TEST(FormatHexTo, ZeroLength) {
|
||||
char buffer[4] = "xxx";
|
||||
format_hex_to(buffer, static_cast<size_t>(sizeof(buffer)), static_cast<const uint8_t *>(nullptr), 0);
|
||||
EXPECT_STREQ(buffer, "");
|
||||
}
|
||||
|
||||
TEST(FormatHexTo, ZeroBufferSize) {
|
||||
char buffer[4] = "xxx";
|
||||
const uint8_t data[] = {0xAB};
|
||||
format_hex_to(buffer, static_cast<size_t>(0), data, 1);
|
||||
// Should not crash, buffer unchanged
|
||||
EXPECT_EQ(buffer[0], 'x');
|
||||
}
|
||||
|
||||
TEST(FormatHexTo, BufferTooSmall) {
|
||||
const uint8_t data[] = {0xAB, 0xCD, 0xEF};
|
||||
char buffer[5]; // only room for 2 bytes
|
||||
format_hex_to(buffer, data, 3);
|
||||
EXPECT_STREQ(buffer, "abcd");
|
||||
}
|
||||
|
||||
TEST(FormatHexTo, MacAddress) {
|
||||
const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
|
||||
char buffer[13];
|
||||
format_hex_to(buffer, mac, 6);
|
||||
EXPECT_STREQ(buffer, "aabbccddeeff");
|
||||
}
|
||||
|
||||
// --- format_hex_pretty_to() ---
|
||||
|
||||
TEST(FormatHexPrettyTo, BasicColon) {
|
||||
const uint8_t data[] = {0xAB, 0xCD, 0xEF};
|
||||
char buffer[9]; // 3 * 3
|
||||
format_hex_pretty_to(buffer, data, 3);
|
||||
EXPECT_STREQ(buffer, "AB:CD:EF");
|
||||
}
|
||||
|
||||
TEST(FormatHexPrettyTo, SingleByte) {
|
||||
const uint8_t data[] = {0x0F};
|
||||
char buffer[3];
|
||||
format_hex_pretty_to(buffer, data, 1);
|
||||
EXPECT_STREQ(buffer, "0F");
|
||||
}
|
||||
|
||||
TEST(FormatHexPrettyTo, ZeroLength) {
|
||||
char buffer[4] = "xxx";
|
||||
format_hex_pretty_to(buffer, static_cast<size_t>(sizeof(buffer)), static_cast<const uint8_t *>(nullptr), 0);
|
||||
EXPECT_STREQ(buffer, "");
|
||||
}
|
||||
|
||||
TEST(FormatHexPrettyTo, ZeroBufferSize) {
|
||||
char buffer[4] = "xxx";
|
||||
const uint8_t data[] = {0xAB};
|
||||
format_hex_pretty_to(buffer, static_cast<size_t>(0), data, 1);
|
||||
EXPECT_EQ(buffer[0], 'x');
|
||||
}
|
||||
|
||||
TEST(FormatHexPrettyTo, CustomSeparator) {
|
||||
const uint8_t data[] = {0xAA, 0xBB, 0xCC};
|
||||
char buffer[9];
|
||||
format_hex_pretty_to(buffer, data, 3, '-');
|
||||
EXPECT_STREQ(buffer, "AA-BB-CC");
|
||||
}
|
||||
|
||||
// --- format_mac_addr_upper() ---
|
||||
|
||||
TEST(FormatMacAddrUpper, Basic) {
|
||||
const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
|
||||
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
format_mac_addr_upper(mac, buffer);
|
||||
EXPECT_STREQ(buffer, "AA:BB:CC:DD:EE:FF");
|
||||
}
|
||||
|
||||
TEST(FormatMacAddrUpper, AllZeros) {
|
||||
const uint8_t mac[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
format_mac_addr_upper(mac, buffer);
|
||||
EXPECT_STREQ(buffer, "00:00:00:00:00:00");
|
||||
}
|
||||
|
||||
// --- format_hex_char() ---
|
||||
|
||||
TEST(FormatHexChar, LowercaseDigits) {
|
||||
EXPECT_EQ(format_hex_char(0), '0');
|
||||
EXPECT_EQ(format_hex_char(9), '9');
|
||||
EXPECT_EQ(format_hex_char(10), 'a');
|
||||
EXPECT_EQ(format_hex_char(15), 'f');
|
||||
}
|
||||
|
||||
TEST(FormatHexChar, UppercaseDigits) {
|
||||
EXPECT_EQ(format_hex_pretty_char(0), '0');
|
||||
EXPECT_EQ(format_hex_pretty_char(9), '9');
|
||||
EXPECT_EQ(format_hex_pretty_char(10), 'A');
|
||||
EXPECT_EQ(format_hex_pretty_char(15), 'F');
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -0,0 +1,77 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::core::testing {
|
||||
|
||||
// --- uint32_to_str_unchecked() (internal, raw pointer) ---
|
||||
|
||||
TEST(Uint32ToStr, InternalZero) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
char *end = uint32_to_str_unchecked(buf, 0);
|
||||
*end = '\0';
|
||||
EXPECT_STREQ(buf, "0");
|
||||
EXPECT_EQ(end - buf, 1);
|
||||
}
|
||||
|
||||
TEST(Uint32ToStr, InternalSingleDigit) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
char *end = uint32_to_str_unchecked(buf, 7);
|
||||
*end = '\0';
|
||||
EXPECT_STREQ(buf, "7");
|
||||
}
|
||||
|
||||
TEST(Uint32ToStr, InternalMultiDigit) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
char *end = uint32_to_str_unchecked(buf, 12345);
|
||||
*end = '\0';
|
||||
EXPECT_STREQ(buf, "12345");
|
||||
EXPECT_EQ(end - buf, 5);
|
||||
}
|
||||
|
||||
TEST(Uint32ToStr, InternalMaxValue) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
char *end = uint32_to_str_unchecked(buf, 4294967295u);
|
||||
*end = '\0';
|
||||
EXPECT_STREQ(buf, "4294967295");
|
||||
EXPECT_EQ(end - buf, 10);
|
||||
}
|
||||
|
||||
TEST(Uint32ToStr, InternalPowersOfTen) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
char *end;
|
||||
|
||||
end = uint32_to_str_unchecked(buf, 10);
|
||||
*end = '\0';
|
||||
EXPECT_STREQ(buf, "10");
|
||||
|
||||
end = uint32_to_str_unchecked(buf, 100);
|
||||
*end = '\0';
|
||||
EXPECT_STREQ(buf, "100");
|
||||
|
||||
end = uint32_to_str_unchecked(buf, 1000000);
|
||||
*end = '\0';
|
||||
EXPECT_STREQ(buf, "1000000");
|
||||
}
|
||||
|
||||
// --- uint32_to_str() (public, span API) ---
|
||||
|
||||
TEST(Uint32ToStr, SpanZero) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
EXPECT_EQ(uint32_to_str(buf, 0), 1u);
|
||||
EXPECT_STREQ(buf, "0");
|
||||
}
|
||||
|
||||
TEST(Uint32ToStr, SpanMultiDigit) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
EXPECT_EQ(uint32_to_str(buf, 12345), 5u);
|
||||
EXPECT_STREQ(buf, "12345");
|
||||
}
|
||||
|
||||
TEST(Uint32ToStr, SpanMaxValue) {
|
||||
char buf[UINT32_MAX_STR_SIZE];
|
||||
EXPECT_EQ(uint32_to_str(buf, 4294967295u), 10u);
|
||||
EXPECT_STREQ(buf, "4294967295");
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
@@ -4,6 +4,14 @@ esphome:
|
||||
- globals.set:
|
||||
id: glob_int
|
||||
value: "10"
|
||||
# Set a float global with an integer literal - must emit the correct
|
||||
# return type so TemplatableFn stores a direct function pointer.
|
||||
- globals.set:
|
||||
id: glob_float
|
||||
value: "102"
|
||||
- globals.set:
|
||||
id: glob_float
|
||||
value: !lambda "return 42;"
|
||||
|
||||
globals:
|
||||
- id: glob_int
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: addr-light-transition
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
light:
|
||||
- platform: mock_addressable_light
|
||||
output_id: strip_output
|
||||
id: strip
|
||||
name: "Test Strip"
|
||||
num_leds: 4
|
||||
gamma_correct: 2.8
|
||||
default_transition_length: 0s
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: "led0_red_raw"
|
||||
id: led0_red_raw
|
||||
update_interval: 10ms
|
||||
accuracy_decimals: 0
|
||||
lambda: |-
|
||||
return (float) id(strip_output).get_raw_red(0);
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@esphome/tests"]
|
||||
@@ -0,0 +1,23 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light")
|
||||
MockAddressableLight = mock_addressable_light_ns.class_(
|
||||
"MockAddressableLight", light.AddressableLight
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MockAddressableLight),
|
||||
cv.Optional(CONF_NUM_LEDS, default=4): cv.positive_not_null_int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS])
|
||||
await light.register_light(var, config)
|
||||
await cg.register_component(var, config)
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
#include "esphome/components/light/addressable_light.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::mock_addressable_light {
|
||||
|
||||
// In-memory addressable light for host-mode integration tests. Exposes the raw
|
||||
// per-LED byte buffer (post-gamma-correction, as the hardware would see it)
|
||||
// so tests can observe transition behavior without real hardware.
|
||||
class MockAddressableLight : public light::AddressableLight {
|
||||
public:
|
||||
explicit MockAddressableLight(uint16_t num_leds)
|
||||
: num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {}
|
||||
|
||||
void setup() override {}
|
||||
void write_state(light::LightState *state) override {}
|
||||
int32_t size() const override { return this->num_leds_; }
|
||||
void clear_effect_data() override {
|
||||
for (uint16_t i = 0; i < this->num_leds_; i++)
|
||||
this->effect_data_[i] = 0;
|
||||
}
|
||||
light::LightTraits get_traits() override {
|
||||
auto traits = light::LightTraits();
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB});
|
||||
return traits;
|
||||
}
|
||||
|
||||
// Accessors for tests: return the raw stored byte (post gamma correction),
|
||||
// which is what actual LED hardware would receive.
|
||||
uint8_t get_raw_red(uint16_t index) const { return this->buf_[index * 4 + 0]; }
|
||||
uint8_t get_raw_green(uint16_t index) const { return this->buf_[index * 4 + 1]; }
|
||||
uint8_t get_raw_blue(uint16_t index) const { return this->buf_[index * 4 + 2]; }
|
||||
uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; }
|
||||
|
||||
protected:
|
||||
light::ESPColorView get_view_internal(int32_t index) const override {
|
||||
size_t pos = index * 4;
|
||||
return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2,
|
||||
this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_};
|
||||
}
|
||||
|
||||
uint16_t num_leds_;
|
||||
std::unique_ptr<uint8_t[]> buf_;
|
||||
std::unique_ptr<uint8_t[]> effect_data_;
|
||||
};
|
||||
|
||||
} // namespace esphome::mock_addressable_light
|
||||
@@ -0,0 +1,141 @@
|
||||
esphome:
|
||||
name: status-flags-test
|
||||
|
||||
host:
|
||||
api:
|
||||
actions:
|
||||
# Warning flag services for sensor_a
|
||||
- action: set_warning_a
|
||||
then:
|
||||
- lambda: "id(sensor_a)->status_set_warning();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
- action: clear_warning_a
|
||||
then:
|
||||
- lambda: "id(sensor_a)->status_clear_warning();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
|
||||
# Warning flag services for sensor_b
|
||||
- action: set_warning_b
|
||||
then:
|
||||
- lambda: "id(sensor_b)->status_set_warning();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
- action: clear_warning_b
|
||||
then:
|
||||
- lambda: "id(sensor_b)->status_clear_warning();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
|
||||
# Error flag services for sensor_a
|
||||
- action: set_error_a
|
||||
then:
|
||||
- lambda: "id(sensor_a)->status_set_error();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
- action: clear_error_a
|
||||
then:
|
||||
- lambda: "id(sensor_a)->status_clear_error();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
|
||||
# Error flag services for sensor_b
|
||||
- action: set_error_b
|
||||
then:
|
||||
- lambda: "id(sensor_b)->status_set_error();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
- action: clear_error_b
|
||||
then:
|
||||
- lambda: "id(sensor_b)->status_clear_error();"
|
||||
- component.update: app_warning_bit
|
||||
- component.update: app_error_bit
|
||||
|
||||
# Snapshot of the status_led_light's output state for observation.
|
||||
- action: snapshot_led
|
||||
then:
|
||||
- component.update: status_led_writes
|
||||
- component.update: status_led_last_state
|
||||
|
||||
logger:
|
||||
|
||||
# Tracks each write to the fake status_led output.
|
||||
globals:
|
||||
- id: status_led_write_count
|
||||
type: uint32_t
|
||||
restore_value: no
|
||||
initial_value: "0"
|
||||
- id: status_led_last_write
|
||||
type: bool
|
||||
restore_value: no
|
||||
initial_value: "false"
|
||||
|
||||
# Fake binary output — status_led_light writes to this instead of a pin.
|
||||
# Every write bumps a counter and records the last value, both of which
|
||||
# are exposed below so the test can verify status_led_light's loop is
|
||||
# actually reading App.get_app_state() and responding.
|
||||
output:
|
||||
- platform: template
|
||||
id: fake_status_led
|
||||
type: binary
|
||||
write_action:
|
||||
- globals.set:
|
||||
id: status_led_write_count
|
||||
value: !lambda "return id(status_led_write_count) + 1;"
|
||||
- globals.set:
|
||||
id: status_led_last_write
|
||||
value: !lambda "return state;"
|
||||
|
||||
# Actual status_led_light component under test.
|
||||
light:
|
||||
- platform: status_led
|
||||
name: Status LED
|
||||
id: status_led_light_id
|
||||
output: fake_status_led
|
||||
|
||||
sensor:
|
||||
# Two components that the test will toggle warning/error flags on.
|
||||
- platform: template
|
||||
name: Sensor A
|
||||
id: sensor_a
|
||||
update_interval: 24h
|
||||
lambda: return 1.0;
|
||||
- platform: template
|
||||
name: Sensor B
|
||||
id: sensor_b
|
||||
update_interval: 24h
|
||||
lambda: return 2.0;
|
||||
|
||||
# Expose App.app_state_'s STATUS_LED_WARNING / STATUS_LED_ERROR bits
|
||||
# as 0.0 / 1.0. force_update ensures every manual component.update
|
||||
# publishes even if the value is unchanged.
|
||||
- platform: template
|
||||
name: App Warning Bit
|
||||
id: app_warning_bit
|
||||
update_interval: 24h
|
||||
force_update: true
|
||||
lambda: |-
|
||||
return (App.get_app_state() & STATUS_LED_WARNING) != 0 ? 1.0 : 0.0;
|
||||
- platform: template
|
||||
name: App Error Bit
|
||||
id: app_error_bit
|
||||
update_interval: 24h
|
||||
force_update: true
|
||||
lambda: |-
|
||||
return (App.get_app_state() & STATUS_LED_ERROR) != 0 ? 1.0 : 0.0;
|
||||
|
||||
# Observables for the fake status_led output.
|
||||
- platform: template
|
||||
name: Status LED Writes
|
||||
id: status_led_writes
|
||||
update_interval: 24h
|
||||
force_update: true
|
||||
lambda: return id(status_led_write_count);
|
||||
- platform: template
|
||||
name: Status LED Last State
|
||||
id: status_led_last_state
|
||||
update_interval: 24h
|
||||
force_update: true
|
||||
lambda: |-
|
||||
return id(status_led_last_write) ? 1.0 : 0.0;
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Integration test for addressable light transitions with gamma correction.
|
||||
|
||||
Regression test for a bug where a long turn-on transition on an addressable
|
||||
light with gamma correction (e.g. gamma_correct: 2.8) produced no visible
|
||||
output for ~90% of the transition duration, then jumped to the target in the
|
||||
final ~10%. Root cause: the transition algorithm read each LED's current value
|
||||
back through the 8-bit stored byte every step; at gamma 2.8 any pre-gamma value
|
||||
below ~27 rounds to stored byte 0, so the stored byte stalled at 0 until
|
||||
progress was high enough for a single step to produce a large-enough pre-gamma
|
||||
value to clear the gamma threshold.
|
||||
|
||||
The fix interpolates against a cached start color when all LEDs started at the
|
||||
same value (the common case for plain turn_on/turn_off), avoiding the round-trip.
|
||||
|
||||
This test uses a host-only mock addressable light that exposes the raw stored
|
||||
byte of each LED, so we can observe the transition directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from aioesphomeapi import LightInfo, SensorInfo, SensorState
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, require_entity
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_addressable_light_transition(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""With gamma 2.8, the stored raw byte must rise visibly well before the end."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, _ = await client.list_entities_services()
|
||||
light = require_entity(entities, "test_strip", LightInfo)
|
||||
sensor = require_entity(entities, "led0_red_raw", SensorInfo)
|
||||
|
||||
# Track the raw-byte sensor. It polls every 10ms in the fixture, and
|
||||
# ESPHome sensors publish on every change, so we collect a time series.
|
||||
# Samples are stored as absolute (loop_time, value); we rebase to the
|
||||
# command-issue time after the run so pre-command samples are strictly
|
||||
# negative and reliably excluded.
|
||||
loop = asyncio.get_running_loop()
|
||||
samples: list[tuple[float, float]] = []
|
||||
|
||||
def on_state(state: object) -> None:
|
||||
if not isinstance(state, SensorState) or state.key != sensor.key:
|
||||
return
|
||||
samples.append((loop.time(), state.state))
|
||||
|
||||
# InitialStateHelper swallows the first state ESPHome sends per entity
|
||||
# on subscribe, so on_state only sees real post-subscribe updates.
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
|
||||
# Start transition: off -> full white over 1 second. This is the
|
||||
# scenario from the bug report, compressed in time.
|
||||
transition_s = 1.0
|
||||
command_time = loop.time()
|
||||
client.light_command(
|
||||
key=light.key,
|
||||
state=True,
|
||||
rgb=(1.0, 1.0, 1.0),
|
||||
brightness=1.0,
|
||||
transition_length=transition_s,
|
||||
)
|
||||
|
||||
# Let the full transition run, plus margin for the final sample.
|
||||
await asyncio.sleep(transition_s + 0.2)
|
||||
|
||||
# Rebase to command-issue time. Pre-command samples have t < 0 and are
|
||||
# excluded; everything else is in seconds since the command was issued.
|
||||
post_command = [
|
||||
(t - command_time, v) for (t, v) in samples if t >= command_time
|
||||
]
|
||||
assert post_command, "no sensor samples received after command was issued"
|
||||
|
||||
# Assertion 1: the transition is not stalled. With the bug, the raw
|
||||
# byte stays at 0 until ~90% of the transition duration. With the fix,
|
||||
# it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76
|
||||
# clears the gamma threshold at progress ~0.30). Require the first
|
||||
# nonzero sample to land well before 50% of the transition duration,
|
||||
# measured from the command-issue time. The 50% bound (rather than
|
||||
# 70%) leaves headroom for assertion 2's mid-window check.
|
||||
first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None)
|
||||
assert first_nonzero is not None, (
|
||||
"raw byte never rose above 0 during the transition — the fade stalled"
|
||||
)
|
||||
assert first_nonzero[0] < transition_s * 0.5, (
|
||||
f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s "
|
||||
f"(>{transition_s * 0.5:.3f}s after command) — transition is stalling"
|
||||
)
|
||||
|
||||
# Assertion 2: by mid-late transition, the raw byte should have reached
|
||||
# a substantial fraction of its final value. Bound the window to
|
||||
# [50%, 90%] of the transition so the post-transition settled value
|
||||
# (which always reaches 255) can't satisfy this assertion — that would
|
||||
# let "stays at 0 then jumps at 99%" regressions slip through.
|
||||
mid_window = [
|
||||
v
|
||||
for (t, v) in post_command
|
||||
if transition_s * 0.5 <= t <= transition_s * 0.9
|
||||
]
|
||||
assert mid_window, "no samples captured in mid-transition window"
|
||||
assert max(mid_window) >= 100, (
|
||||
f"raw byte peaked at only {max(mid_window)} between 50%–90% of "
|
||||
"transition (expected >= 100 for white target at gamma 2.8)"
|
||||
)
|
||||
|
||||
# Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255.
|
||||
final_samples = [v for (_, v) in post_command[-5:]]
|
||||
assert max(final_samples) >= 250, (
|
||||
f"final raw byte was {max(final_samples)}, expected >= 250"
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Integration tests for Component::status_set/clear_warning/error propagation.
|
||||
|
||||
Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual
|
||||
components correctly updates the app-wide bits on Application::app_state_,
|
||||
AND that the status_led_light component actually responds to those bits
|
||||
by writing to its output (the full chain from component.status_set_warning
|
||||
→ App.app_state_ → status_led_light.loop() reading get_app_state()).
|
||||
|
||||
Exercises the multi-component OR semantics (the app bit stays set while
|
||||
any component still has the flag, and only clears when the last component
|
||||
clears its bit), the independence of warning and error, and the actual
|
||||
status_led_light read of the bits via a fake template output that counts
|
||||
writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# Time to let the host-mode main loop run so status_led_light.loop() can
|
||||
# execute enough iterations to produce measurable write-count changes on
|
||||
# the fake template output. 300 ms is well above the minimum needed.
|
||||
STATUS_LED_SETTLE_S = 0.3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_flags(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
entities, services = await client.list_entities_services()
|
||||
|
||||
# Map every custom API service by name for the test to execute.
|
||||
svc = {s.name: s for s in services}
|
||||
for name in (
|
||||
"set_warning_a",
|
||||
"clear_warning_a",
|
||||
"set_warning_b",
|
||||
"clear_warning_b",
|
||||
"set_error_a",
|
||||
"clear_error_a",
|
||||
"set_error_b",
|
||||
"clear_error_b",
|
||||
"snapshot_led",
|
||||
):
|
||||
assert name in svc, f"service {name} not registered"
|
||||
|
||||
# Track every sensor we care about. SensorTracker gives us
|
||||
# expect(value) / expect_any() futures that resolve when a
|
||||
# matching state arrives; much simpler than manual bookkeeping.
|
||||
tracker = SensorTracker(
|
||||
[
|
||||
"app_warning_bit",
|
||||
"app_error_bit",
|
||||
"status_led_writes",
|
||||
"status_led_last_state",
|
||||
]
|
||||
)
|
||||
tracker.key_to_sensor.update(
|
||||
build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys()))
|
||||
)
|
||||
|
||||
# Swallow initial state broadcasts so the test only reacts to
|
||||
# state changes triggered by our service calls.
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state))
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
async def call(name: str) -> None:
|
||||
await client.execute_service(svc[name], {})
|
||||
|
||||
async def call_and_expect_bits(
|
||||
service_name: str, *, warning: float, error: float
|
||||
) -> None:
|
||||
"""Execute a service and wait for both app bit sensors to match.
|
||||
|
||||
Each bit-toggling service calls component.update on both
|
||||
app_warning_bit and app_error_bit, so both sensors publish.
|
||||
"""
|
||||
futures = tracker.expect_all(
|
||||
{"app_warning_bit": warning, "app_error_bit": error}
|
||||
)
|
||||
await call(service_name)
|
||||
await tracker.await_all(futures)
|
||||
|
||||
async def snapshot_led_writes() -> int:
|
||||
"""Trigger a publish of the fake status_led output counter and return it."""
|
||||
future = tracker.expect_any("status_led_writes")
|
||||
await call("snapshot_led")
|
||||
await tracker.await_change(future, "status_led_writes")
|
||||
return int(tracker.sensor_states["status_led_writes"][-1])
|
||||
|
||||
# ---- Baseline: everything clean ----
|
||||
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
||||
|
||||
# ================================================================
|
||||
# Part 1 — STATUS_LED_WARNING propagation to App.app_state_
|
||||
# ================================================================
|
||||
|
||||
# Single component set/clear
|
||||
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
||||
|
||||
# Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone
|
||||
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0)
|
||||
|
||||
# Opposite clear order
|
||||
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
||||
|
||||
# ================================================================
|
||||
# Part 2 — STATUS_LED_ERROR propagation (same scenarios)
|
||||
# ================================================================
|
||||
|
||||
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
|
||||
await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0)
|
||||
|
||||
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
|
||||
await call_and_expect_bits("set_error_b", warning=0.0, error=1.0)
|
||||
await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0)
|
||||
await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0)
|
||||
|
||||
# ================================================================
|
||||
# Part 3 — warning and error are independent
|
||||
# ================================================================
|
||||
|
||||
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
||||
await call_and_expect_bits("set_error_b", warning=1.0, error=1.0)
|
||||
await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0)
|
||||
await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0)
|
||||
|
||||
# ================================================================
|
||||
# Part 4 — status_led_light actually reads App.app_state_
|
||||
# ================================================================
|
||||
# The fake status_led_light output increments status_led_write_count
|
||||
# on every write. status_led_light::loop() writes its output on every
|
||||
# iteration while an error/warning bit is set, so after holding a
|
||||
# warning for ~300 ms we should see the counter move significantly.
|
||||
# This is the end-to-end proof that the bits we set above actually
|
||||
# reach status_led_light and drive its behavior.
|
||||
|
||||
count_before_warning = await snapshot_led_writes()
|
||||
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
||||
# Let status_led_light's loop run long enough to toggle the pin
|
||||
# several times (it reads get_app_state() every main loop iteration).
|
||||
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
||||
count_after_warning = await snapshot_led_writes()
|
||||
assert count_after_warning > count_before_warning, (
|
||||
"status_led_light did not respond to STATUS_LED_WARNING being set: "
|
||||
f"write count stayed at {count_before_warning} → {count_after_warning}. "
|
||||
"The full chain Component::status_set_warning → App.app_state_ → "
|
||||
"status_led_light::loop reading get_app_state() is broken."
|
||||
)
|
||||
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
||||
|
||||
# Same check for ERROR
|
||||
count_before_error = await snapshot_led_writes()
|
||||
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
|
||||
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
||||
count_after_error = await snapshot_led_writes()
|
||||
assert count_after_error > count_before_error, (
|
||||
"status_led_light did not respond to STATUS_LED_ERROR being set: "
|
||||
f"write count stayed at {count_before_error} → {count_after_error}. "
|
||||
)
|
||||
await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0)
|
||||
|
||||
# ---- Set → clear → re-set round-trip ----
|
||||
# After clearing, status_led_light stops writing (steady state).
|
||||
# Re-setting the flag must make it resume. This guards against a
|
||||
# future idle optimization (e.g. #15642) where status_led disables
|
||||
# its own loop when idle: if the re-enable path were broken, the
|
||||
# second set would not produce writes.
|
||||
#
|
||||
# Snapshot AFTER the clear to avoid counting writes that were still
|
||||
# in-flight from the error-set phase.
|
||||
count_after_clear = await snapshot_led_writes()
|
||||
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
||||
count_after_idle = await snapshot_led_writes()
|
||||
assert count_after_idle - count_after_clear <= 5, (
|
||||
"status_led_light kept writing after warning/error was cleared: "
|
||||
f"count grew from {count_after_clear} to {count_after_idle}. "
|
||||
"Expected it to stop writing once all status bits were clear."
|
||||
)
|
||||
# Re-set warning — writes must resume.
|
||||
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
|
||||
await asyncio.sleep(STATUS_LED_SETTLE_S)
|
||||
count_after_reset = await snapshot_led_writes()
|
||||
assert count_after_reset > count_after_idle + 5, (
|
||||
"status_led_light did not resume writing after re-setting "
|
||||
f"STATUS_LED_WARNING: count went from {count_after_idle} to "
|
||||
f"{count_after_reset}. If an idle optimization disabled the "
|
||||
"loop, the re-enable path may be broken."
|
||||
)
|
||||
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
|
||||
Reference in New Issue
Block a user