mirror of
https://github.com/esphome/esphome.git
synced 2026-09-14 00:28:39 +00:00
Merge branch 'dev' into kamilcuk/use-placement-new
This commit is contained in:
@@ -81,18 +81,32 @@ def _get_data() -> LightData:
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def generate_gamma_table(gamma_correct: float) -> list[HexInt]:
|
||||
"""Generate a 256-entry uint16 gamma lookup table.
|
||||
|
||||
For gamma > 0, non-zero indices are clamped to a minimum of 1 to preserve
|
||||
the invariant that non-zero input always produces non-zero output. Without
|
||||
this, small brightness values (e.g. 1%) get quantized to exactly 0.0,
|
||||
which breaks zero_means_zero logic in FloatOutput.
|
||||
"""
|
||||
if gamma_correct > 0:
|
||||
return [
|
||||
HexInt(
|
||||
max(1, min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
|
||||
if i > 0
|
||||
else HexInt(0)
|
||||
)
|
||||
for i in range(256)
|
||||
]
|
||||
return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
|
||||
|
||||
|
||||
def _get_or_create_gamma_table(gamma_correct):
|
||||
data = _get_data()
|
||||
if gamma_correct in data.gamma_tables:
|
||||
return data.gamma_tables[gamma_correct]
|
||||
|
||||
if gamma_correct > 0:
|
||||
forward = [
|
||||
HexInt(min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
|
||||
for i in range(256)
|
||||
]
|
||||
else:
|
||||
forward = [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
|
||||
forward = generate_gamma_table(gamma_correct)
|
||||
|
||||
gamma_str = f"{gamma_correct}".replace(".", "_")
|
||||
fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16)
|
||||
|
||||
@@ -154,6 +154,16 @@ class LightColorValues {
|
||||
}
|
||||
|
||||
/// Convert these light color values to an CWWW representation with the given parameters.
|
||||
///
|
||||
/// Note on gamma and constant_brightness: This method operates on the raw/internal channel
|
||||
/// values stored in this object. For cold_white_ and warm_white_ specifically, these
|
||||
/// may already be gamma-uncorrected when derived from a color_temperature value.
|
||||
/// For constant_brightness=false, additional gamma for the output can be applied after
|
||||
/// this method since gamma commutes with simple multiplication. For constant_brightness=true,
|
||||
/// the caller (LightState::current_values_as_cwww) must apply gamma to the individual
|
||||
/// channel values BEFORE the balancing formula, because the nonlinear max/sum ratio does
|
||||
/// not commute with gamma. See LightState::current_values_as_cwww() for the correct
|
||||
/// implementation.
|
||||
void as_cwww(float *cold_white, float *warm_white, bool constant_brightness = false) const {
|
||||
if (this->color_mode_ & ColorCapability::COLD_WARM_WHITE) {
|
||||
const float cw_level = this->cold_white_;
|
||||
|
||||
@@ -223,12 +223,11 @@ void LightState::current_values_as_rgbw(float *red, float *green, float *blue, f
|
||||
}
|
||||
void LightState::current_values_as_rgbww(float *red, float *green, float *blue, float *cold_white, float *warm_white,
|
||||
bool constant_brightness) {
|
||||
this->current_values.as_rgbww(red, green, blue, cold_white, warm_white, constant_brightness);
|
||||
this->current_values.as_rgb(red, green, blue);
|
||||
*red = this->gamma_correct_lut(*red);
|
||||
*green = this->gamma_correct_lut(*green);
|
||||
*blue = this->gamma_correct_lut(*blue);
|
||||
*cold_white = this->gamma_correct_lut(*cold_white);
|
||||
*warm_white = this->gamma_correct_lut(*warm_white);
|
||||
this->current_values_as_cwww(cold_white, warm_white, constant_brightness);
|
||||
}
|
||||
void LightState::current_values_as_rgbct(float *red, float *green, float *blue, float *color_temperature,
|
||||
float *white_brightness) {
|
||||
@@ -241,9 +240,45 @@ void LightState::current_values_as_rgbct(float *red, float *green, float *blue,
|
||||
*white_brightness = this->gamma_correct_lut(*white_brightness);
|
||||
}
|
||||
void LightState::current_values_as_cwww(float *cold_white, float *warm_white, bool constant_brightness) {
|
||||
this->current_values.as_cwww(cold_white, warm_white, constant_brightness);
|
||||
*cold_white = this->gamma_correct_lut(*cold_white);
|
||||
*warm_white = this->gamma_correct_lut(*warm_white);
|
||||
if (!constant_brightness) {
|
||||
// Without constant_brightness, gamma commutes with simple multiplication:
|
||||
// gamma(white_level * cw) = gamma(white_level) * gamma(cw)
|
||||
// (since gamma(a*b) = (a*b)^g = a^g * b^g = gamma(a) * gamma(b))
|
||||
// so applying gamma after is mathematically equivalent and simpler.
|
||||
this->current_values.as_cwww(cold_white, warm_white, false);
|
||||
*cold_white = this->gamma_correct_lut(*cold_white);
|
||||
*warm_white = this->gamma_correct_lut(*warm_white);
|
||||
return;
|
||||
}
|
||||
|
||||
// For constant_brightness mode, gamma MUST be applied to the individual
|
||||
// channel values BEFORE the balancing formula (max/sum ratio), not after.
|
||||
//
|
||||
// Why: The cold_white_ and warm_white_ values stored in LightColorValues
|
||||
// are gamma-uncorrected (see transform_parameters_() which applies
|
||||
// gamma_uncorrect to the linear CW/WW fractions derived from color
|
||||
// temperature). Applying gamma_correct here recovers the original linear
|
||||
// fractions, which the constant_brightness formula then uses to distribute
|
||||
// power evenly. The max/sum formula ensures cold+warm PWM output sums to
|
||||
// a constant, keeping total power (and perceived brightness) the same
|
||||
// across all color temperatures.
|
||||
//
|
||||
// Applying gamma AFTER the formula would be incorrect because gamma is
|
||||
// nonlinear: gamma(a/b) != gamma(a)/gamma(b), so the carefully balanced
|
||||
// ratio would be distorted, causing a severe brightness dip at mid-range
|
||||
// color temperatures.
|
||||
const auto &v = this->current_values;
|
||||
if (!(v.get_color_mode() & ColorCapability::COLD_WARM_WHITE)) {
|
||||
*cold_white = *warm_white = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const float cw_level = this->gamma_correct_lut(v.get_cold_white());
|
||||
const float ww_level = this->gamma_correct_lut(v.get_warm_white());
|
||||
const float white_level = this->gamma_correct_lut(v.get_state() * v.get_brightness());
|
||||
const float sum = cw_level > 0 || ww_level > 0 ? cw_level + ww_level : 1; // Don't divide by zero.
|
||||
*cold_white = white_level * std::max(cw_level, ww_level) * cw_level / sum;
|
||||
*warm_white = white_level * std::max(cw_level, ww_level) * ww_level / sum;
|
||||
}
|
||||
void LightState::current_values_as_ct(float *color_temperature, float *white_brightness) {
|
||||
auto traits = this->get_traits();
|
||||
|
||||
@@ -331,11 +331,27 @@ async def to_code(config: ConfigType) -> None:
|
||||
CORE.data.setdefault(CONF_LOGGER, {})[CONF_LEVEL] = level
|
||||
tx_buffer_size = config[CONF_TX_BUFFER_SIZE]
|
||||
cg.add_define("ESPHOME_LOGGER_TX_BUFFER_SIZE", tx_buffer_size)
|
||||
log = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
baud_rate,
|
||||
)
|
||||
if CORE.is_esp32:
|
||||
# Determine task log buffer size and define USE_ESPHOME_TASK_LOG_BUFFER early
|
||||
# so the constructor can allocate the buffer immediately, preventing a race
|
||||
# where another task logs before the buffer is initialized.
|
||||
task_log_buffer_size = 0
|
||||
if CORE.is_esp32 or CORE.is_libretiny or CORE.is_nrf52:
|
||||
task_log_buffer_size = config[CONF_TASK_LOG_BUFFER_SIZE]
|
||||
elif CORE.is_host:
|
||||
task_log_buffer_size = 64 # Fixed 64 slots for host
|
||||
if task_log_buffer_size > 0:
|
||||
cg.add_define("USE_ESPHOME_TASK_LOG_BUFFER")
|
||||
log = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
baud_rate,
|
||||
task_log_buffer_size,
|
||||
)
|
||||
else:
|
||||
log = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
baud_rate,
|
||||
)
|
||||
if CORE.is_esp32 or CORE.is_host:
|
||||
cg.add(log.create_pthread_key())
|
||||
# set_uart_selection() must be called before pre_setup() because
|
||||
# pre_setup() switches on uart_ to decide which hardware to initialize
|
||||
@@ -364,17 +380,10 @@ async def _late_logger_init(config: ConfigType) -> None:
|
||||
log = await cg.get_variable(config[CONF_ID])
|
||||
level = config[CONF_LEVEL]
|
||||
baud_rate: int = config[CONF_BAUD_RATE]
|
||||
if CORE.is_esp32 or CORE.is_libretiny or CORE.is_nrf52:
|
||||
task_log_buffer_size = config[CONF_TASK_LOG_BUFFER_SIZE]
|
||||
if CORE.using_zephyr:
|
||||
task_log_buffer_size = config.get(CONF_TASK_LOG_BUFFER_SIZE, 0)
|
||||
if task_log_buffer_size > 0:
|
||||
cg.add_define("USE_ESPHOME_TASK_LOG_BUFFER")
|
||||
cg.add(log.init_log_buffer(task_log_buffer_size))
|
||||
if CORE.using_zephyr:
|
||||
zephyr_add_prj_conf("MPSC_PBUF", True)
|
||||
elif CORE.is_host:
|
||||
cg.add(log.create_pthread_key())
|
||||
cg.add_define("USE_ESPHOME_TASK_LOG_BUFFER")
|
||||
cg.add(log.init_log_buffer(64)) # Fixed 64 slots for host
|
||||
zephyr_add_prj_conf("MPSC_PBUF", True)
|
||||
|
||||
# Enable runtime tag levels if logs are configured or explicitly enabled
|
||||
logs_config = config[CONF_LOGS]
|
||||
|
||||
@@ -152,29 +152,25 @@ inline uint8_t Logger::level_for(const char *tag) {
|
||||
return this->current_level_;
|
||||
}
|
||||
|
||||
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
|
||||
Logger::Logger(uint32_t baud_rate, size_t task_log_buffer_size) : baud_rate_(baud_rate) {
|
||||
#else
|
||||
Logger::Logger(uint32_t baud_rate) : baud_rate_(baud_rate) {
|
||||
#endif
|
||||
#if defined(USE_ESP32) || defined(USE_LIBRETINY)
|
||||
this->main_task_ = xTaskGetCurrentTaskHandle();
|
||||
#elif defined(USE_ZEPHYR)
|
||||
this->main_task_ = k_current_get();
|
||||
#elif defined(USE_HOST)
|
||||
this->main_thread_ = pthread_self();
|
||||
this->main_thread_ = pthread_self();
|
||||
#endif
|
||||
}
|
||||
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
|
||||
void Logger::init_log_buffer(size_t total_buffer_size) {
|
||||
// Host uses slot count instead of byte size
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - allocated once, never freed
|
||||
this->log_buffer_ = new logger::TaskLogBuffer(total_buffer_size);
|
||||
|
||||
#if !(defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC))
|
||||
// Start with loop disabled when using task buffer
|
||||
// The loop will be enabled automatically when messages arrive
|
||||
// Zephyr with USB CDC needs loop active to poll port readiness via cdc_loop_()
|
||||
this->disable_loop_when_buffer_empty_();
|
||||
this->log_buffer_ = new logger::TaskLogBuffer(task_log_buffer_size);
|
||||
// Note: we don't disable loop here because the component isn't registered with App yet.
|
||||
// The loop self-disables on its first iteration when it finds no messages to process.
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC))
|
||||
void Logger::loop() {
|
||||
|
||||
@@ -143,9 +143,10 @@ enum UARTSelection : uint8_t {
|
||||
*/
|
||||
class Logger final : public Component {
|
||||
public:
|
||||
explicit Logger(uint32_t baud_rate);
|
||||
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
|
||||
void init_log_buffer(size_t total_buffer_size);
|
||||
explicit Logger(uint32_t baud_rate, size_t task_log_buffer_size);
|
||||
#else
|
||||
explicit Logger(uint32_t baud_rate);
|
||||
#endif
|
||||
#if defined(USE_ESPHOME_TASK_LOG_BUFFER) || (defined(USE_ZEPHYR) && defined(USE_LOGGER_UART_SELECTION_USB_CDC))
|
||||
void loop() override;
|
||||
|
||||
@@ -95,10 +95,6 @@ void PMSX003Component::loop() {
|
||||
// Just go ahead and read stuff
|
||||
break;
|
||||
}
|
||||
} else if (now - this->last_update_ < this->update_interval_) {
|
||||
// Otherwise just leave the sensor powered up and come back when we hit the update
|
||||
// time
|
||||
return;
|
||||
}
|
||||
|
||||
if (now - this->last_transmission_ >= 500) {
|
||||
@@ -114,10 +110,11 @@ void PMSX003Component::loop() {
|
||||
this->read_byte(&this->data_[this->data_index_]);
|
||||
auto check = this->check_byte_();
|
||||
if (!check.has_value()) {
|
||||
// finished
|
||||
this->parse_data_();
|
||||
if (this->update_interval_ > STABILISING_MS || now - this->last_update_ >= this->update_interval_) {
|
||||
this->parse_data_();
|
||||
this->last_update_ = now;
|
||||
}
|
||||
this->data_index_ = 0;
|
||||
this->last_update_ = now;
|
||||
} else if (!*check) {
|
||||
// wrong data
|
||||
this->data_index_ = 0;
|
||||
@@ -138,7 +135,7 @@ optional<bool> PMSX003Component::check_byte_() {
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGW(TAG, "Start character %u mismatch: 0x%02X != 0x%02X", index + 1, byte, START_CHARACTER_1);
|
||||
ESP_LOGW(TAG, "Start character %u mismatch: 0x%02X != 0x%02X", index + 1, byte, start_char);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,12 @@ static const char *const TAG = "sht4x";
|
||||
static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0};
|
||||
static const uint8_t SERIAL_NUMBER_COMMAND = 0x89;
|
||||
|
||||
void SHT4XComponent::start_heater_() {
|
||||
uint8_t cmd[] = {this->heater_command_};
|
||||
|
||||
ESP_LOGD(TAG, "Heater turning on");
|
||||
if (this->write(cmd, 1) != i2c::ERROR_OK) {
|
||||
this->status_set_error(LOG_STR("Failed to turn on heater"));
|
||||
}
|
||||
}
|
||||
// Conversion constants from SHT4x datasheet
|
||||
static constexpr float TEMPERATURE_OFFSET = -45.0f;
|
||||
static constexpr float TEMPERATURE_SPAN = 175.0f;
|
||||
static constexpr float HUMIDITY_OFFSET = -6.0f;
|
||||
static constexpr float HUMIDITY_SPAN = 125.0f;
|
||||
static constexpr float RAW_MAX = 65535.0f;
|
||||
|
||||
void SHT4XComponent::read_serial_number_() {
|
||||
uint16_t buffer[2];
|
||||
@@ -39,8 +37,8 @@ void SHT4XComponent::setup() {
|
||||
this->read_serial_number_();
|
||||
|
||||
if (std::isfinite(this->duty_cycle_) && this->duty_cycle_ > 0.0f) {
|
||||
uint32_t heater_interval = static_cast<uint32_t>(static_cast<uint16_t>(this->heater_time_) / this->duty_cycle_);
|
||||
ESP_LOGD(TAG, "Heater interval: %" PRIu32, heater_interval);
|
||||
this->heater_interval_ = static_cast<uint32_t>(static_cast<uint16_t>(this->heater_time_) / this->duty_cycle_);
|
||||
ESP_LOGD(TAG, "Heater interval: %" PRIu32, this->heater_interval_);
|
||||
|
||||
if (this->heater_power_ == SHT4X_HEATERPOWER_HIGH) {
|
||||
if (this->heater_time_ == SHT4X_HEATERTIME_LONG) {
|
||||
@@ -62,8 +60,6 @@ void SHT4XComponent::setup() {
|
||||
}
|
||||
}
|
||||
ESP_LOGD(TAG, "Heater command: %x", this->heater_command_);
|
||||
|
||||
this->set_interval(heater_interval, [this]() { this->start_heater_(); });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,19 +102,27 @@ void SHT4XComponent::update() {
|
||||
// Evaluate and publish measurements
|
||||
if (this->temp_sensor_ != nullptr) {
|
||||
// Temp is contained in the first result word
|
||||
float sensor_value_temp = buffer[0];
|
||||
float temp = -45 + 175 * sensor_value_temp / 65535;
|
||||
|
||||
float temp = TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast<float>(buffer[0]) / RAW_MAX;
|
||||
this->temp_sensor_->publish_state(temp);
|
||||
}
|
||||
|
||||
if (this->humidity_sensor_ != nullptr) {
|
||||
// Relative humidity is in the second result word
|
||||
float sensor_value_rh = buffer[1];
|
||||
float rh = -6 + 125 * sensor_value_rh / 65535;
|
||||
|
||||
float rh = HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast<float>(buffer[1]) / RAW_MAX;
|
||||
this->humidity_sensor_->publish_state(rh);
|
||||
}
|
||||
|
||||
// Fire heater after measurement to maximize cooldown time before the next reading.
|
||||
// The heater command produces a measurement that we don't need (datasheet 4.9).
|
||||
if (this->heater_interval_ > 0) {
|
||||
uint32_t now = millis();
|
||||
if (now - this->last_heater_millis_ >= this->heater_interval_) {
|
||||
ESP_LOGD(TAG, "Heater turning on");
|
||||
if (this->write_command(this->heater_command_)) {
|
||||
this->last_heater_millis_ = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,10 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri
|
||||
SHT4XHEATERTIME heater_time_;
|
||||
float duty_cycle_;
|
||||
|
||||
void start_heater_();
|
||||
void read_serial_number_();
|
||||
uint8_t heater_command_;
|
||||
uint32_t heater_interval_{0};
|
||||
uint32_t last_heater_millis_{0};
|
||||
uint32_t serial_number_;
|
||||
|
||||
sensor::Sensor *temp_sensor_{nullptr};
|
||||
|
||||
@@ -30,12 +30,17 @@ enum UARTDirection {
|
||||
const LogString *parity_to_str(UARTParityOptions parity);
|
||||
|
||||
/// Result of a flush() call.
|
||||
// Some vendor SDKs (e.g., Realtek) define SUCCESS as a macro.
|
||||
// Save and restore around the enum to avoid collisions with our scoped enum value.
|
||||
#pragma push_macro("SUCCESS")
|
||||
#undef SUCCESS
|
||||
enum class FlushResult {
|
||||
SUCCESS, ///< Confirmed: all bytes left the TX FIFO.
|
||||
TIMEOUT, ///< Confirmed: timed out before TX completed.
|
||||
FAILED, ///< Confirmed: driver or hardware error.
|
||||
ASSUMED_SUCCESS, ///< Platform cannot report result; success is assumed.
|
||||
};
|
||||
#pragma pop_macro("SUCCESS")
|
||||
|
||||
class UARTComponent {
|
||||
public:
|
||||
|
||||
@@ -6,11 +6,17 @@ namespace esphome::ultrasonic {
|
||||
|
||||
static const char *const TAG = "ultrasonic.sensor";
|
||||
|
||||
static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us of each other (noise filtering)
|
||||
static constexpr uint32_t START_DELAY_US = 100; // Ignore edges within 100us of trigger (filters bleed-through)
|
||||
static constexpr uint32_t START_TIMEOUT_US = 40000; // Maximum time to wait for echo pulse to start
|
||||
|
||||
void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) {
|
||||
uint32_t now = micros();
|
||||
if (arg->echo_pin_isr.digital_read()) {
|
||||
// Ignore edges after measurement complete or too soon after trigger pulse
|
||||
if (arg->echo_end || (now - arg->measurement_start_us) <= START_DELAY_US) {
|
||||
return;
|
||||
}
|
||||
if (!arg->echo_start || (now - arg->echo_start_us) <= DEBOUNCE_US) {
|
||||
arg->echo_start_us = now;
|
||||
arg->echo_start = true;
|
||||
} else {
|
||||
@@ -21,15 +27,14 @@ void IRAM_ATTR UltrasonicSensorStore::gpio_intr(UltrasonicSensorStore *arg) {
|
||||
|
||||
void IRAM_ATTR UltrasonicSensorComponent::send_trigger_pulse_() {
|
||||
InterruptLock lock;
|
||||
this->store_.echo_start_us = 0;
|
||||
this->store_.echo_end_us = 0;
|
||||
this->store_.echo_start = false;
|
||||
this->store_.echo_end = false;
|
||||
this->store_.measurement_start_us = micros();
|
||||
this->trigger_pin_isr_.digital_write(true);
|
||||
delayMicroseconds(this->pulse_time_us_);
|
||||
this->trigger_pin_isr_.digital_write(false);
|
||||
this->measurement_pending_ = true;
|
||||
this->measurement_start_us_ = micros();
|
||||
this->measurement_start_us_ = this->store_.measurement_start_us;
|
||||
}
|
||||
|
||||
void UltrasonicSensorComponent::setup() {
|
||||
@@ -37,7 +42,6 @@ void UltrasonicSensorComponent::setup() {
|
||||
this->trigger_pin_->digital_write(false);
|
||||
this->trigger_pin_isr_ = this->trigger_pin_->to_isr();
|
||||
this->echo_pin_->setup();
|
||||
this->store_.echo_pin_isr = this->echo_pin_->to_isr();
|
||||
this->echo_pin_->attach_interrupt(UltrasonicSensorStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE);
|
||||
}
|
||||
|
||||
@@ -77,17 +81,10 @@ void UltrasonicSensorComponent::loop() {
|
||||
}
|
||||
|
||||
if (this->store_.echo_end) {
|
||||
float result;
|
||||
if (this->store_.echo_start) {
|
||||
uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us;
|
||||
ESP_LOGV(TAG, "pulse start took %" PRIu32 "us, echo took %" PRIu32 "us",
|
||||
this->store_.echo_start_us - this->measurement_start_us_, pulse_duration);
|
||||
result = UltrasonicSensorComponent::us_to_m(pulse_duration);
|
||||
ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - pulse end before pulse start, does the echo pin need to be inverted?", this->name_.c_str());
|
||||
result = NAN;
|
||||
}
|
||||
uint32_t pulse_duration = this->store_.echo_end_us - this->store_.echo_start_us;
|
||||
ESP_LOGV(TAG, "Echo took %" PRIu32 "us", pulse_duration);
|
||||
float result = UltrasonicSensorComponent::us_to_m(pulse_duration);
|
||||
ESP_LOGD(TAG, "'%s' - Got distance: %.3f m", this->name_.c_str(), result);
|
||||
this->publish_state(result);
|
||||
this->measurement_pending_ = false;
|
||||
return;
|
||||
|
||||
@@ -11,8 +11,7 @@ namespace esphome::ultrasonic {
|
||||
struct UltrasonicSensorStore {
|
||||
static void gpio_intr(UltrasonicSensorStore *arg);
|
||||
|
||||
ISRInternalGPIOPin echo_pin_isr;
|
||||
volatile uint32_t wait_start_us{0};
|
||||
volatile uint32_t measurement_start_us{0};
|
||||
volatile uint32_t echo_start_us{0};
|
||||
volatile uint32_t echo_end_us{0};
|
||||
volatile bool echo_start{false};
|
||||
|
||||
@@ -871,9 +871,6 @@ void WiFiComponent::loop() {
|
||||
|
||||
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
|
||||
|
||||
bool WiFiComponent::has_ap() const { return this->has_ap_; }
|
||||
bool WiFiComponent::is_ap_active() const { return this->ap_started_; }
|
||||
bool WiFiComponent::has_sta() const { return !this->sta_.empty(); }
|
||||
#ifdef USE_WIFI_11KV_SUPPORT
|
||||
void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
|
||||
void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
|
||||
@@ -2250,8 +2247,6 @@ bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; }
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
|
||||
#endif
|
||||
uint8_t WiFiAP::get_channel() const { return this->channel_; }
|
||||
bool WiFiAP::has_channel() const { return this->channel_ != 0; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const optional<ManualIP> &WiFiAP::get_manual_ip() const { return this->manual_ip_; }
|
||||
#endif
|
||||
|
||||
@@ -263,8 +263,8 @@ class WiFiAP {
|
||||
#ifdef USE_WIFI_WPA2_EAP
|
||||
const optional<EAPAuth> &get_eap() const;
|
||||
#endif // USE_WIFI_WPA2_EAP
|
||||
uint8_t get_channel() const;
|
||||
bool has_channel() const;
|
||||
uint8_t get_channel() const { return this->channel_; }
|
||||
bool has_channel() const { return this->channel_ != 0; }
|
||||
int8_t get_priority() const { return priority_; }
|
||||
#ifdef USE_WIFI_MANUAL_IP
|
||||
const optional<ManualIP> &get_manual_ip() const;
|
||||
@@ -470,9 +470,9 @@ class WiFiComponent final : public Component {
|
||||
/// Reconnect WiFi if required.
|
||||
void loop() override;
|
||||
|
||||
bool has_sta() const;
|
||||
bool has_ap() const;
|
||||
bool is_ap_active() const;
|
||||
bool has_sta() const { return !this->sta_.empty(); }
|
||||
bool has_ap() const { return this->has_ap_; }
|
||||
bool is_ap_active() const { return this->ap_started_; }
|
||||
|
||||
#ifdef USE_WIFI_11KV_SUPPORT
|
||||
void set_btm(bool btm);
|
||||
|
||||
@@ -26,7 +26,7 @@ void setup() {
|
||||
|
||||
// Log functions call global_logger->log_vprintf_() without a null check,
|
||||
// so we must set up a Logger before any test that triggers logging.
|
||||
static esphome::logger::Logger test_logger(0);
|
||||
static esphome::logger::Logger test_logger(0, 64);
|
||||
test_logger.set_log_level(ESPHOME_LOG_LEVEL);
|
||||
test_logger.pre_setup();
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ void original_setup() {
|
||||
void setup() {
|
||||
// Log functions call global_logger->log_vprintf_() without a null check,
|
||||
// so we must set up a Logger before any test that triggers logging.
|
||||
static esphome::logger::Logger test_logger(0);
|
||||
static esphome::logger::Logger test_logger(0, 64);
|
||||
test_logger.set_log_level(ESPHOME_LOG_LEVEL);
|
||||
test_logger.pre_setup();
|
||||
|
||||
|
||||
@@ -6,4 +6,8 @@ sensor:
|
||||
humidity:
|
||||
name: SHT4X Humidity
|
||||
address: 0x44
|
||||
precision: High
|
||||
heater_max_duty: 0.02
|
||||
heater_power: High
|
||||
heater_time: Long
|
||||
update_interval: 15s
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
uart:
|
||||
- id: uart_id
|
||||
tx_pin: PA23
|
||||
rx_pin: PA18
|
||||
baud_rate: 9600
|
||||
data_bits: 8
|
||||
parity: NONE
|
||||
stop_bits: 1
|
||||
|
||||
switch:
|
||||
- platform: uart
|
||||
name: "UART Switch"
|
||||
uart_id: uart_id
|
||||
data: [0x01, 0x02, 0x03]
|
||||
@@ -0,0 +1,57 @@
|
||||
esphome:
|
||||
name: light-cb-test
|
||||
host:
|
||||
api: # Port will be automatically injected
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: cb_cold_white_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "CB_CW_OUTPUT:%.6f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: cb_warm_white_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "CB_WW_OUTPUT:%.6f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: ncb_cold_white_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "NCB_CW_OUTPUT:%.6f"
|
||||
args: [state]
|
||||
- platform: template
|
||||
id: ncb_warm_white_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log:
|
||||
format: "NCB_WW_OUTPUT:%.6f"
|
||||
args: [state]
|
||||
|
||||
light:
|
||||
- platform: cwww
|
||||
name: "Test CB Light"
|
||||
id: test_cb_light
|
||||
cold_white: cb_cold_white_output
|
||||
warm_white: cb_warm_white_output
|
||||
cold_white_color_temperature: 6536 K
|
||||
warm_white_color_temperature: 2000 K
|
||||
constant_brightness: true
|
||||
gamma_correct: 2.8
|
||||
|
||||
- platform: cwww
|
||||
name: "Test NCB Light"
|
||||
id: test_ncb_light
|
||||
cold_white: ncb_cold_white_output
|
||||
warm_white: ncb_warm_white_output
|
||||
cold_white_color_temperature: 6536 K
|
||||
warm_white_color_temperature: 2000 K
|
||||
constant_brightness: false
|
||||
gamma_correct: 2.8
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Integration test for constant_brightness with gamma correction.
|
||||
|
||||
Tests both constant_brightness: true and false cwww lights with gamma
|
||||
correction in a single compilation to verify:
|
||||
- constant_brightness: true maintains constant total CW+WW power output
|
||||
- constant_brightness: false correctly varies total power across color temps
|
||||
|
||||
This is a regression test for https://github.com/esphome/esphome/issues/15040
|
||||
where the gamma LUT refactor (#14123) broke constant_brightness by applying
|
||||
gamma after the balancing formula instead of before it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import EntityState, LightInfo, LightState
|
||||
import pytest
|
||||
|
||||
from .state_utils import InitialStateHelper
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_light_constant_brightness(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test constant_brightness true and false behavior with gamma correction."""
|
||||
# Track output values for both lights from log lines
|
||||
cb_cw_pattern = re.compile(r"(?<!N)CB_CW_OUTPUT:([\d.]+)")
|
||||
cb_ww_pattern = re.compile(r"(?<!N)CB_WW_OUTPUT:([\d.]+)")
|
||||
ncb_cw_pattern = re.compile(r"NCB_CW_OUTPUT:([\d.]+)")
|
||||
ncb_ww_pattern = re.compile(r"NCB_WW_OUTPUT:([\d.]+)")
|
||||
|
||||
latest: dict[str, float] = {
|
||||
"cb_cw": 0.0,
|
||||
"cb_ww": 0.0,
|
||||
"ncb_cw": 0.0,
|
||||
"ncb_ww": 0.0,
|
||||
}
|
||||
|
||||
def on_log_line(line: str) -> None:
|
||||
for pattern, key in [
|
||||
(cb_cw_pattern, "cb_cw"),
|
||||
(cb_ww_pattern, "cb_ww"),
|
||||
(ncb_cw_pattern, "ncb_cw"),
|
||||
(ncb_ww_pattern, "ncb_ww"),
|
||||
]:
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
latest[key] = float(match.group(1))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=on_log_line),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
entities, _ = await client.list_entities_services()
|
||||
lights = [e for e in entities if isinstance(e, LightInfo)]
|
||||
cb_light = next(e for e in lights if e.object_id.endswith("cb_light"))
|
||||
ncb_light = next(e for e in lights if e.object_id.endswith("ncb_light"))
|
||||
|
||||
# Use InitialStateHelper to wait for initial state broadcast
|
||||
initial_state_helper = InitialStateHelper(entities)
|
||||
|
||||
# Track state changes per light key
|
||||
state_futures: dict[int, asyncio.Future[EntityState]] = {}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, LightState) and state.key in state_futures:
|
||||
future = state_futures[state.key]
|
||||
if not future.done():
|
||||
future.set_result(state)
|
||||
|
||||
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
|
||||
|
||||
try:
|
||||
await initial_state_helper.wait_for_initial_states()
|
||||
except TimeoutError:
|
||||
pytest.fail("Timeout waiting for initial states")
|
||||
|
||||
async def send_and_wait(
|
||||
light_key: int, timeout: float = 5.0, **kwargs: Any
|
||||
) -> LightState:
|
||||
"""Send a light command and wait for the state response."""
|
||||
state_futures[light_key] = loop.create_future()
|
||||
client.light_command(key=light_key, **kwargs)
|
||||
try:
|
||||
return await asyncio.wait_for(state_futures[light_key], timeout=timeout)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"Timeout waiting for light state after command: {kwargs}")
|
||||
|
||||
# --- Test constant_brightness: true ---
|
||||
|
||||
# Turn on CB light at full brightness
|
||||
await send_and_wait(
|
||||
cb_light.key,
|
||||
state=True,
|
||||
brightness=1.0,
|
||||
color_temperature=153.0,
|
||||
transition_length=0,
|
||||
)
|
||||
|
||||
test_mireds = [
|
||||
153.0, # Pure cold white
|
||||
200.0, # Mostly cold
|
||||
280.0, # Mixed
|
||||
326.5, # Midpoint
|
||||
400.0, # Mostly warm
|
||||
500.0, # Pure warm white
|
||||
]
|
||||
|
||||
cb_totals: list[tuple[float, float, float]] = []
|
||||
for mireds in test_mireds:
|
||||
await send_and_wait(
|
||||
cb_light.key, color_temperature=mireds, transition_length=0
|
||||
)
|
||||
cb_totals.append((mireds, latest["cb_cw"], latest["cb_ww"]))
|
||||
|
||||
# All totals should be approximately equal (constant brightness)
|
||||
reference_total = next((cw + ww for _, cw, ww in cb_totals if cw + ww > 0), 0)
|
||||
assert reference_total > 0, (
|
||||
f"Reference total power is zero, CB light outputs not working. "
|
||||
f"Values: {cb_totals}"
|
||||
)
|
||||
|
||||
for mireds, cw, ww in cb_totals:
|
||||
total = cw + ww
|
||||
assert total == pytest.approx(reference_total, rel=0.05), (
|
||||
f"constant_brightness: Total power at {mireds} mireds "
|
||||
f"({total:.4f}) differs from reference ({reference_total:.4f}) "
|
||||
f"by more than 5%. CW={cw:.4f}, WW={ww:.4f}. "
|
||||
f"All values: {cb_totals}"
|
||||
)
|
||||
|
||||
# --- Test constant_brightness: false ---
|
||||
|
||||
# Turn on NCB light at full brightness
|
||||
await send_and_wait(
|
||||
ncb_light.key,
|
||||
state=True,
|
||||
brightness=1.0,
|
||||
color_temperature=153.0,
|
||||
transition_length=0,
|
||||
)
|
||||
|
||||
ncb_totals: list[tuple[float, float, float]] = []
|
||||
for mireds in test_mireds:
|
||||
await send_and_wait(
|
||||
ncb_light.key, color_temperature=mireds, transition_length=0
|
||||
)
|
||||
ncb_totals.append((mireds, latest["ncb_cw"], latest["ncb_ww"]))
|
||||
|
||||
extreme_cw = ncb_totals[0] # 153 mireds - pure cold
|
||||
extreme_ww = ncb_totals[-1] # 500 mireds - pure warm
|
||||
midpoint = ncb_totals[3] # 326.5 mireds - midpoint
|
||||
|
||||
# At pure cold white, WW should be ~0
|
||||
assert extreme_cw[2] == pytest.approx(0.0, abs=0.01), (
|
||||
f"Pure cold white should have WW~0, got WW={extreme_cw[2]:.4f}"
|
||||
)
|
||||
# At pure warm white, CW should be ~0
|
||||
assert extreme_ww[1] == pytest.approx(0.0, abs=0.01), (
|
||||
f"Pure warm white should have CW~0, got CW={extreme_ww[1]:.4f}"
|
||||
)
|
||||
|
||||
# At midpoint, both channels should be non-zero
|
||||
assert midpoint[1] > 0.05, f"Midpoint CW should be >0.05, got {midpoint[1]:.4f}"
|
||||
assert midpoint[2] > 0.05, f"Midpoint WW should be >0.05, got {midpoint[2]:.4f}"
|
||||
|
||||
# Total power at midpoint should be higher than at the extremes
|
||||
midpoint_total = midpoint[1] + midpoint[2]
|
||||
extreme_cw_total = extreme_cw[1] + extreme_cw[2]
|
||||
extreme_ww_total = extreme_ww[1] + extreme_ww[2]
|
||||
|
||||
assert midpoint_total > extreme_cw_total, (
|
||||
f"Midpoint total ({midpoint_total:.4f}) should be > pure CW total "
|
||||
f"({extreme_cw_total:.4f}). All values: {ncb_totals}"
|
||||
)
|
||||
assert midpoint_total > extreme_ww_total, (
|
||||
f"Midpoint total ({midpoint_total:.4f}) should be > pure WW total "
|
||||
f"({extreme_ww_total:.4f}). All values: {ncb_totals}"
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for the gamma LUT table generation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.light import generate_gamma_table
|
||||
|
||||
|
||||
def _simulate_gamma_correct_lut(table: list[int], value: float) -> float:
|
||||
"""Simulate the C++ gamma_correct_lut interpolation from light_state.cpp."""
|
||||
if value <= 0.0:
|
||||
return 0.0
|
||||
if value >= 1.0:
|
||||
return 1.0
|
||||
scaled = value * 255.0
|
||||
idx = int(scaled)
|
||||
if idx >= 255:
|
||||
return table[255] / 65535.0
|
||||
frac = scaled - idx
|
||||
a = float(table[idx])
|
||||
b = float(table[idx + 1])
|
||||
return (a + frac * (b - a)) / 65535.0
|
||||
|
||||
|
||||
def test_table_length() -> None:
|
||||
"""Table must always have exactly 256 entries."""
|
||||
table = generate_gamma_table(2.8)
|
||||
assert len(table) == 256
|
||||
|
||||
|
||||
def test_index_zero_is_zero() -> None:
|
||||
"""Index 0 must be 0 so true off remains off."""
|
||||
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
|
||||
table = generate_gamma_table(gamma)
|
||||
assert table[0] == 0, f"gamma={gamma}"
|
||||
|
||||
|
||||
def test_index_255_is_max() -> None:
|
||||
"""Index 255 must be 65535 (full on)."""
|
||||
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
|
||||
table = generate_gamma_table(gamma)
|
||||
assert table[255] == 65535, f"gamma={gamma}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
|
||||
def test_nonzero_indices_are_nonzero(gamma: float) -> None:
|
||||
"""All indices > 0 must produce non-zero values.
|
||||
|
||||
This prevents zero_means_zero breakage: non-zero input must always
|
||||
produce non-zero output so FloatOutput applies min_power scaling.
|
||||
"""
|
||||
table = generate_gamma_table(gamma)
|
||||
for i in range(1, 256):
|
||||
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
|
||||
def test_table_monotonically_nondecreasing(gamma: float) -> None:
|
||||
"""The gamma table must be monotonically non-decreasing."""
|
||||
table = generate_gamma_table(gamma)
|
||||
for i in range(1, 256):
|
||||
assert table[i] >= table[i - 1], (
|
||||
f"gamma={gamma}: table[{i}]={table[i]} < table[{i - 1}]={table[i - 1]}"
|
||||
)
|
||||
|
||||
|
||||
def test_linear_gamma() -> None:
|
||||
"""With gamma=0 (linear), table should be evenly spaced."""
|
||||
table = generate_gamma_table(0)
|
||||
assert table[0] == 0
|
||||
assert table[128] == round(128 / 255.0 * 65535)
|
||||
assert table[255] == 65535
|
||||
|
||||
|
||||
@pytest.mark.parametrize("brightness", [0.01, 0.005, 0.001, 1 / 255])
|
||||
def test_small_brightness_nonzero_after_lut(brightness: float) -> None:
|
||||
"""Small but non-zero brightness must produce non-zero output through the LUT.
|
||||
|
||||
Regression test for #15055: with zero_means_zero=true, a gamma-corrected
|
||||
value of exactly 0.0 causes FloatOutput to skip min_power scaling, turning
|
||||
the LED off instead of to minimum brightness.
|
||||
"""
|
||||
table = generate_gamma_table(2.8)
|
||||
result = _simulate_gamma_correct_lut(table, brightness)
|
||||
assert result > 0.0, (
|
||||
f"brightness={brightness}: gamma LUT returned 0.0, would break zero_means_zero"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
|
||||
def test_small_brightness_nonzero_all_gammas(gamma: float) -> None:
|
||||
"""1% brightness must be non-zero for all common gamma values."""
|
||||
table = generate_gamma_table(gamma)
|
||||
result = _simulate_gamma_correct_lut(table, 0.01)
|
||||
assert result > 0.0, f"gamma={gamma}: 1% brightness returned 0.0"
|
||||
|
||||
|
||||
def test_lut_zero_returns_zero() -> None:
|
||||
"""LUT with input 0.0 must return 0.0."""
|
||||
table = generate_gamma_table(2.8)
|
||||
assert _simulate_gamma_correct_lut(table, 0.0) == 0.0
|
||||
|
||||
|
||||
def test_lut_one_returns_one() -> None:
|
||||
"""LUT with input 1.0 must return 1.0."""
|
||||
table = generate_gamma_table(2.8)
|
||||
assert _simulate_gamma_correct_lut(table, 1.0) == 1.0
|
||||
|
||||
|
||||
def test_lut_output_monotonically_nondecreasing() -> None:
|
||||
"""LUT output must be monotonically non-decreasing across the full range."""
|
||||
table = generate_gamma_table(2.8)
|
||||
prev = 0.0
|
||||
for i in range(1001):
|
||||
value = i / 1000.0
|
||||
result = _simulate_gamma_correct_lut(table, value)
|
||||
assert result >= prev, f"value={value}: result {result} < previous {prev}"
|
||||
prev = result
|
||||
Reference in New Issue
Block a user