From 742d724e652f8d1716c4e551c69eb08de2885bfa Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:16:55 -0500 Subject: [PATCH 01/12] [seeed_mr24hpc1] Add ifdef guards for conditional entity types (#13147) Co-authored-by: Claude Opus 4.5 --- .../seeed_mr24hpc1/seeed_mr24hpc1.cpp | 432 +++++++++++------- 1 file changed, 273 insertions(+), 159 deletions(-) diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 4c0416d727..08d83f9390 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -64,15 +64,21 @@ void MR24HPC1Component::dump_config() { void MR24HPC1Component::setup() { this->check_uart_settings(115200); +#ifdef USE_NUMBER if (this->custom_mode_number_ != nullptr) { this->custom_mode_number_->publish_state(0); // Zero out the custom mode } +#endif +#ifdef USE_SENSOR if (this->custom_mode_num_sensor_ != nullptr) { this->custom_mode_num_sensor_->publish_state(0); } +#endif +#ifdef USE_TEXT_SENSOR if (this->custom_mode_end_text_sensor_ != nullptr) { this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); } +#endif this->set_custom_end_mode(); this->poll_time_base_func_check_ = true; this->check_dev_inf_sign_ = true; @@ -353,6 +359,7 @@ void MR24HPC1Component::r24_split_data_frame_(uint8_t value) { // Parses data frames related to product information void MR24HPC1Component::r24_frame_parse_product_information_(uint8_t *data) { +#ifdef USE_TEXT_SENSOR uint16_t product_len = encode_uint16(data[FRAME_COMMAND_WORD_INDEX + 1], data[FRAME_COMMAND_WORD_INDEX + 2]); if (data[FRAME_COMMAND_WORD_INDEX] == COMMAND_PRODUCT_MODE) { if ((this->product_model_text_sensor_ != nullptr) && (product_len < PRODUCT_BUF_MAX_SIZE)) { @@ -388,109 +395,153 @@ void MR24HPC1Component::r24_frame_parse_product_information_(uint8_t *data) { ESP_LOGD(TAG, "Reply: get firmwareVersion error!"); } } +#endif } // Parsing the underlying open parameters void MR24HPC1Component::r24_frame_parse_open_underlying_information_(uint8_t *data) { - if (data[FRAME_COMMAND_WORD_INDEX] == 0x00) { - if (this->underlying_open_function_switch_ != nullptr) { - this->underlying_open_function_switch_->publish_state( - data[FRAME_DATA_INDEX]); // Underlying Open Parameter Switch Status Updates - } - if (data[FRAME_DATA_INDEX]) { - this->s_output_info_switch_flag_ = OUTPUT_SWITCH_ON; - } else { - this->s_output_info_switch_flag_ = OUTPUT_SWTICH_OFF; - } - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x01) { - if (this->custom_spatial_static_value_sensor_ != nullptr) { - this->custom_spatial_static_value_sensor_->publish_state(data[FRAME_DATA_INDEX]); - } - if (this->custom_presence_of_detection_sensor_ != nullptr) { - this->custom_presence_of_detection_sensor_->publish_state(data[FRAME_DATA_INDEX + 1] * 0.5f); - } - if (this->custom_spatial_motion_value_sensor_ != nullptr) { - this->custom_spatial_motion_value_sensor_->publish_state(data[FRAME_DATA_INDEX + 2]); - } - if (this->custom_motion_distance_sensor_ != nullptr) { - this->custom_motion_distance_sensor_->publish_state(data[FRAME_DATA_INDEX + 3] * 0.5f); - } - if (this->custom_motion_speed_sensor_ != nullptr) { - this->custom_motion_speed_sensor_->publish_state((data[FRAME_DATA_INDEX + 4] - 10) * 0.5f); - } - } else if ((data[FRAME_COMMAND_WORD_INDEX] == 0x06) || (data[FRAME_COMMAND_WORD_INDEX] == 0x86)) { - // none:0x00 close_to:0x01 far_away:0x02 - if ((this->keep_away_text_sensor_ != nullptr) && (data[FRAME_DATA_INDEX] < 3)) { - this->keep_away_text_sensor_->publish_state(S_KEEP_AWAY_STR[data[FRAME_DATA_INDEX]]); - } - } else if ((this->movement_signs_sensor_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x07) || (data[FRAME_COMMAND_WORD_INDEX] == 0x87))) { - this->movement_signs_sensor_->publish_state(data[FRAME_DATA_INDEX]); - } else if ((this->existence_threshold_number_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x08) || (data[FRAME_COMMAND_WORD_INDEX] == 0x88))) { - this->existence_threshold_number_->publish_state(data[FRAME_DATA_INDEX]); - } else if ((this->motion_threshold_number_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x09) || (data[FRAME_COMMAND_WORD_INDEX] == 0x89))) { - this->motion_threshold_number_->publish_state(data[FRAME_DATA_INDEX]); - } else if ((this->existence_boundary_select_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x0a) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8a))) { - if (this->existence_boundary_select_->has_index(data[FRAME_DATA_INDEX] - 1)) { - this->existence_boundary_select_->publish_state(data[FRAME_DATA_INDEX] - 1); - } - } else if ((this->motion_boundary_select_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x0b) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8b))) { - if (this->motion_boundary_select_->has_index(data[FRAME_DATA_INDEX] - 1)) { - this->motion_boundary_select_->publish_state(data[FRAME_DATA_INDEX] - 1); - } - } else if ((this->motion_trigger_number_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x0c) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8c))) { - uint32_t motion_trigger_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], - data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); - this->motion_trigger_number_->publish_state(motion_trigger_time); - } else if ((this->motion_to_rest_number_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x0d) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8d))) { - uint32_t move_to_rest_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], - data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); - this->motion_to_rest_number_->publish_state(move_to_rest_time); - } else if ((this->custom_unman_time_number_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x0e) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8e))) { - uint32_t enter_unmanned_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], - data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); - float custom_unmanned_time = enter_unmanned_time / 1000.0; - this->custom_unman_time_number_->publish_state(custom_unmanned_time); - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x80) { - if (data[FRAME_DATA_INDEX]) { - this->s_output_info_switch_flag_ = OUTPUT_SWITCH_ON; - } else { - this->s_output_info_switch_flag_ = OUTPUT_SWTICH_OFF; - } - if (this->underlying_open_function_switch_ != nullptr) { - this->underlying_open_function_switch_->publish_state(data[FRAME_DATA_INDEX]); - } - } else if ((this->custom_spatial_static_value_sensor_ != nullptr) && (data[FRAME_COMMAND_WORD_INDEX] == 0x81)) { - this->custom_spatial_static_value_sensor_->publish_state(data[FRAME_DATA_INDEX]); - } else if ((this->custom_spatial_motion_value_sensor_ != nullptr) && (data[FRAME_COMMAND_WORD_INDEX] == 0x82)) { - this->custom_spatial_motion_value_sensor_->publish_state(data[FRAME_DATA_INDEX]); - } else if ((this->custom_presence_of_detection_sensor_ != nullptr) && (data[FRAME_COMMAND_WORD_INDEX] == 0x83)) { - this->custom_presence_of_detection_sensor_->publish_state( - S_PRESENCE_OF_DETECTION_RANGE_STR[data[FRAME_DATA_INDEX]]); - } else if ((this->custom_motion_distance_sensor_ != nullptr) && (data[FRAME_COMMAND_WORD_INDEX] == 0x84)) { - this->custom_motion_distance_sensor_->publish_state(data[FRAME_DATA_INDEX] * 0.5f); - } else if ((this->custom_motion_speed_sensor_ != nullptr) && (data[FRAME_COMMAND_WORD_INDEX] == 0x85)) { - this->custom_motion_speed_sensor_->publish_state((data[FRAME_DATA_INDEX] - 10) * 0.5f); + switch (data[FRAME_COMMAND_WORD_INDEX]) { + case 0x00: + case 0x80: +#ifdef USE_SWITCH + if (this->underlying_open_function_switch_ != nullptr) { + this->underlying_open_function_switch_->publish_state(data[FRAME_DATA_INDEX]); + } +#endif + this->s_output_info_switch_flag_ = data[FRAME_DATA_INDEX] ? OUTPUT_SWITCH_ON : OUTPUT_SWTICH_OFF; + break; +#ifdef USE_SENSOR + case 0x01: + if (this->custom_spatial_static_value_sensor_ != nullptr) { + this->custom_spatial_static_value_sensor_->publish_state(data[FRAME_DATA_INDEX]); + } + if (this->custom_presence_of_detection_sensor_ != nullptr) { + this->custom_presence_of_detection_sensor_->publish_state(data[FRAME_DATA_INDEX + 1] * 0.5f); + } + if (this->custom_spatial_motion_value_sensor_ != nullptr) { + this->custom_spatial_motion_value_sensor_->publish_state(data[FRAME_DATA_INDEX + 2]); + } + if (this->custom_motion_distance_sensor_ != nullptr) { + this->custom_motion_distance_sensor_->publish_state(data[FRAME_DATA_INDEX + 3] * 0.5f); + } + if (this->custom_motion_speed_sensor_ != nullptr) { + this->custom_motion_speed_sensor_->publish_state((data[FRAME_DATA_INDEX + 4] - 10) * 0.5f); + } + break; + case 0x07: + case 0x87: + if (this->movement_signs_sensor_ != nullptr) { + this->movement_signs_sensor_->publish_state(data[FRAME_DATA_INDEX]); + } + break; + case 0x81: + if (this->custom_spatial_static_value_sensor_ != nullptr) { + this->custom_spatial_static_value_sensor_->publish_state(data[FRAME_DATA_INDEX]); + } + break; + case 0x82: + if (this->custom_spatial_motion_value_sensor_ != nullptr) { + this->custom_spatial_motion_value_sensor_->publish_state(data[FRAME_DATA_INDEX]); + } + break; + case 0x83: + if (this->custom_presence_of_detection_sensor_ != nullptr) { + this->custom_presence_of_detection_sensor_->publish_state( + S_PRESENCE_OF_DETECTION_RANGE_STR[data[FRAME_DATA_INDEX]]); + } + break; + case 0x84: + if (this->custom_motion_distance_sensor_ != nullptr) { + this->custom_motion_distance_sensor_->publish_state(data[FRAME_DATA_INDEX] * 0.5f); + } + break; + case 0x85: + if (this->custom_motion_speed_sensor_ != nullptr) { + this->custom_motion_speed_sensor_->publish_state((data[FRAME_DATA_INDEX] - 10) * 0.5f); + } + break; +#endif +#ifdef USE_TEXT_SENSOR + case 0x06: + case 0x86: + // none:0x00 close_to:0x01 far_away:0x02 + if ((this->keep_away_text_sensor_ != nullptr) && (data[FRAME_DATA_INDEX] < 3)) { + this->keep_away_text_sensor_->publish_state(S_KEEP_AWAY_STR[data[FRAME_DATA_INDEX]]); + } + break; +#endif +#ifdef USE_NUMBER + case 0x08: + case 0x88: + if (this->existence_threshold_number_ != nullptr) { + this->existence_threshold_number_->publish_state(data[FRAME_DATA_INDEX]); + } + break; + case 0x09: + case 0x89: + if (this->motion_threshold_number_ != nullptr) { + this->motion_threshold_number_->publish_state(data[FRAME_DATA_INDEX]); + } + break; + case 0x0c: + case 0x8c: + if (this->motion_trigger_number_ != nullptr) { + uint32_t motion_trigger_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], + data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); + this->motion_trigger_number_->publish_state(motion_trigger_time); + } + break; + case 0x0d: + case 0x8d: + if (this->motion_to_rest_number_ != nullptr) { + uint32_t move_to_rest_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], + data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); + this->motion_to_rest_number_->publish_state(move_to_rest_time); + } + break; + case 0x0e: + case 0x8e: + if (this->custom_unman_time_number_ != nullptr) { + uint32_t enter_unmanned_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], + data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); + this->custom_unman_time_number_->publish_state(enter_unmanned_time / 1000.0f); + } + break; +#endif +#ifdef USE_SELECT + case 0x0a: + case 0x8a: + if (this->existence_boundary_select_ != nullptr) { + if (this->existence_boundary_select_->has_index(data[FRAME_DATA_INDEX] - 1)) { + this->existence_boundary_select_->publish_state(data[FRAME_DATA_INDEX] - 1); + } + } + break; + case 0x0b: + case 0x8b: + if (this->motion_boundary_select_ != nullptr) { + if (this->motion_boundary_select_->has_index(data[FRAME_DATA_INDEX] - 1)) { + this->motion_boundary_select_->publish_state(data[FRAME_DATA_INDEX] - 1); + } + } + break; +#endif } } void MR24HPC1Component::r24_parse_data_frame_(uint8_t *data, uint8_t len) { switch (data[FRAME_CONTROL_WORD_INDEX]) { case 0x01: { - if ((this->heartbeat_state_text_sensor_ != nullptr) && (data[FRAME_COMMAND_WORD_INDEX] == 0x01)) { - this->heartbeat_state_text_sensor_->publish_state("Equipment Normal"); - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x02) { + if (data[FRAME_COMMAND_WORD_INDEX] == 0x02) { ESP_LOGD(TAG, "Reply: query restart packet"); - } else if (this->heartbeat_state_text_sensor_ != nullptr) { - this->heartbeat_state_text_sensor_->publish_state("Equipment Abnormal"); + break; } +#ifdef USE_TEXT_SENSOR + if (this->heartbeat_state_text_sensor_ != nullptr) { + this->heartbeat_state_text_sensor_->publish_state( + data[FRAME_COMMAND_WORD_INDEX] == 0x01 ? "Equipment Normal" : "Equipment Abnormal"); + } +#endif } break; case 0x02: { this->r24_frame_parse_product_information_(data); @@ -511,86 +562,123 @@ void MR24HPC1Component::r24_parse_data_frame_(uint8_t *data, uint8_t len) { } void MR24HPC1Component::r24_frame_parse_work_status_(uint8_t *data) { - if (data[FRAME_COMMAND_WORD_INDEX] == 0x01) { - ESP_LOGD(TAG, "Reply: get radar init status 0x%02X", data[FRAME_DATA_INDEX]); - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x07) { - if ((this->scene_mode_select_ != nullptr) && (this->scene_mode_select_->has_index(data[FRAME_DATA_INDEX]))) { - this->scene_mode_select_->publish_state(data[FRAME_DATA_INDEX]); - } else { - ESP_LOGD(TAG, "Select has index offset %d Error", data[FRAME_DATA_INDEX]); - } - } else if ((this->sensitivity_number_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x08) || (data[FRAME_COMMAND_WORD_INDEX] == 0x88))) { - // 1-3 - this->sensitivity_number_->publish_state(data[FRAME_DATA_INDEX]); - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x09) { - // 1-4 - if (this->custom_mode_num_sensor_ != nullptr) { - this->custom_mode_num_sensor_->publish_state(data[FRAME_DATA_INDEX]); - } - if (this->custom_mode_number_ != nullptr) { - this->custom_mode_number_->publish_state(0); - } - if (this->custom_mode_end_text_sensor_ != nullptr) { - this->custom_mode_end_text_sensor_->publish_state("Setup in progress"); - } - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x81) { - ESP_LOGD(TAG, "Reply: get radar init status 0x%02X", data[FRAME_DATA_INDEX]); - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x87) { - if ((this->scene_mode_select_ != nullptr) && (this->scene_mode_select_->has_index(data[FRAME_DATA_INDEX]))) { - this->scene_mode_select_->publish_state(data[FRAME_DATA_INDEX]); - } else { - ESP_LOGD(TAG, "Select has index offset %d Error", data[FRAME_DATA_INDEX]); - } - } else if ((this->custom_mode_end_text_sensor_ != nullptr) && (data[FRAME_COMMAND_WORD_INDEX] == 0x0A)) { - this->custom_mode_end_text_sensor_->publish_state("Set Success!"); - } else if (data[FRAME_COMMAND_WORD_INDEX] == 0x89) { - if (data[FRAME_DATA_INDEX] == 0) { - if (this->custom_mode_end_text_sensor_ != nullptr) { - this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); + switch (data[FRAME_COMMAND_WORD_INDEX]) { + case 0x01: + case 0x81: + ESP_LOGD(TAG, "Reply: get radar init status 0x%02X", data[FRAME_DATA_INDEX]); + break; + case 0x09: +#ifdef USE_SENSOR + if (this->custom_mode_num_sensor_ != nullptr) { + this->custom_mode_num_sensor_->publish_state(data[FRAME_DATA_INDEX]); } +#endif +#ifdef USE_NUMBER if (this->custom_mode_number_ != nullptr) { this->custom_mode_number_->publish_state(0); } +#endif +#ifdef USE_TEXT_SENSOR + if (this->custom_mode_end_text_sensor_ != nullptr) { + this->custom_mode_end_text_sensor_->publish_state("Setup in progress"); + } +#endif + break; + case 0x89: +#ifdef USE_SENSOR if (this->custom_mode_num_sensor_ != nullptr) { this->custom_mode_num_sensor_->publish_state(data[FRAME_DATA_INDEX]); } - } else { - if (this->custom_mode_num_sensor_ != nullptr) { - this->custom_mode_num_sensor_->publish_state(data[FRAME_DATA_INDEX]); +#endif + if (data[FRAME_DATA_INDEX] == 0) { +#ifdef USE_TEXT_SENSOR + if (this->custom_mode_end_text_sensor_ != nullptr) { + this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); + } +#endif +#ifdef USE_NUMBER + if (this->custom_mode_number_ != nullptr) { + this->custom_mode_number_->publish_state(0); + } +#endif } - } - } else { - ESP_LOGD(TAG, "[%s] No found COMMAND_WORD(%02X) in Frame", __FUNCTION__, data[FRAME_COMMAND_WORD_INDEX]); + break; +#ifdef USE_SELECT + case 0x07: + case 0x87: + if ((this->scene_mode_select_ != nullptr) && (this->scene_mode_select_->has_index(data[FRAME_DATA_INDEX]))) { + this->scene_mode_select_->publish_state(data[FRAME_DATA_INDEX]); + } else { + ESP_LOGD(TAG, "Select has index offset %d Error", data[FRAME_DATA_INDEX]); + } + break; +#endif +#ifdef USE_NUMBER + case 0x08: + case 0x88: + if (this->sensitivity_number_ != nullptr) { + this->sensitivity_number_->publish_state(data[FRAME_DATA_INDEX]); + } + break; +#endif +#ifdef USE_TEXT_SENSOR + case 0x0A: + if (this->custom_mode_end_text_sensor_ != nullptr) { + this->custom_mode_end_text_sensor_->publish_state("Set Success!"); + } + break; +#endif + default: + ESP_LOGD(TAG, "[%s] No found COMMAND_WORD(%02X) in Frame", __FUNCTION__, data[FRAME_COMMAND_WORD_INDEX]); + break; } } void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { - if ((this->has_target_binary_sensor_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x01) || (data[FRAME_COMMAND_WORD_INDEX] == 0x81))) { - this->has_target_binary_sensor_->publish_state(S_SOMEONE_EXISTS_STR[data[FRAME_DATA_INDEX]]); - } else if ((this->motion_status_text_sensor_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x02) || (data[FRAME_COMMAND_WORD_INDEX] == 0x82))) { - if (data[FRAME_DATA_INDEX] < 3) { - this->motion_status_text_sensor_->publish_state(S_MOTION_STATUS_STR[data[FRAME_DATA_INDEX]]); - } - } else if ((this->movement_signs_sensor_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x03) || (data[FRAME_COMMAND_WORD_INDEX] == 0x83))) { - this->movement_signs_sensor_->publish_state(data[FRAME_DATA_INDEX]); - } else if ((this->unman_time_select_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x0A) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8A))) { - // none:0x00 1s:0x01 30s:0x02 1min:0x03 2min:0x04 5min:0x05 10min:0x06 30min:0x07 1hour:0x08 - if (data[FRAME_DATA_INDEX] < 9) { - this->unman_time_select_->publish_state(data[FRAME_DATA_INDEX]); - } - } else if ((this->keep_away_text_sensor_ != nullptr) && - ((data[FRAME_COMMAND_WORD_INDEX] == 0x0B) || (data[FRAME_COMMAND_WORD_INDEX] == 0x8B))) { - // none:0x00 close_to:0x01 far_away:0x02 - if (data[FRAME_DATA_INDEX] < 3) { - this->keep_away_text_sensor_->publish_state(S_KEEP_AWAY_STR[data[FRAME_DATA_INDEX]]); - } - } else { - ESP_LOGD(TAG, "[%s] No found COMMAND_WORD(%02X) in Frame", __FUNCTION__, data[FRAME_COMMAND_WORD_INDEX]); + switch (data[FRAME_COMMAND_WORD_INDEX]) { +#ifdef USE_BINARY_SENSOR + case 0x01: + case 0x81: + if (this->has_target_binary_sensor_ != nullptr) { + this->has_target_binary_sensor_->publish_state(S_SOMEONE_EXISTS_STR[data[FRAME_DATA_INDEX]]); + } + break; +#endif +#ifdef USE_SENSOR + case 0x03: + case 0x83: + if (this->movement_signs_sensor_ != nullptr) { + this->movement_signs_sensor_->publish_state(data[FRAME_DATA_INDEX]); + } + break; +#endif +#ifdef USE_TEXT_SENSOR + case 0x02: + case 0x82: + if ((this->motion_status_text_sensor_ != nullptr) && (data[FRAME_DATA_INDEX] < 3)) { + this->motion_status_text_sensor_->publish_state(S_MOTION_STATUS_STR[data[FRAME_DATA_INDEX]]); + } + break; + case 0x0B: + case 0x8B: + // none:0x00 close_to:0x01 far_away:0x02 + if ((this->keep_away_text_sensor_ != nullptr) && (data[FRAME_DATA_INDEX] < 3)) { + this->keep_away_text_sensor_->publish_state(S_KEEP_AWAY_STR[data[FRAME_DATA_INDEX]]); + } + break; +#endif +#ifdef USE_SELECT + case 0x0A: + case 0x8A: + // none:0x00 1s:0x01 30s:0x02 1min:0x03 2min:0x04 5min:0x05 10min:0x06 30min:0x07 1hour:0x08 + if ((this->unman_time_select_ != nullptr) && (data[FRAME_DATA_INDEX] < 9)) { + this->unman_time_select_->publish_state(data[FRAME_DATA_INDEX]); + } + break; +#endif + default: + ESP_LOGD(TAG, "[%s] No found COMMAND_WORD(%02X) in Frame", __FUNCTION__, data[FRAME_COMMAND_WORD_INDEX]); + break; } } @@ -695,12 +783,15 @@ void MR24HPC1Component::set_underlying_open_function(bool enable) { } else { this->send_query_(UNDERLYING_SWITCH_OFF, sizeof(UNDERLYING_SWITCH_OFF)); } +#ifdef USE_TEXT_SENSOR if (this->keep_away_text_sensor_ != nullptr) { this->keep_away_text_sensor_->publish_state(""); } if (this->motion_status_text_sensor_ != nullptr) { this->motion_status_text_sensor_->publish_state(""); } +#endif +#ifdef USE_SENSOR if (this->custom_spatial_static_value_sensor_ != nullptr) { this->custom_spatial_static_value_sensor_->publish_state(NAN); } @@ -716,6 +807,7 @@ void MR24HPC1Component::set_underlying_open_function(bool enable) { if (this->custom_motion_speed_sensor_ != nullptr) { this->custom_motion_speed_sensor_->publish_state(NAN); } +#endif } void MR24HPC1Component::set_scene_mode(uint8_t value) { @@ -723,12 +815,16 @@ void MR24HPC1Component::set_scene_mode(uint8_t value) { uint8_t send_data[10] = {0x53, 0x59, 0x05, 0x07, 0x00, 0x01, value, 0x00, 0x54, 0x43}; send_data[7] = get_frame_crc_sum(send_data, send_data_len); this->send_query_(send_data, send_data_len); +#ifdef USE_NUMBER if (this->custom_mode_number_ != nullptr) { this->custom_mode_number_->publish_state(0); } +#endif +#ifdef USE_SENSOR if (this->custom_mode_num_sensor_ != nullptr) { this->custom_mode_num_sensor_->publish_state(0); } +#endif this->get_scene_mode(); this->get_sensitivity(); this->get_custom_mode(); @@ -768,9 +864,11 @@ void MR24HPC1Component::set_unman_time(uint8_t value) { void MR24HPC1Component::set_custom_mode(uint8_t mode) { if (mode == 0) { this->set_custom_end_mode(); // Equivalent to end setting +#ifdef USE_NUMBER if (this->custom_mode_number_ != nullptr) { this->custom_mode_number_->publish_state(0); } +#endif return; } uint8_t send_data_len = 10; @@ -793,9 +891,11 @@ void MR24HPC1Component::set_custom_end_mode() { uint8_t send_data_len = 10; uint8_t send_data[10] = {0x53, 0x59, 0x05, 0x0a, 0x00, 0x01, 0x0F, 0xCB, 0x54, 0x43}; this->send_query_(send_data, send_data_len); +#ifdef USE_NUMBER if (this->custom_mode_number_ != nullptr) { this->custom_mode_number_->publish_state(0); // Clear setpoints } +#endif this->get_existence_boundary(); this->get_motion_boundary(); this->get_existence_threshold(); @@ -809,8 +909,10 @@ void MR24HPC1Component::set_custom_end_mode() { } void MR24HPC1Component::set_existence_boundary(uint8_t value) { +#ifdef USE_SENSOR if ((this->custom_mode_num_sensor_ != nullptr) && (this->custom_mode_num_sensor_->state == 0)) return; // You'll have to check that you're in custom mode to set it up +#endif uint8_t send_data_len = 10; uint8_t send_data[10] = {0x53, 0x59, 0x08, 0x0A, 0x00, 0x01, (uint8_t) (value + 1), 0x00, 0x54, 0x43}; send_data[7] = get_frame_crc_sum(send_data, send_data_len); @@ -819,8 +921,10 @@ void MR24HPC1Component::set_existence_boundary(uint8_t value) { } void MR24HPC1Component::set_motion_boundary(uint8_t value) { +#ifdef USE_SENSOR if ((this->custom_mode_num_sensor_ != nullptr) && (this->custom_mode_num_sensor_->state == 0)) return; // You'll have to check that you're in custom mode to set it up +#endif uint8_t send_data_len = 10; uint8_t send_data[10] = {0x53, 0x59, 0x08, 0x0B, 0x00, 0x01, (uint8_t) (value + 1), 0x00, 0x54, 0x43}; send_data[7] = get_frame_crc_sum(send_data, send_data_len); @@ -829,8 +933,10 @@ void MR24HPC1Component::set_motion_boundary(uint8_t value) { } void MR24HPC1Component::set_existence_threshold(uint8_t value) { +#ifdef USE_SENSOR if ((this->custom_mode_num_sensor_ != nullptr) && (this->custom_mode_num_sensor_->state == 0)) return; // You'll have to check that you're in custom mode to set it up +#endif uint8_t send_data_len = 10; uint8_t send_data[10] = {0x53, 0x59, 0x08, 0x08, 0x00, 0x01, value, 0x00, 0x54, 0x43}; send_data[7] = get_frame_crc_sum(send_data, send_data_len); @@ -839,8 +945,10 @@ void MR24HPC1Component::set_existence_threshold(uint8_t value) { } void MR24HPC1Component::set_motion_threshold(uint8_t value) { +#ifdef USE_SENSOR if ((this->custom_mode_num_sensor_ != nullptr) && (this->custom_mode_num_sensor_->state == 0)) return; // You'll have to check that you're in custom mode to set it up +#endif uint8_t send_data_len = 10; uint8_t send_data[10] = {0x53, 0x59, 0x08, 0x09, 0x00, 0x01, value, 0x00, 0x54, 0x43}; send_data[7] = get_frame_crc_sum(send_data, send_data_len); @@ -849,8 +957,10 @@ void MR24HPC1Component::set_motion_threshold(uint8_t value) { } void MR24HPC1Component::set_motion_trigger_time(uint8_t value) { +#ifdef USE_SENSOR if ((this->custom_mode_num_sensor_ != nullptr) && (this->custom_mode_num_sensor_->state == 0)) return; // You'll have to check that you're in custom mode to set it up +#endif uint8_t send_data_len = 13; uint8_t send_data[13] = {0x53, 0x59, 0x08, 0x0C, 0x00, 0x04, 0x00, 0x00, 0x00, value, 0x00, 0x54, 0x43}; send_data[10] = get_frame_crc_sum(send_data, send_data_len); @@ -859,8 +969,10 @@ void MR24HPC1Component::set_motion_trigger_time(uint8_t value) { } void MR24HPC1Component::set_motion_to_rest_time(uint16_t value) { +#ifdef USE_SENSOR if ((this->custom_mode_num_sensor_ != nullptr) && (this->custom_mode_num_sensor_->state == 0)) return; // You'll have to check that you're in custom mode to set it up +#endif uint8_t h8_num = (value >> 8) & 0xff; uint8_t l8_num = value & 0xff; uint8_t send_data_len = 13; @@ -871,8 +983,10 @@ void MR24HPC1Component::set_motion_to_rest_time(uint16_t value) { } void MR24HPC1Component::set_custom_unman_time(uint16_t value) { +#ifdef USE_SENSOR if ((this->custom_mode_num_sensor_ != nullptr) && (this->custom_mode_num_sensor_->state == 0)) return; // You'll have to check that you're in custom mode to set it up +#endif uint32_t value_ms = value * 1000; uint8_t h24_num = (value_ms >> 24) & 0xff; uint8_t h16_num = (value_ms >> 16) & 0xff; From 68064dc974fc62aaeb464ebaae3838e62d6a7f66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:17:07 -1000 Subject: [PATCH 02/12] [web_server] Fix v1 compilation on ESP-IDF by adding missing write method (#13153) --- esphome/components/web_server_idf/web_server_idf.h | 1 + tests/components/web_server/test_v1.esp32-idf.yaml | 1 + 2 files changed, 2 insertions(+) create mode 100644 tests/components/web_server/test_v1.esp32-idf.yaml diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 5f9f598388..cae7006d96 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -80,6 +80,7 @@ class AsyncResponseStream : public AsyncWebServerResponse { void print(const std::string &str) { this->content_.append(str); } void print(float value); void printf(const char *fmt, ...) __attribute__((format(printf, 2, 3))); + void write(uint8_t c) { this->content_.push_back(static_cast(c)); } protected: std::string content_; diff --git a/tests/components/web_server/test_v1.esp32-idf.yaml b/tests/components/web_server/test_v1.esp32-idf.yaml new file mode 100644 index 0000000000..389a930284 --- /dev/null +++ b/tests/components/web_server/test_v1.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common_v1.yaml From 909bd1074affdd5dab29a8fbee49eda742b5ed6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:17:18 -1000 Subject: [PATCH 03/12] [wifi] Fix captive portal/improv only attempting last configured network (#13086) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 117 ++++++++++++++++----- esphome/components/wifi/wifi_component.h | 12 ++- 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 352081fe31..ff6284c073 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -48,7 +48,7 @@ static const char *const TAG = "wifi"; /// The WiFi component uses a state machine with priority degradation to handle connection failures /// and automatically cycle through different BSSIDs in mesh networks or multiple configured networks. /// -/// Connection Flow: +/// Normal Connection Flow (SCAN_BASED): /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Fast Connect Path (Optional) │ /// ├──────────────────────────────────────────────────────────────────────┤ @@ -109,10 +109,13 @@ static const char *const TAG = "wifi"; /// │ (Skip Hidden1/Hidden2, try Hidden3 from example) │ /// │ - If none → Skip RETRY_HIDDEN, go to step 5 │ /// │ ↓ │ -/// │ 5. FAILED → RESTARTING_ADAPTER (skipped if AP/improv active) │ +/// │ 5. FAILED → RESTARTING_ADAPTER │ +/// │ - Normal: restart adapter, clear state │ +/// │ - AP/improv active: skip restart, just disconnect │ /// │ ↓ │ /// │ 6. Loop back to start: │ /// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ +/// │ - If AP/improv active → RETRY_HIDDEN (blind retry, see below) │ /// │ - Otherwise → SCAN_CONNECTING (rescan) │ /// │ ↓ │ /// │ 7. RESCAN → Apply stored priorities, sort again │ @@ -134,8 +137,10 @@ static const char *const TAG = "wifi"; /// - FAST_CONNECT_CYCLING_APS: Cycle through remaining configured networks (1 attempt each, fast_connect only) /// - EXPLICIT_HIDDEN: Try consecutive networks marked hidden:true before scanning (1 attempt per SSID) /// - SCAN_CONNECTING: Connect using scan results (2 attempts per BSSID) -/// - RETRY_HIDDEN: Try networks not found in scan (1 attempt per SSID, skipped if none found) -/// - RESTARTING_ADAPTER: Restart WiFi adapter to clear stuck state +/// - RETRY_HIDDEN: Behavior controlled by RetryHiddenMode: +/// * SCAN_BASED: Try networks not found in scan (truly hidden, 1 attempt per SSID) +/// * BLIND_RETRY: Cycle through ALL networks when scanning disabled (AP active) +/// - RESTARTING_ADAPTER: Restart WiFi adapter to clear stuck state (restart skipped if AP active) /// /// Hidden Network Handling: /// - Networks marked 'hidden: true' before first non-hidden → Tried in EXPLICIT_HIDDEN phase @@ -146,6 +151,35 @@ static const char *const TAG = "wifi"; /// - Networks marked 'hidden: true' always use hidden mode, even if broadcasting SSID /// /// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Captive Portal / Improv Mode (AP active, scanning disabled) │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ When captive_portal or esp32_improv is active, WiFi scanning is │ +/// │ disabled because it disrupts AP clients (radio leaves AP channel │ +/// │ to hop through other channels, causing client disconnections). │ +/// │ │ +/// │ Flow with RetryHiddenMode::BLIND_RETRY: │ +/// │ │ +/// │ 1. RESTARTING_ADAPTER → In this mode, skip adapter restart and │ +/// │ just disconnect (normal mode restarts the adapter) │ +/// │ - Sets retry_hidden_mode_ = BLIND_RETRY │ +/// │ - Enter extended cooldown (30s vs normal 500ms) │ +/// │ ↓ │ +/// │ 2. determine_next_phase_() returns RETRY_HIDDEN (skips scanning) │ +/// │ ↓ │ +/// │ 3. RETRY_HIDDEN with BLIND_RETRY mode: │ +/// │ - find_next_hidden_sta_() ignores scan_result_ │ +/// │ - ALL configured networks become candidates │ +/// │ - Cycles through networks: Net1 → Net2 → Net3 → ... │ +/// │ ↓ │ +/// │ 4. After exhausting all networks → Back to RESTARTING_ADAPTER │ +/// │ - Loop continues until connection succeeds or user configures │ +/// │ new credentials via captive portal │ +/// │ │ +/// │ The 30s cooldown gives users time to interact with captive portal │ +/// │ without constant connection attempts disrupting the AP. │ +/// └──────────────────────────────────────────────────────────────────────┘ +/// +/// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Post-Connect Roaming (for stationary devices) │ /// ├──────────────────────────────────────────────────────────────────────┤ /// │ Purpose: Handle AP reboot or power loss scenarios where device │ @@ -332,7 +366,23 @@ bool WiFiComponent::ssid_was_seen_in_scan_(const std::string &ssid) const { } int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) { - // Find next SSID that wasn't in scan results (might be hidden) + // Find next SSID to try in RETRY_HIDDEN phase. + // + // This function operates in two modes based on retry_hidden_mode_: + // + // 1. SCAN_BASED mode: + // After SCAN_CONNECTING phase, only returns networks that were NOT visible + // in the scan (truly hidden networks that need probe requests). + // + // 2. BLIND_RETRY mode: + // When captive portal/improv is active, scanning is skipped to avoid + // disrupting the AP. In this mode, ALL configured networks are returned + // as candidates, cycling through them sequentially. This allows the device + // to keep trying all networks while users configure WiFi via captive portal. + // + // Additionally, if EXPLICIT_HIDDEN phase was executed (first network marked hidden:true), + // those networks are skipped here since they were already tried. + // bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_(); // Start searching from start_index + 1 for (size_t i = start_index + 1; i < this->sta_.size(); i++) { @@ -349,9 +399,9 @@ int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) { } } - // If we didn't scan this cycle, treat all networks as potentially hidden - // Otherwise, only retry networks that weren't seen in the scan - if (!this->did_scan_this_cycle_ || !this->ssid_was_seen_in_scan_(sta.get_ssid())) { + // In BLIND_RETRY mode, treat all networks as candidates + // In SCAN_BASED mode, only retry networks that weren't seen in the scan + if (this->retry_hidden_mode_ == RetryHiddenMode::BLIND_RETRY || !this->ssid_was_seen_in_scan_(sta.get_ssid())) { ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast(i)); return static_cast(i); } @@ -1158,7 +1208,7 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; - this->did_scan_this_cycle_ = true; + this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { ESP_LOGW(TAG, "No networks found"); @@ -1463,8 +1513,23 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { if (this->went_through_explicit_hidden_phase_()) { return WiFiRetryPhase::EXPLICIT_HIDDEN; } - // Skip scanning when captive portal/improv is active to avoid disrupting AP - // Even passive scans can cause brief AP disconnections on ESP32 + // Skip scanning when captive portal/improv is active to avoid disrupting AP. + // + // WHY SCANNING DISRUPTS AP MODE: + // WiFi scanning requires the radio to leave the AP's channel and hop through + // other channels to listen for beacons. During this time (even for passive scans), + // the AP cannot service connected clients - they experience disconnections or + // timeouts. On ESP32, even passive scans cause brief but noticeable disruptions + // that break captive portal HTTP requests and DNS lookups. + // + // BLIND RETRY MODE: + // When captive portal/improv is active, we use RETRY_HIDDEN as a "try all networks + // blindly" mode. Since retry_hidden_mode_ is set to BLIND_RETRY (in RESTARTING_ADAPTER + // transition), find_next_hidden_sta_() will treat ALL configured networks as + // candidates, cycling through them without requiring scan results. + // + // This allows users to configure WiFi via captive portal while the device keeps + // attempting to connect to all configured networks in sequence. if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { return WiFiRetryPhase::RETRY_HIDDEN; } @@ -1533,19 +1598,19 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { break; case WiFiRetryPhase::RETRY_HIDDEN: - // Starting hidden mode - find first SSID that wasn't in scan results - if (old_phase == WiFiRetryPhase::SCAN_CONNECTING) { - // Keep scan results so we can skip SSIDs that were visible in the scan - // Don't clear scan_result_ - we need it to know which SSIDs are NOT hidden + // Always reset to first candidate when entering this phase. + // This phase can be entered from: + // - SCAN_CONNECTING: normal flow, find_next_hidden_sta_() skips networks visible in scan + // - RESTARTING_ADAPTER: captive portal active, find_next_hidden_sta_() tries ALL networks + // + // The retry_hidden_mode_ controls the behavior: + // - SCAN_BASED: scan_result_ is checked, visible networks are skipped + // - BLIND_RETRY: scan_result_ is ignored, all networks become candidates + // We don't clear scan_result_ here - the mode controls whether it's consulted. + this->selected_sta_index_ = this->find_next_hidden_sta_(-1); - // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase - // In that case, skip networks marked hidden:true (already tried) - // Otherwise, include them (they haven't been tried yet) - this->selected_sta_index_ = this->find_next_hidden_sta_(-1); - - if (this->selected_sta_index_ == -1) { - ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode"); - } + if (this->selected_sta_index_ == -1) { + ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode"); } break; @@ -1561,7 +1626,11 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle - this->did_scan_this_cycle_ = false; + // This is critical for captive portal/improv flow: when determine_next_phase_() + // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_() + // will see BLIND_RETRY mode and treat ALL networks as candidates, + // effectively cycling through all configured networks without scan results. + this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY; // Always enter cooldown after restart (or skip-restart) to allow stabilization // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 9b606bd692..b4c4a622d5 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -124,6 +124,16 @@ enum class RoamingState : uint8_t { RECONNECTING, }; +/// Controls how RETRY_HIDDEN phase selects networks to try +enum class RetryHiddenMode : uint8_t { + /// Normal mode: scan completed, only try networks NOT visible in scan results + /// (truly hidden networks that need probe requests) + SCAN_BASED, + /// Blind retry mode: scanning disabled (captive portal/improv active), + /// try ALL configured networks sequentially without consulting scan results + BLIND_RETRY, +}; + /// Struct for setting static IPs in WiFiComponent. struct ManualIP { network::IPAddress static_ip; @@ -676,7 +686,7 @@ class WiFiComponent : public Component { bool enable_on_boot_{true}; bool got_ipv4_address_{false}; bool keep_scan_results_{false}; - bool did_scan_this_cycle_{false}; + RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY}; bool skip_cooldown_next_cycle_{false}; bool post_connect_roaming_{true}; // Enabled by default RoamingState roaming_state_{RoamingState::IDLE}; From 723ca57617548887bd8b08dfc66f74cbdccadd9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:17:32 -1000 Subject: [PATCH 04/12] [uptime] Format text sensor output on stack to avoid heap allocations (#13150) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../uptime/text_sensor/uptime_text_sensor.cpp | 94 ++++++++++++------- .../uptime/text_sensor/uptime_text_sensor.h | 1 - 2 files changed, 62 insertions(+), 33 deletions(-) diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index 94585379fe..b7b3273f39 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -9,6 +9,19 @@ namespace uptime { static const char *const TAG = "uptime.sensor"; +// Clamp position to valid buffer range when snprintf indicates truncation +static size_t clamp_buffer_pos(size_t pos, size_t buf_size) { return pos < buf_size ? pos : buf_size - 1; } + +static void append_unit(char *buf, size_t buf_size, size_t &pos, const char *separator, unsigned value, + const char *label) { + if (pos > 0) { + pos += snprintf(buf + pos, buf_size - pos, "%s", separator); + pos = clamp_buffer_pos(pos, buf_size); + } + pos += snprintf(buf + pos, buf_size - pos, "%u%s", value, label); + pos = clamp_buffer_pos(pos, buf_size); +} + void UptimeTextSensor::setup() { this->last_ms_ = millis(); if (this->last_ms_ < 60 * 1000) @@ -16,11 +29,6 @@ void UptimeTextSensor::setup() { this->update(); } -void UptimeTextSensor::insert_buffer_(std::string &buffer, const char *key, unsigned value) const { - buffer.insert(0, this->separator_); - buffer.insert(0, str_sprintf("%u%s", value, key)); -} - void UptimeTextSensor::update() { auto now = millis(); // get whole seconds since last update. Note that even if the millis count has overflowed between updates, @@ -29,36 +37,58 @@ void UptimeTextSensor::update() { this->last_ms_ = now - delta % 1000; // save remainder for next update delta /= 1000; this->uptime_ += delta; - auto uptime = this->uptime_; + uint32_t uptime = this->uptime_; unsigned interval = this->get_update_interval() / 1000; - std::string buffer{}; - // display from the largest unit that corresponds to the update interval, drop larger units that are zero. - while (true) { // enable use of break for early exit - unsigned remainder = uptime % 60; - uptime /= 60; - if (interval < 30) { - this->insert_buffer_(buffer, this->seconds_text_, remainder); - if (!this->expand_ && uptime == 0) - break; + + // Calculate all time units + unsigned seconds = uptime % 60; + uptime /= 60; + unsigned minutes = uptime % 60; + uptime /= 60; + unsigned hours = uptime % 24; + uptime /= 24; + unsigned days = uptime; + + // Determine which units to display based on interval thresholds + bool seconds_enabled = interval < 30; + bool minutes_enabled = interval < 1800; + bool hours_enabled = interval < 12 * 3600; + + // Show from highest non-zero unit (or all in expand mode) down to smallest enabled + bool show_days = this->expand_ || days > 0; + bool show_hours = hours_enabled && (show_days || hours > 0); + bool show_minutes = minutes_enabled && (show_hours || minutes > 0); + bool show_seconds = seconds_enabled && (show_minutes || seconds > 0); + + // If nothing shown, show smallest enabled unit + if (!show_days && !show_hours && !show_minutes && !show_seconds) { + if (seconds_enabled) { + show_seconds = true; + } else if (minutes_enabled) { + show_minutes = true; + } else if (hours_enabled) { + show_hours = true; + } else { + show_days = true; } - remainder = uptime % 60; - uptime /= 60; - if (interval < 1800) { - this->insert_buffer_(buffer, this->minutes_text_, remainder); - if (!this->expand_ && uptime == 0) - break; - } - remainder = uptime % 24; - uptime /= 24; - if (interval < 12 * 3600) { - this->insert_buffer_(buffer, this->hours_text_, remainder); - if (!this->expand_ && uptime == 0) - break; - } - this->insert_buffer_(buffer, this->days_text_, (unsigned) uptime); - break; } - this->publish_state(buffer); + + // Build output string on stack + // Home Assistant max state length is 255 chars + null terminator + char buf[256]; + size_t pos = 0; + buf[0] = '\0'; // Initialize for empty case + + if (show_days) + append_unit(buf, sizeof(buf), pos, this->separator_, days, this->days_text_); + if (show_hours) + append_unit(buf, sizeof(buf), pos, this->separator_, hours, this->hours_text_); + if (show_minutes) + append_unit(buf, sizeof(buf), pos, this->separator_, minutes, this->minutes_text_); + if (show_seconds) + append_unit(buf, sizeof(buf), pos, this->separator_, seconds, this->seconds_text_); + + this->publish_state(buf); } float UptimeTextSensor::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.h b/esphome/components/uptime/text_sensor/uptime_text_sensor.h index 8dd058998c..947d9c91e9 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.h +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.h @@ -29,7 +29,6 @@ class UptimeTextSensor : public text_sensor::TextSensor, public PollingComponent void set_seconds(const char *seconds_text) { this->seconds_text_ = seconds_text; } protected: - void insert_buffer_(std::string &buffer, const char *key, unsigned value) const; const char *days_text_; const char *hours_text_; const char *minutes_text_; From 6a3737bac3443f4d62cd2d793cdc4afa7c34bf0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:17:44 -1000 Subject: [PATCH 05/12] [improv_serial] Use int8_to_str to avoid heap allocation for RSSI formatting (#13149) --- .../components/improv_serial/improv_serial_component.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 936ff414b1..17d630fe83 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -267,8 +267,10 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command if (std::find(networks.begin(), networks.end(), ssid) != networks.end()) continue; // Send each ssid separately to avoid overflowing the buffer - std::vector data = improv::build_rpc_response( - improv::GET_WIFI_NETWORKS, {ssid, str_sprintf("%d", scan.get_rssi()), YESNO(scan.get_with_auth())}, false); + char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null + *int8_to_str(rssi_buf, scan.get_rssi()) = '\0'; + std::vector data = + improv::build_rpc_response(improv::GET_WIFI_NETWORKS, {ssid, rssi_buf, YESNO(scan.get_with_auth())}, false); this->send_response_(data); networks.push_back(ssid); } From 684790c2aba71738fb0cbbb326bb1c00d8849dca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:17:57 -1000 Subject: [PATCH 06/12] [web_server_idf] Reduce heap usage in DefaultHeaders and auth (#13141) --- .../components/web_server_base/__init__.py | 2 ++ .../web_server_base/web_server_base.h | 2 ++ .../web_server_idf/web_server_idf.cpp | 32 +++++++++++++------ .../web_server_idf/web_server_idf.h | 11 +++++-- esphome/core/defines.h | 1 + 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 4cf76eba0e..d5d75b395d 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -34,6 +34,8 @@ async def to_code(config): cg.add(cg.RawExpression(f"{web_server_base_ns}::global_web_server_base = {var}")) if CORE.is_esp32: + # Count for StaticVector in web_server_idf - matches headers added in init() + cg.add_define("WEB_SERVER_DEFAULT_HEADERS_COUNT", 1) return if CORE.using_arduino: diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 7e95e00f29..0c25467f1b 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -100,6 +100,8 @@ class WebServerBase : public Component { } this->server_ = std::make_unique(this->port_); // All content is controlled and created by user - so allowing all origins is fine here. + // NOTE: Currently 1 header. If more are added, update in __init__.py: + // cg.add_define("WEB_SERVER_DEFAULT_HEADERS_COUNT", 1) DefaultHeaders::Instance().addHeader(ESPHOME_F("Access-Control-Allow-Origin"), ESPHOME_F("*")); this->server_->begin(); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 5062aa1e6c..55d2040a3a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -309,8 +309,8 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } httpd_resp_set_hdr(*this, "Accept-Ranges", "none"); - for (const auto &pair : DefaultHeaders::Instance().headers_) { - httpd_resp_set_hdr(*this, pair.first.c_str(), pair.second.c_str()); + for (const auto &header : DefaultHeaders::Instance().headers_) { + httpd_resp_set_hdr(*this, header.name, header.value); } delete this->rsp_; @@ -335,17 +335,29 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw return false; } - std::string user_info; - user_info += username; - user_info += ':'; - user_info += password; + // Build user:pass in stack buffer to avoid heap allocation + constexpr size_t max_user_info_len = 256; + char user_info[max_user_info_len]; + size_t user_len = strlen(username); + size_t pass_len = strlen(password); + size_t user_info_len = user_len + 1 + pass_len; + + if (user_info_len >= max_user_info_len) { + ESP_LOGW(TAG, "Credentials too long for authentication"); + return false; + } + + memcpy(user_info, username, user_len); + user_info[user_len] = ':'; + memcpy(user_info + user_len + 1, password, pass_len); + user_info[user_info_len] = '\0'; size_t n = 0, out; - esp_crypto_base64_encode(nullptr, 0, &n, reinterpret_cast(user_info.c_str()), user_info.size()); + esp_crypto_base64_encode(nullptr, 0, &n, reinterpret_cast(user_info), user_info_len); auto digest = std::unique_ptr(new char[n + 1]); esp_crypto_base64_encode(reinterpret_cast(digest.get()), n, &out, - reinterpret_cast(user_info.c_str()), user_info.size()); + reinterpret_cast(user_info), user_info_len); return strcmp(digest.get(), auth_str + auth_prefix_len) == 0; } @@ -483,8 +495,8 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * httpd_resp_set_hdr(req, "Cache-Control", "no-cache"); httpd_resp_set_hdr(req, "Connection", "keep-alive"); - for (const auto &pair : DefaultHeaders::Instance().headers_) { - httpd_resp_set_hdr(req, pair.first.c_str(), pair.second.c_str()); + for (const auto &header : DefaultHeaders::Instance().headers_) { + httpd_resp_set_hdr(req, header.name, header.value); } httpd_resp_send_chunk(req, CRLF_STR, CRLF_LEN); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index cae7006d96..2a334a11e3 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -2,6 +2,7 @@ #ifdef USE_ESP32 #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include #include @@ -327,6 +328,11 @@ class AsyncEventSource : public AsyncWebHandler { }; #endif // USE_WEBSERVER +struct HttpHeader { + const char *name; + const char *value; +}; + class DefaultHeaders { friend class AsyncWebServerRequest; #ifdef USE_WEBSERVER @@ -335,13 +341,14 @@ class DefaultHeaders { public: // NOLINTNEXTLINE(readability-identifier-naming) - void addHeader(const char *name, const char *value) { this->headers_.emplace_back(name, value); } + void addHeader(const char *name, const char *value) { this->headers_.push_back({name, value}); } // NOLINTNEXTLINE(readability-identifier-naming) static DefaultHeaders &Instance(); protected: - std::vector> headers_; + // Stack-allocated, no reallocation machinery. Count defined in web_server_base where headers are added. + StaticVector headers_; }; } // namespace web_server_idf diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ae94f6ef5f..adb2921b68 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -213,6 +213,7 @@ #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING +#define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT From 26e90b4ca6cb1ee6fcf1e98deb3e45cc9db89b39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:18:13 -1000 Subject: [PATCH 07/12] [light] Move LightColorValues::lerp() out of header to reduce code duplication (#13138) --- .../components/light/light_color_values.cpp | 28 +++++++++++++++++++ esphome/components/light/light_color_values.h | 21 +------------- 2 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 esphome/components/light/light_color_values.cpp diff --git a/esphome/components/light/light_color_values.cpp b/esphome/components/light/light_color_values.cpp new file mode 100644 index 0000000000..2f22bb3c68 --- /dev/null +++ b/esphome/components/light/light_color_values.cpp @@ -0,0 +1,28 @@ +#include "light_color_values.h" + +#include + +namespace esphome::light { + +LightColorValues LightColorValues::lerp(const LightColorValues &start, const LightColorValues &end, float completion) { + // Directly interpolate the raw values to avoid getter/setter overhead. + // This is safe because: + // - All LightColorValues have their values clamped when set via the setters + // - std::lerp guarantees output is in the same range as inputs + // - Therefore the output doesn't need clamping, so we can skip the setters + LightColorValues v; + v.color_mode_ = end.color_mode_; + v.state_ = std::lerp(start.state_, end.state_, completion); + v.brightness_ = std::lerp(start.brightness_, end.brightness_, completion); + v.color_brightness_ = std::lerp(start.color_brightness_, end.color_brightness_, completion); + v.red_ = std::lerp(start.red_, end.red_, completion); + v.green_ = std::lerp(start.green_, end.green_, completion); + v.blue_ = std::lerp(start.blue_, end.blue_, completion); + v.white_ = std::lerp(start.white_, end.white_, completion); + v.color_temperature_ = std::lerp(start.color_temperature_, end.color_temperature_, completion); + v.cold_white_ = std::lerp(start.cold_white_, end.cold_white_, completion); + v.warm_white_ = std::lerp(start.warm_white_, end.warm_white_, completion); + return v; +} + +} // namespace esphome::light diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index bedfad2c35..97756b9f26 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -82,26 +82,7 @@ class LightColorValues { * @param completion The completion value. 0 -> start, 1 -> end. * @return The linearly interpolated LightColorValues. */ - static LightColorValues lerp(const LightColorValues &start, const LightColorValues &end, float completion) { - // Directly interpolate the raw values to avoid getter/setter overhead. - // This is safe because: - // - All LightColorValues have their values clamped when set via the setters - // - std::lerp guarantees output is in the same range as inputs - // - Therefore the output doesn't need clamping, so we can skip the setters - LightColorValues v; - v.color_mode_ = end.color_mode_; - v.state_ = std::lerp(start.state_, end.state_, completion); - v.brightness_ = std::lerp(start.brightness_, end.brightness_, completion); - v.color_brightness_ = std::lerp(start.color_brightness_, end.color_brightness_, completion); - v.red_ = std::lerp(start.red_, end.red_, completion); - v.green_ = std::lerp(start.green_, end.green_, completion); - v.blue_ = std::lerp(start.blue_, end.blue_, completion); - v.white_ = std::lerp(start.white_, end.white_, completion); - v.color_temperature_ = std::lerp(start.color_temperature_, end.color_temperature_, completion); - v.cold_white_ = std::lerp(start.cold_white_, end.cold_white_, completion); - v.warm_white_ = std::lerp(start.warm_white_, end.warm_white_, completion); - return v; - } + static LightColorValues lerp(const LightColorValues &start, const LightColorValues &end, float completion); /** Normalize the color (RGB/W) component. * From ace3ff21700803740e0ca687dc49e2faf6d70f6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:18:24 -1000 Subject: [PATCH 08/12] [safe_mode] Conditionally compile callback when on_safe_mode is configured (#13136) --- esphome/components/safe_mode/__init__.py | 8 +++++--- esphome/components/safe_mode/automation.h | 11 +++++++---- esphome/components/safe_mode/safe_mode.cpp | 8 ++++---- esphome/components/safe_mode/safe_mode.h | 10 ++++++---- esphome/core/defines.h | 1 + 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 9944d71722..d1754aaad7 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -59,9 +59,11 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - for conf in config.get(CONF_ON_SAFE_MODE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + if on_safe_mode_config := config.get(CONF_ON_SAFE_MODE): + cg.add_define("USE_SAFE_MODE_CALLBACK") + for conf in on_safe_mode_config: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) condition = var.should_enter_safe_mode( config[CONF_NUM_ATTEMPTS], diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h index 1ffa86a588..952ed4da33 100644 --- a/esphome/components/safe_mode/automation.h +++ b/esphome/components/safe_mode/automation.h @@ -1,10 +1,12 @@ #pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SAFE_MODE_CALLBACK #include "safe_mode.h" #include "esphome/core/automation.h" -namespace esphome { -namespace safe_mode { +namespace esphome::safe_mode { class SafeModeTrigger : public Trigger<> { public: @@ -13,5 +15,6 @@ class SafeModeTrigger : public Trigger<> { } }; -} // namespace safe_mode -} // namespace esphome +} // namespace esphome::safe_mode + +#endif // USE_SAFE_MODE_CALLBACK diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index c7bd8748f5..ef6ebea247 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -13,8 +13,7 @@ #include #endif -namespace esphome { -namespace safe_mode { +namespace esphome::safe_mode { static const char *const TAG = "safe_mode"; @@ -126,7 +125,9 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en ESP_LOGW(TAG, "SAFE MODE IS ACTIVE"); +#ifdef USE_SAFE_MODE_CALLBACK this->safe_mode_callback_.call(); +#endif return true; } @@ -157,5 +158,4 @@ void SafeModeComponent::on_safe_shutdown() { this->clean_rtc(); } -} // namespace safe_mode -} // namespace esphome +} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 028b7b11cb..4aefd11458 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -5,8 +5,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -namespace esphome { -namespace safe_mode { +namespace esphome::safe_mode { /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent : public Component { @@ -25,9 +24,11 @@ class SafeModeComponent : public Component { void on_safe_shutdown() override; +#ifdef USE_SAFE_MODE_CALLBACK void add_on_safe_mode_callback(std::function &&callback) { this->safe_mode_callback_.add(std::move(callback)); } +#endif protected: void write_rtc_(uint32_t val); @@ -43,11 +44,12 @@ class SafeModeComponent : public Component { uint8_t safe_mode_num_attempts_{0}; // Larger objects at the end ESPPreferenceObject rtc_; +#ifdef USE_SAFE_MODE_CALLBACK CallbackManager safe_mode_callback_{}; +#endif static const uint32_t ENTER_SAFE_MODE_MAGIC = 0x5afe5afe; ///< a magic number to indicate that safe mode should be entered on next boot }; -} // namespace safe_mode -} // namespace esphome +} // namespace esphome::safe_mode diff --git a/esphome/core/defines.h b/esphome/core/defines.h index adb2921b68..ed5f152e9f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -100,6 +100,7 @@ #define USE_OUTPUT #define USE_POWER_SUPPLY #define USE_QR_CODE +#define USE_SAFE_MODE_CALLBACK #define USE_SELECT #define USE_SENSOR #define USE_STATUS_LED From 52132ea3bc6e575f621bb0ade5a75ba7ea084714 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:18:37 -1000 Subject: [PATCH 09/12] [ch422g][lc709203f][qmc5883l] Avoid heap allocation in status_set_warning calls (#13152) --- esphome/components/ch422g/ch422g.cpp | 8 ++++++-- esphome/components/lc709203f/lc709203f.cpp | 14 +++++++++----- esphome/components/qmc5883l/qmc5883l.cpp | 12 +++++++++--- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index f47b67da6f..d031c31294 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -93,7 +93,9 @@ bool CH422GComponent::read_inputs_() { bool CH422GComponent::write_reg_(uint8_t reg, uint8_t value) { auto err = this->bus_->write_readv(reg, &value, 1, nullptr, 0); if (err != i2c::ERROR_OK) { - this->status_set_warning(str_sprintf("write failed for register 0x%X, error %d", reg, err).c_str()); + char buf[64]; + snprintf(buf, sizeof(buf), "write failed for register 0x%X, error %d", reg, err); + this->status_set_warning(buf); return false; } this->status_clear_warning(); @@ -104,7 +106,9 @@ uint8_t CH422GComponent::read_reg_(uint8_t reg) { uint8_t value; auto err = this->bus_->write_readv(reg, nullptr, 0, &value, 1); if (err != i2c::ERROR_OK) { - this->status_set_warning(str_sprintf("read failed for register 0x%X, error %d", reg, err).c_str()); + char buf[64]; + snprintf(buf, sizeof(buf), "read failed for register 0x%X, error %d", reg, err); + this->status_set_warning(buf); return 0; } this->status_clear_warning(); diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index ad9d6b3098..8c7018124a 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -183,11 +183,14 @@ uint8_t Lc709203f::get_register_(uint8_t register_to_read, uint16_t *register_va return_code = this->read_register(register_to_read, &read_buffer[3], 3); if (return_code != i2c::NO_ERROR) { // Error on the i2c bus - this->status_set_warning( - str_sprintf("Error code %d when reading from register 0x%02X", return_code, register_to_read).c_str()); + char buf[64]; + snprintf(buf, sizeof(buf), "Error code %d when reading from register 0x%02X", return_code, register_to_read); + this->status_set_warning(buf); } else if (crc8(read_buffer, 5, 0x00, 0x07, true) != read_buffer[5]) { // I2C indicated OK, but the CRC of the data does not matcth. - this->status_set_warning(str_sprintf("CRC error reading from register 0x%02X", register_to_read).c_str()); + char buf[64]; + snprintf(buf, sizeof(buf), "CRC error reading from register 0x%02X", register_to_read); + this->status_set_warning(buf); } else { *register_value = ((uint16_t) read_buffer[4] << 8) | (uint16_t) read_buffer[3]; return i2c::NO_ERROR; @@ -225,8 +228,9 @@ uint8_t Lc709203f::set_register_(uint8_t register_to_set, uint16_t value_to_set) if (return_code == i2c::NO_ERROR) { return return_code; } else { - this->status_set_warning( - str_sprintf("Error code %d when writing to register 0x%02X", return_code, register_to_set).c_str()); + char buf[64]; + snprintf(buf, sizeof(buf), "Error code %d when writing to register 0x%02X", return_code, register_to_set); + this->status_set_warning(buf); } } diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index d2041a2d52..693614581c 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -105,7 +105,9 @@ void QMC5883LComponent::update() { if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) { err = this->read_register(QMC5883L_REGISTER_STATUS, &status, 1); if (err != i2c::ERROR_OK) { - this->status_set_warning(str_sprintf("status read failed (%d)", err).c_str()); + char buf[32]; + snprintf(buf, sizeof(buf), "status read failed (%d)", err); + this->status_set_warning(buf); return; } } @@ -127,7 +129,9 @@ void QMC5883LComponent::update() { } err = this->read_bytes_16_le_(start, &raw[dest], 3 - dest); if (err != i2c::ERROR_OK) { - this->status_set_warning(str_sprintf("mag read failed (%d)", err).c_str()); + char buf[32]; + snprintf(buf, sizeof(buf), "mag read failed (%d)", err); + this->status_set_warning(buf); return; } @@ -155,7 +159,9 @@ void QMC5883LComponent::update() { uint16_t raw_temp; err = this->read_bytes_16_le_(QMC5883L_REGISTER_TEMPERATURE_LSB, &raw_temp); if (err != i2c::ERROR_OK) { - this->status_set_warning(str_sprintf("temp read failed (%d)", err).c_str()); + char buf[32]; + snprintf(buf, sizeof(buf), "temp read failed (%d)", err); + this->status_set_warning(buf); return; } temp = int16_t(raw_temp) * 0.01f; From 38e2e4a56d86f8b5f665a6ab6e66519de5513666 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:18:49 -1000 Subject: [PATCH 10/12] [runtime_stats] Fix log output formatting alignment (#13155) --- esphome/components/runtime_stats/runtime_stats.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index 7e837a18e8..9a1e1a109a 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -29,7 +29,7 @@ void RuntimeStatsCollector::record_component_time(Component *component, uint32_t void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, "Component Runtime Statistics\n" - "Period stats (last %" PRIu32 "ms):", + " Period stats (last %" PRIu32 "ms):", this->log_interval_); // First collect stats we want to display @@ -55,7 +55,7 @@ void RuntimeStatsCollector::log_stats_() { } // Log total stats since boot - ESP_LOGI(TAG, "Total stats (since boot):"); + ESP_LOGI(TAG, " Total stats (since boot):"); // Re-sort by total runtime for all-time stats std::sort(stats_to_display.begin(), stats_to_display.end(), From 45c0796e40bda25ef741bcda5818d4476dc09fda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:19:00 -1000 Subject: [PATCH 11/12] [ci] Add RP2040 to memory impact analysis (#13134) --- script/determine-jobs.py | 11 ++- tests/script/test_determine_jobs.py | 129 ++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 44e8e4b5ab..a61c9bf08d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -90,6 +90,7 @@ class Platform(StrEnum): ESP32_S2_IDF = "esp32-s2-idf" ESP32_S3_IDF = "esp32-s3-idf" BK72XX_ARD = "bk72xx-ard" # LibreTiny BK7231N + RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico # Memory impact analysis constants @@ -122,6 +123,7 @@ PLATFORM_SPECIFIC_COMPONENTS = frozenset( # 3. ESP32 IDF - Primary ESP32 platform, most representative of modern ESPHome # 4-6. Other ESP32 variants - Less commonly used but still supported # 7. BK72XX - LibreTiny platform (good for detecting LibreTiny-specific changes) +# 8. RP2040 - Raspberry Pi Pico platform MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.ESP32_C6_IDF, # ESP32-C6 IDF (newest, supports Thread/Zigbee) Platform.ESP8266_ARD, # ESP8266 Arduino (most memory constrained, fastest builds) @@ -130,6 +132,7 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.ESP32_S2_IDF, # ESP32-S2 IDF Platform.ESP32_S3_IDF, # ESP32-S3 IDF Platform.BK72XX_ARD, # LibreTiny BK7231N + Platform.RP2040_ARD, # Raspberry Pi Pico ] @@ -408,7 +411,7 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: - wifi_component_esp8266.cpp, *_esp8266.h -> ESP8266_ARD - *_esp32*.cpp -> ESP32 IDF (generic) - *_libretiny.cpp, *_bk72*.* -> BK72XX (LibreTiny) - - *_pico.cpp, *_rp2040.* -> RP2040 (not in preference list) + - *_pico.cpp, *_rp2040.* -> RP2040_ARD Args: filename: File path to check @@ -445,9 +448,9 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: if "libretiny" in filename_lower or "bk72" in filename_lower: return Platform.BK72XX_ARD - # RP2040 is not in MEMORY_IMPACT_PLATFORM_PREFERENCE - # if "pico" in filename_lower or "rp2040" in filename_lower: - # return None # No RP2040 platform preference + # RP2040 / Raspberry Pi Pico + if "pico" in filename_lower or "rp2040" in filename_lower: + return Platform.RP2040_ARD return None diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index 291a23967b..bd20cb3e21 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -1421,6 +1421,135 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> assert len(result["components"]) == 40 +# Tests for _detect_platform_hint_from_filename function + + +@pytest.mark.parametrize( + ("filename", "expected_platform"), + [ + # ESP-IDF platform detection + ("esphome/components/wifi/wifi_esp_idf.cpp", determine_jobs.Platform.ESP32_IDF), + ( + "esphome/components/wifi/wifi_component_esp_idf.cpp", + determine_jobs.Platform.ESP32_IDF, + ), + ( + "esphome/components/ethernet/ethernet_idf.cpp", + determine_jobs.Platform.ESP32_IDF, + ), + # ESP32 variant detection with IDF suffix + ( + "esphome/components/ble/esp32c3_idf.cpp", + determine_jobs.Platform.ESP32_C3_IDF, + ), + ( + "esphome/components/ble/esp32c6_idf.cpp", + determine_jobs.Platform.ESP32_C6_IDF, + ), + ( + "esphome/components/ble/esp32s2_idf.cpp", + determine_jobs.Platform.ESP32_S2_IDF, + ), + ( + "esphome/components/ble/esp32s3_idf.cpp", + determine_jobs.Platform.ESP32_S3_IDF, + ), + # ESP8266 detection + ( + "esphome/components/wifi/wifi_esp8266.cpp", + determine_jobs.Platform.ESP8266_ARD, + ), + ("esphome/core/helpers_esp8266.h", determine_jobs.Platform.ESP8266_ARD), + # Generic ESP32 detection (without IDF suffix) + ("esphome/components/wifi/wifi_esp32.cpp", determine_jobs.Platform.ESP32_IDF), + ( + "esphome/components/ethernet/ethernet_esp32.cpp", + determine_jobs.Platform.ESP32_IDF, + ), + # LibreTiny / BK72xx detection + ( + "esphome/components/wifi/wifi_libretiny.cpp", + determine_jobs.Platform.BK72XX_ARD, + ), + ("esphome/components/ble/ble_bk72xx.cpp", determine_jobs.Platform.BK72XX_ARD), + # RP2040 / Raspberry Pi Pico detection + ("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), + ("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), + ("esphome/components/i2c/i2c_pico.cpp", determine_jobs.Platform.RP2040_ARD), + ("esphome/components/spi/spi_pico.cpp", determine_jobs.Platform.RP2040_ARD), + ( + "tests/components/rp2040/test.rp2040-ard.yaml", + determine_jobs.Platform.RP2040_ARD, + ), + # No platform hint (generic files) + ("esphome/components/wifi/wifi.cpp", None), + ("esphome/components/sensor/sensor.h", None), + ("esphome/core/helpers.h", None), + ("README.md", None), + ], + ids=[ + "esp_idf_suffix", + "esp_idf_component_suffix", + "idf_suffix", + "esp32c3_idf", + "esp32c6_idf", + "esp32s2_idf", + "esp32s3_idf", + "esp8266_suffix", + "esp8266_core_header", + "generic_esp32", + "esp32_in_name", + "libretiny", + "bk72xx", + "rp2040_gpio", + "rp2040_wifi", + "pico_i2c", + "pico_spi", + "rp2040_test_yaml", + "generic_wifi_no_hint", + "generic_sensor_no_hint", + "core_helpers_no_hint", + "readme_no_hint", + ], +) +def test_detect_platform_hint_from_filename( + filename: str, expected_platform: determine_jobs.Platform | None +) -> None: + """Test _detect_platform_hint_from_filename correctly detects platform hints.""" + result = determine_jobs._detect_platform_hint_from_filename(filename) + assert result == expected_platform + + +@pytest.mark.parametrize( + ("filename", "expected_platform"), + [ + # RP2040/Pico with different cases + ("file_RP2040.cpp", determine_jobs.Platform.RP2040_ARD), + ("file_Rp2040.cpp", determine_jobs.Platform.RP2040_ARD), + ("file_PICO.cpp", determine_jobs.Platform.RP2040_ARD), + ("file_Pico.cpp", determine_jobs.Platform.RP2040_ARD), + # ESP8266 with different cases + ("file_ESP8266.cpp", determine_jobs.Platform.ESP8266_ARD), + # ESP32 with different cases + ("file_ESP32.cpp", determine_jobs.Platform.ESP32_IDF), + ], + ids=[ + "rp2040_uppercase", + "rp2040_mixedcase", + "pico_uppercase", + "pico_titlecase", + "esp8266_uppercase", + "esp32_uppercase", + ], +) +def test_detect_platform_hint_from_filename_case_insensitive( + filename: str, expected_platform: determine_jobs.Platform +) -> None: + """Test that platform detection is case-insensitive.""" + result = determine_jobs._detect_platform_hint_from_filename(filename) + assert result == expected_platform + + def test_component_batching_beta_branch_40_per_batch( tmp_path: Path, mock_should_run_integration_tests: Mock, From cea8c9b21225b059b59376e0351fb94f3426266a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jan 2026 17:26:42 -1000 Subject: [PATCH 12/12] [core] Deprecate heap-allocating string helpers to prevent fragmentation patterns --- .ai/instructions.md | 4 ++++ esphome/core/helpers.h | 22 ++++++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.ai/instructions.md b/.ai/instructions.md index 994d517f75..cb08a1e460 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -293,6 +293,10 @@ This document provides essential context for AI models interacting with this pro * **Configuration Design:** Aim for simplicity with sensible defaults, while allowing for advanced customization. * **Embedded Systems Optimization:** ESPHome targets resource-constrained microcontrollers. Be mindful of flash size and RAM usage. + **Why Heap Allocation Matters:** + + ESP devices run for months with small heaps shared between Wi-Fi, BLE, LWIP, and application code. Over time, repeated allocations of different sizes fragment the heap. Failures happen when the largest contiguous block shrinks, even if total free heap is still large. We have seen field crashes caused by this. For this reason, ESPHome treats runtime heap allocation in hot paths as a reliability bug, not a performance issue. Helpers that hide allocation (`std::string`, `std::to_string`, string-returning helpers) are being deprecated and replaced with buffer and view based APIs. + **STL Container Guidelines:** ESPHome runs on embedded systems with limited resources. Choose containers carefully: diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index cd43709f7d..dee192cf61 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -518,6 +518,8 @@ bool str_startswith(const std::string &str, const std::string &start); bool str_endswith(const std::string &str, const std::string &end); /// Truncate a string to a specific length. +/// @deprecated Allocates heap memory and is unused. Removed in 2026.7.0. +ESPDEPRECATED("Allocates heap memory and is unused. Removed in 2026.7.0.", "2026.1.0") std::string str_truncate(const std::string &str, size_t length); /// Extract the part of the string until either the first occurrence of the specified character, or the end @@ -529,11 +531,15 @@ std::string str_until(const std::string &str, char ch); /// Convert the string to lower case. std::string str_lower_case(const std::string &str); /// Convert the string to upper case. +/// @deprecated Allocates heap memory and is unused. Removed in 2026.7.0. +ESPDEPRECATED("Allocates heap memory and is unused. Removed in 2026.7.0.", "2026.1.0") std::string str_upper_case(const std::string &str); /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } /// Convert the string to snake case (lowercase with underscores). +/// @deprecated Allocates heap memory and is unused in C++. Removed in 2026.7.0. +ESPDEPRECATED("Allocates heap memory and is unused in C++. Removed in 2026.7.0.", "2026.1.0") std::string str_snake_case(const std::string &str); /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. @@ -848,17 +854,29 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { } /// Format the six-byte array \p mac into a MAC address. +/// @deprecated Allocates heap memory. Use format_mac_addr_upper() with a stack buffer instead. Removed in 2026.7.0. +ESPDEPRECATED("Allocates heap memory. Use format_mac_addr_upper() with stack buffer. Removed in 2026.7.0.", "2026.1.0") std::string format_mac_address_pretty(const uint8_t mac[6]); /// Format the byte array \p data of length \p len in lowercased hex. +/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. +ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") std::string format_hex(const uint8_t *data, size_t length); /// Format the vector \p data in lowercased hex. +/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. +ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") std::string format_hex(const std::vector &data); /// Format an unsigned integer in lowercased hex, starting with the most significant byte. -template::value, int> = 0> std::string format_hex(T val) { +/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. +template::value, int> = 0> +ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") +std::string format_hex(T val) { val = convert_big_endian(val); return format_hex(reinterpret_cast(&val), sizeof(T)); } -template std::string format_hex(const std::array &data) { +/// @deprecated Allocates heap memory. Use format_hex_to() with a stack buffer instead. Removed in 2026.7.0. +template +ESPDEPRECATED("Allocates heap memory. Use format_hex_to() with stack buffer. Removed in 2026.7.0.", "2026.1.0") +std::string format_hex(const std::array &data) { return format_hex(data.data(), data.size()); }