Compare commits

..

10 Commits

Author SHA1 Message Date
J. Nick Koston
ae5a3e616a improve comment 2026-01-17 16:26:18 -10:00
J. Nick Koston
759278191b simpler 2026-01-17 16:23:30 -10:00
J. Nick Koston
4549e375c1 adjust 2026-01-17 16:16:58 -10:00
J. Nick Koston
a8b07af2a3 fixes 2026-01-17 16:12:51 -10:00
J. Nick Koston
f003fac5d8 document, document, document 2026-01-17 15:51:45 -10:00
J. Nick Koston
6b02f5dfbd [esp32_ble_tracker] Optimize loop with state change tracking for ~85% CPU reduction 2026-01-17 15:47:37 -10:00
J. Nick Koston
e4fb6988ff [web_server] Use ESPHOME_F for canHandle domain checks to reduce ESP8266 RAM (#13315)
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-01-17 22:29:29 +00:00
J. Nick Koston
d31b733dce [light] Store color mode JSON strings in flash on ESP8266 (#13314) 2026-01-17 16:06:25 -06:00
Keith Burzinski
b25a2f8d8e [infrared][web_server] Implement initial web_server support (#13202)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-01-17 16:01:13 -06:00
J. Nick Koston
3f892711c7 [core][opentherm] Add format_bin_to(), soft-deprecate format_bin() (#13232) 2026-01-17 11:09:42 -10:00
30 changed files with 458 additions and 463 deletions

View File

@@ -69,7 +69,6 @@ from esphome.cpp_types import ( # noqa: F401
JsonObjectConst,
Parented,
PollingComponent,
StringRef,
arduino_json_ns,
bool_,
const_char_ptr,

View File

@@ -135,8 +135,8 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
if (this->state_ != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}

View File

@@ -50,7 +50,7 @@ void BLEClientBase::loop() {
this->set_state(espbt::ClientState::INIT);
return;
}
if (this->state_ == espbt::ClientState::INIT) {
if (this->state() == espbt::ClientState::INIT) {
auto ret = esp_ble_gattc_app_register(this->app_id);
if (ret) {
ESP_LOGE(TAG, "gattc app register failed. app_id=%d code=%d", this->app_id, ret);
@@ -60,7 +60,7 @@ void BLEClientBase::loop() {
}
// If idle, we can disable the loop as connect()
// will enable it again when a connection is needed.
else if (this->state_ == espbt::ClientState::IDLE) {
else if (this->state() == espbt::ClientState::IDLE) {
this->disable_loop();
}
}
@@ -86,7 +86,7 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) {
return false;
if (this->address_ == 0 || device.address_uint64() != this->address_)
return false;
if (this->state_ != espbt::ClientState::IDLE)
if (this->state() != espbt::ClientState::IDLE)
return false;
this->log_event_("Found device");
@@ -102,10 +102,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) {
void BLEClientBase::connect() {
// Prevent duplicate connection attempts
if (this->state_ == espbt::ClientState::CONNECTING || this->state_ == espbt::ClientState::CONNECTED ||
this->state_ == espbt::ClientState::ESTABLISHED) {
if (this->state() == espbt::ClientState::CONNECTING || this->state() == espbt::ClientState::CONNECTED ||
this->state() == espbt::ClientState::ESTABLISHED) {
ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, this->address_str_,
espbt::client_state_to_string(this->state_));
espbt::client_state_to_string(this->state()));
return;
}
ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_);
@@ -133,12 +133,12 @@ void BLEClientBase::connect() {
esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); }
void BLEClientBase::disconnect() {
if (this->state_ == espbt::ClientState::IDLE || this->state_ == espbt::ClientState::DISCONNECTING) {
if (this->state() == espbt::ClientState::IDLE || this->state() == espbt::ClientState::DISCONNECTING) {
ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_,
espbt::client_state_to_string(this->state_));
espbt::client_state_to_string(this->state()));
return;
}
if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
if (this->state() == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_,
this->address_str_);
this->want_disconnect_ = true;
@@ -150,7 +150,7 @@ void BLEClientBase::disconnect() {
void BLEClientBase::unconditional_disconnect() {
// Disconnect without checking the state.
ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_, this->conn_id_);
if (this->state_ == espbt::ClientState::DISCONNECTING) {
if (this->state() == espbt::ClientState::DISCONNECTING) {
this->log_error_("Already disconnecting");
return;
}
@@ -170,7 +170,7 @@ void BLEClientBase::unconditional_disconnect() {
this->log_gattc_warning_("esp_ble_gattc_close", err);
}
if (this->state_ == espbt::ClientState::DISCOVERED) {
if (this->state() == espbt::ClientState::DISCOVERED) {
this->set_address(0);
this->set_state(espbt::ClientState::IDLE);
} else {
@@ -295,18 +295,18 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
// ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an
// error, if the error occurred at the BTA/GATT layer. This can result in the event
// arriving after we've already transitioned to IDLE state.
if (this->state_ == espbt::ClientState::IDLE) {
if (this->state() == espbt::ClientState::IDLE) {
ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_,
this->address_str_, param->open.status);
break;
}
if (this->state_ != espbt::ClientState::CONNECTING) {
if (this->state() != espbt::ClientState::CONNECTING) {
// This should not happen but lets log it in case it does
// because it means we have a bad assumption about how the
// ESP BT stack works.
ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_,
this->address_str_, espbt::client_state_to_string(this->state_), param->open.status);
this->address_str_, espbt::client_state_to_string(this->state()), param->open.status);
}
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->log_gattc_warning_("Connection open", param->open.status);
@@ -327,7 +327,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) {
// Cached connections already connected with medium parameters, no update needed
// only set our state, subclients might have more stuff to do yet.
this->state_ = espbt::ClientState::ESTABLISHED;
this->set_state_internal_(espbt::ClientState::ESTABLISHED);
break;
}
// For V3_WITHOUT_CACHE, we already set fast params before connecting
@@ -356,7 +356,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
return false;
// Check if we were disconnected while waiting for service discovery
if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER &&
this->state_ == espbt::ClientState::CONNECTED) {
this->state() == espbt::ClientState::CONNECTED) {
this->log_warning_("Remote closed during discovery");
} else {
ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_,
@@ -433,7 +433,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
#endif
}
ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_);
this->state_ = espbt::ClientState::ESTABLISHED;
this->set_state_internal_(espbt::ClientState::ESTABLISHED);
break;
}
case ESP_GATTC_READ_DESCR_EVT: {

View File

@@ -44,7 +44,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void unconditional_disconnect();
void release_services();
bool connected() { return this->state_ == espbt::ClientState::ESTABLISHED; }
bool connected() { return this->state() == espbt::ClientState::ESTABLISHED; }
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }

View File

@@ -105,15 +105,13 @@ void ESP32BLETracker::loop() {
}
// Check for scan timeout - moved here from scheduler to avoid false reboots
// when the loop is blocked
// when the loop is blocked. This must run every iteration for safety.
if (this->scanner_state_ == ScannerState::RUNNING) {
switch (this->scan_timeout_state_) {
case ScanTimeoutState::MONITORING: {
uint32_t now = App.get_loop_component_start_time();
uint32_t timeout_ms = this->scan_duration_ * 2000;
// Robust time comparison that handles rollover correctly
// This works because unsigned arithmetic wraps around predictably
if ((now - this->scan_start_time_) > timeout_ms) {
if ((App.get_loop_component_start_time() - this->scan_start_time_) > this->scan_timeout_ms_) {
// First time we've seen the timeout exceeded - wait one more loop iteration
// This ensures all components have had a chance to process pending events
// This is because esp32_ble may not have run yet and called
@@ -128,13 +126,31 @@ void ESP32BLETracker::loop() {
ESP_LOGE(TAG, "Scan never terminated, rebooting");
App.reboot();
break;
case ScanTimeoutState::INACTIVE:
// This case should be unreachable - scanner and timeout states are always synchronized
break;
}
}
// Fast path: skip expensive client state counting and processing
// if no state has changed since last loop iteration.
//
// How state changes ensure we reach the code below:
// - handle_scanner_failure_(): scanner_state_ becomes FAILED via set_scanner_state_(), or
// scan_set_param_failed_ requires scanner_state_==RUNNING which can only be reached via
// set_scanner_state_(RUNNING) in gap_scan_start_complete_() (scan params are set during
// STARTING, not RUNNING, so version is always incremented before this condition is true)
// - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_()
// - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or
// connecting client finishes (state change), or scanner reaches RUNNING/IDLE
//
// All conditions that affect the logic below are tied to state changes that increment
// state_version_, so the fast path is safe.
if (this->state_version_ == this->last_processed_version_) {
return;
}
this->last_processed_version_ = this->state_version_;
// State changed - do full processing
ClientStateCounts counts = this->count_client_states_();
if (counts != this->client_state_counts_) {
this->client_state_counts_ = counts;
@@ -142,6 +158,7 @@ void ESP32BLETracker::loop() {
this->client_state_counts_.discovered, this->client_state_counts_.disconnecting);
}
// Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set
if (this->scanner_state_ == ScannerState::FAILED ||
(this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) {
this->handle_scanner_failure_();
@@ -160,6 +177,8 @@ void ESP32BLETracker::loop() {
*/
// Start scan: reached when scanner_state_ becomes IDLE (via set_scanner_state_()) and
// all clients are idle (their state changes increment version when they finish)
if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) {
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
this->update_coex_preference_(false);
@@ -168,8 +187,9 @@ void ESP32BLETracker::loop() {
this->start_scan_(false); // first = false
}
}
// If there is a discovered client and no connecting
// clients, then promote the discovered client to ready to connect.
// Promote discovered clients: reached when a client's state becomes DISCOVERED (via set_state()),
// or when a blocking condition clears (connecting client finishes, scanner reaches RUNNING/IDLE).
// All these trigger state_version_ increment, so we'll process and check promotion eligibility.
// We check both RUNNING and IDLE states because:
// - RUNNING: gap_scan_event_handler initiates stop_scan_() but promotion can happen immediately
// - IDLE: Scanner has already stopped (naturally or by gap_scan_event_handler)
@@ -236,6 +256,7 @@ void ESP32BLETracker::start_scan_(bool first) {
// Start timeout monitoring in loop() instead of using scheduler
// This prevents false reboots when the loop is blocked
this->scan_start_time_ = App.get_loop_component_start_time();
this->scan_timeout_ms_ = this->scan_duration_ * 2000;
this->scan_timeout_state_ = ScanTimeoutState::MONITORING;
esp_err_t err = esp_ble_gap_set_scan_params(&this->scan_params_);
@@ -253,6 +274,10 @@ void ESP32BLETracker::start_scan_(bool first) {
void ESP32BLETracker::register_client(ESPBTClient *client) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
client->app_id = ++this->app_id_;
// Give client a pointer to our state_version_ so it can notify us of state changes.
// This enables loop() fast-path optimization - we skip expensive work when no state changed.
// Safe because ESP32BLETracker (singleton) outlives all registered clients.
client->set_tracker_state_version(&this->state_version_);
this->clients_.push_back(client);
this->recalculate_advertisement_parser_types();
#endif
@@ -382,6 +407,7 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i
void ESP32BLETracker::set_scanner_state_(ScannerState state) {
this->scanner_state_ = state;
this->state_version_++;
for (auto *listener : this->scanner_state_listeners_) {
listener->on_scanner_state(state);
}

View File

@@ -216,6 +216,19 @@ enum class ConnectionType : uint8_t {
V3_WITHOUT_CACHE
};
/// Base class for BLE GATT clients that connect to remote devices.
///
/// State Change Tracking Design:
/// -----------------------------
/// ESP32BLETracker::loop() needs to know when client states change to avoid
/// expensive polling. Rather than checking all clients every iteration (~7000/min),
/// we use a version counter owned by ESP32BLETracker that clients increment on
/// state changes. The tracker compares versions to skip work when nothing changed.
///
/// Ownership: ESP32BLETracker owns state_version_. Clients hold a non-owning
/// pointer (tracker_state_version_) set during register_client(). Clients
/// increment the counter through this pointer when their state changes.
/// The pointer may be null if the client is not registered with a tracker.
class ESPBTClient : public ESPBTDeviceListener {
public:
virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
@@ -225,26 +238,49 @@ class ESPBTClient : public ESPBTDeviceListener {
virtual void disconnect() = 0;
bool disconnect_pending() const { return this->want_disconnect_; }
void cancel_pending_disconnect() { this->want_disconnect_ = false; }
/// Set the client state with IDLE handling (clears want_disconnect_).
/// Notifies the tracker of state change for loop optimization.
virtual void set_state(ClientState st) {
this->state_ = st;
this->set_state_internal_(st);
if (st == ClientState::IDLE) {
this->want_disconnect_ = false;
}
}
ClientState state() const { return state_; }
ClientState state() const { return this->state_; }
/// Called by ESP32BLETracker::register_client() to enable state change notifications.
/// The pointer must remain valid for the lifetime of the client (guaranteed since
/// ESP32BLETracker is a singleton that outlives all clients).
void set_tracker_state_version(uint8_t *version) { this->tracker_state_version_ = version; }
// Memory optimized layout
uint8_t app_id; // App IDs are small integers assigned sequentially
protected:
// Group 1: 1-byte types
ClientState state_{ClientState::INIT};
/// Set state without IDLE handling - use for direct state transitions.
/// Increments the tracker's state version counter to signal that loop()
/// should do full processing on the next iteration.
void set_state_internal_(ClientState st) {
this->state_ = st;
// Notify tracker that state changed (tracker_state_version_ is owned by ESP32BLETracker)
if (this->tracker_state_version_ != nullptr) {
(*this->tracker_state_version_)++;
}
}
// want_disconnect_ is set to true when a disconnect is requested
// while the client is connecting. This is used to disconnect the
// client as soon as we get the connection id (conn_id_) from the
// ESP_GATTC_OPEN_EVT event.
bool want_disconnect_{false};
// 2 bytes used, 2 bytes padding
private:
ClientState state_{ClientState::INIT};
/// Non-owning pointer to ESP32BLETracker::state_version_. When this client's
/// state changes, we increment the tracker's counter to signal that loop()
/// should perform full processing. Null if client not registered with tracker.
uint8_t *tracker_state_version_{nullptr};
};
class ESP32BLETracker : public Component,
@@ -380,6 +416,16 @@ class ESP32BLETracker : public Component,
// Group 4: 1-byte types (enums, uint8_t, bool)
uint8_t app_id_{0};
uint8_t scan_start_fail_count_{0};
/// Version counter for loop() fast-path optimization. Incremented when:
/// - Scanner state changes (via set_scanner_state_())
/// - Any registered client's state changes (clients hold pointer to this counter)
/// Owned by this class; clients receive non-owning pointer via register_client().
/// When loop() sees state_version_ == last_processed_version_, it skips expensive
/// client state counting and takes the fast path (just timeout check + return).
uint8_t state_version_{0};
/// Last state_version_ value when loop() did full processing. Compared against
/// state_version_ to detect if any state changed since last iteration.
uint8_t last_processed_version_{0};
ScannerState scanner_state_{ScannerState::IDLE};
bool scan_continuous_;
bool scan_active_;
@@ -396,6 +442,8 @@ class ESP32BLETracker : public Component,
EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot
};
uint32_t scan_start_time_{0};
/// Precomputed timeout value: scan_duration_ * 2000
uint32_t scan_timeout_ms_{0};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
};

View File

@@ -90,7 +90,9 @@ async def setup_event_core_(var, config, *, event_types: list[str]):
for conf in config.get(CONF_ON_EVENT, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [(cg.StringRef, "event_type")], conf)
await automation.build_automation(
trigger, [(cg.std_string, "event_type")], conf
)
cg.add(var.set_event_types(event_types))

View File

@@ -14,10 +14,10 @@ template<typename... Ts> class TriggerEventAction : public Action<Ts...>, public
void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); }
};
class EventTrigger : public Trigger<StringRef> {
class EventTrigger : public Trigger<std::string> {
public:
EventTrigger(Event *event) {
event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); });
event->add_on_event_callback([this](const std::string &event_type) { this->trigger(event_type); });
}
};

View File

@@ -23,7 +23,7 @@ void Event::trigger(const std::string &event_type) {
}
this->last_event_type_ = found;
ESP_LOGD(TAG, "'%s' >> '%s'", this->get_name().c_str(), this->last_event_type_);
this->event_callback_.call(StringRef(found));
this->event_callback_.call(event_type);
#if defined(USE_EVENT) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_event(this);
#endif
@@ -45,7 +45,7 @@ void Event::set_event_types(const std::vector<const char *> &event_types) {
this->last_event_type_ = nullptr; // Reset when types change
}
void Event::add_on_event_callback(std::function<void(StringRef event_type)> &&callback) {
void Event::add_on_event_callback(std::function<void(const std::string &event_type)> &&callback) {
this->event_callback_.add(std::move(callback));
}

View File

@@ -70,10 +70,10 @@ class Event : public EntityBase, public EntityBase_DeviceClass {
/// Check if an event has been triggered.
bool has_event() const { return this->last_event_type_ != nullptr; }
void add_on_event_callback(std::function<void(StringRef event_type)> &&callback);
void add_on_event_callback(std::function<void(const std::string &event_type)> &&callback);
protected:
LazyCallbackManager<void(StringRef event_type)> event_callback_;
LazyCallbackManager<void(const std::string &event_type)> event_callback_;
FixedVector<const char *> types_;
private:

View File

@@ -77,7 +77,7 @@ FanSpeedSetTrigger = fan_ns.class_(
"FanSpeedSetTrigger", automation.Trigger.template(cg.int_)
)
FanPresetSetTrigger = fan_ns.class_(
"FanPresetSetTrigger", automation.Trigger.template(cg.StringRef)
"FanPresetSetTrigger", automation.Trigger.template(cg.std_string)
)
FanIsOnCondition = fan_ns.class_("FanIsOnCondition", automation.Condition.template())
@@ -287,7 +287,7 @@ async def setup_fan_core_(var, config):
await automation.build_automation(trigger, [(cg.int_, "x")], conf)
for conf in config.get(CONF_ON_PRESET_SET, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [(cg.StringRef, "x")], conf)
await automation.build_automation(trigger, [(cg.std_string, "x")], conf)
async def register_fan(var, config):

View File

@@ -208,7 +208,7 @@ class FanSpeedSetTrigger : public Trigger<int> {
int last_speed_;
};
class FanPresetSetTrigger : public Trigger<StringRef> {
class FanPresetSetTrigger : public Trigger<std::string> {
public:
FanPresetSetTrigger(Fan *state) {
state->add_on_state_callback([this, state]() {
@@ -216,7 +216,7 @@ class FanPresetSetTrigger : public Trigger<StringRef> {
auto should_trigger = preset_mode != this->last_preset_mode_;
this->last_preset_mode_ = preset_mode;
if (should_trigger) {
this->trigger(preset_mode);
this->trigger(std::string(preset_mode));
}
});
this->last_preset_mode_ = state->get_preset_mode();

View File

@@ -1,4 +1,5 @@
#include "light_json_schema.h"
#include "color_mode.h"
#include "light_output.h"
#include "esphome/core/progmem.h"
@@ -8,29 +9,32 @@ namespace esphome::light {
// See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema
// Get JSON string for color mode using linear search (avoids large switch jump table)
static const char *get_color_mode_json_str(ColorMode mode) {
// Parallel arrays: mode values and their corresponding strings
// Uses less RAM than a switch jump table on sparse enum values
static constexpr ColorMode MODES[] = {
ColorMode::ON_OFF,
ColorMode::BRIGHTNESS,
ColorMode::WHITE,
ColorMode::COLOR_TEMPERATURE,
ColorMode::COLD_WARM_WHITE,
ColorMode::RGB,
ColorMode::RGB_WHITE,
ColorMode::RGB_COLOR_TEMPERATURE,
ColorMode::RGB_COLD_WARM_WHITE,
};
static constexpr const char *STRINGS[] = {
"onoff", "brightness", "white", "color_temp", "cwww", "rgb", "rgbw", "rgbct", "rgbww",
};
for (size_t i = 0; i < sizeof(MODES) / sizeof(MODES[0]); i++) {
if (MODES[i] == mode)
return STRINGS[i];
// Get JSON string for color mode.
// ColorMode enum values are sparse bitmasks (0, 1, 3, 7, 11, 19, 35, 39, 47, 51) which would
// generate a large jump table. Converting to bit index (0-9) allows a compact switch.
static ProgmemStr get_color_mode_json_str(ColorMode mode) {
switch (ColorModeBitPolicy::to_bit(mode)) {
case 1:
return ESPHOME_F("onoff");
case 2:
return ESPHOME_F("brightness");
case 3:
return ESPHOME_F("white");
case 4:
return ESPHOME_F("color_temp");
case 5:
return ESPHOME_F("cwww");
case 6:
return ESPHOME_F("rgb");
case 7:
return ESPHOME_F("rgbw");
case 8:
return ESPHOME_F("rgbct");
case 9:
return ESPHOME_F("rgbww");
default:
return nullptr;
}
return nullptr;
}
void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
@@ -44,7 +48,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) {
auto values = state.remote_values;
const auto color_mode = values.get_color_mode();
const char *mode_str = get_color_mode_json_str(color_mode);
const auto *mode_str = get_color_mode_json_str(color_mode);
if (mode_str != nullptr) {
root[ESPHOME_F("color_mode")] = mode_str;
}

View File

@@ -561,8 +561,9 @@ const char *OpenTherm::message_id_to_str(MessageId id) {
}
void OpenTherm::debug_data(OpenthermData &data) {
ESP_LOGD(TAG, "%s %s %s %s", format_bin(data.type).c_str(), format_bin(data.id).c_str(),
format_bin(data.valueHB).c_str(), format_bin(data.valueLB).c_str());
char type_buf[9], id_buf[9], hb_buf[9], lb_buf[9];
ESP_LOGD(TAG, "%s %s %s %s", format_bin_to(type_buf, data.type), format_bin_to(id_buf, data.id),
format_bin_to(hb_buf, data.valueHB), format_bin_to(lb_buf, data.valueLB));
ESP_LOGD(TAG, "type: %s; id: %u; HB: %u; LB: %u; uint_16: %u; float: %f",
this->message_type_to_str((MessageType) data.type), data.id, data.valueHB, data.valueLB, data.u16(),
data.f88());

View File

@@ -33,7 +33,7 @@ SelectPtr = Select.operator("ptr")
# Triggers
SelectStateTrigger = select_ns.class_(
"SelectStateTrigger",
automation.Trigger.template(cg.StringRef, cg.size_t),
automation.Trigger.template(cg.std_string, cg.size_t),
)
# Actions
@@ -100,7 +100,7 @@ async def setup_select_core_(var, config, *, options: list[str]):
for conf in config.get(CONF_ON_VALUE, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(
trigger, [(cg.StringRef, "x"), (cg.size_t, "i")], conf
trigger, [(cg.std_string, "x"), (cg.size_t, "i")], conf
)
if (mqtt_id := config.get(CONF_MQTT_ID)) is not None:

View File

@@ -6,11 +6,11 @@
namespace esphome::select {
class SelectStateTrigger : public Trigger<StringRef, size_t> {
class SelectStateTrigger : public Trigger<std::string, size_t> {
public:
explicit SelectStateTrigger(Select *parent) : parent_(parent) {
parent->add_on_state_callback(
[this](size_t index) { this->trigger(StringRef(this->parent_->option_at(index)), index); });
[this](size_t index) { this->trigger(std::string(this->parent_->option_at(index)), index); });
}
protected:

View File

@@ -88,5 +88,5 @@ async def to_code(config):
if CONF_SET_ACTION in config:
await automation.build_automation(
var.get_set_trigger(), [(cg.StringRef, "x")], config[CONF_SET_ACTION]
var.get_set_trigger(), [(cg.std_string, "x")], config[CONF_SET_ACTION]
)

View File

@@ -41,7 +41,7 @@ void TemplateSelect::update() {
}
void TemplateSelect::control(size_t index) {
this->set_trigger_->trigger(StringRef(this->option_at(index)));
this->set_trigger_->trigger(std::string(this->option_at(index)));
if (this->optimistic_)
this->publish_state(index);

View File

@@ -4,7 +4,6 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/preferences.h"
#include "esphome/core/string_ref.h"
#include "esphome/core/template_lambda.h"
namespace esphome::template_ {
@@ -18,7 +17,7 @@ class TemplateSelect final : public select::Select, public PollingComponent {
void dump_config() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
Trigger<StringRef> *get_set_trigger() const { return this->set_trigger_; }
Trigger<std::string> *get_set_trigger() const { return this->set_trigger_; }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_initial_option_index(size_t initial_option_index) { this->initial_option_index_ = initial_option_index; }
void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; }
@@ -28,7 +27,7 @@ class TemplateSelect final : public select::Select, public PollingComponent {
bool optimistic_ = false;
size_t initial_option_index_{0};
bool restore_value_ = false;
Trigger<StringRef> *set_trigger_ = new Trigger<StringRef>();
Trigger<std::string> *set_trigger_ = new Trigger<std::string>();
TemplateLambda<std::string> f_;
ESPPreferenceObject pref_;

View File

@@ -143,7 +143,7 @@ bool ListEntitiesIterator::on_water_heater(water_heater::WaterHeater *obj) {
#ifdef USE_INFRARED
bool ListEntitiesIterator::on_infrared(infrared::Infrared *obj) {
// Infrared web_server support not yet implemented - this stub acknowledges the entity
this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::infrared_all_json_generator);
return true;
}
#endif

View File

@@ -33,6 +33,10 @@
#include "esphome/components/water_heater/water_heater.h"
#endif
#ifdef USE_INFRARED
#include "esphome/components/infrared/infrared.h"
#endif
#ifdef USE_WEBSERVER_LOCAL
#if USE_WEBSERVER_VERSION == 2
#include "server_index_v2.h"
@@ -1952,6 +1956,110 @@ std::string WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDe
}
#endif
#ifdef USE_INFRARED
void WebServer::handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (infrared::Infrared *obj : App.get_infrareds()) {
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->infrared_json_(obj, detail);
request->send(200, ESPHOME_F("application/json"), data.c_str());
return;
}
if (!match.method_equals(ESPHOME_F("transmit"))) {
request->send(404);
return;
}
// Only allow transmit if the device supports it
if (!obj->has_transmitter()) {
request->send(400, ESPHOME_F("text/plain"), "Device does not support transmission");
return;
}
// Parse parameters
auto call = obj->make_call();
// Parse carrier frequency (optional)
if (request->hasParam(ESPHOME_F("carrier_frequency"))) {
auto value = parse_number<uint32_t>(request->getParam(ESPHOME_F("carrier_frequency"))->value().c_str());
if (value.has_value()) {
call.set_carrier_frequency(*value);
}
}
// Parse repeat count (optional, defaults to 1)
if (request->hasParam(ESPHOME_F("repeat_count"))) {
auto value = parse_number<uint32_t>(request->getParam(ESPHOME_F("repeat_count"))->value().c_str());
if (value.has_value()) {
call.set_repeat_count(*value);
}
}
// Parse base64url-encoded raw timings (required)
// Base64url is URL-safe: uses A-Za-z0-9-_ (no special characters needing escaping)
if (!request->hasParam(ESPHOME_F("data"))) {
request->send(400, ESPHOME_F("text/plain"), "Missing 'data' parameter");
return;
}
// .c_str() is required for Arduino framework where value() returns Arduino String instead of std::string
std::string encoded =
request->getParam(ESPHOME_F("data"))->value().c_str(); // NOLINT(readability-redundant-string-cstr)
// Validate base64url is not empty
if (encoded.empty()) {
request->send(400, ESPHOME_F("text/plain"), "Empty 'data' parameter");
return;
}
#ifdef USE_ESP8266
// ESP8266 is single-threaded, call directly
call.set_raw_timings_base64url(encoded);
call.perform();
#else
// Defer to main loop for thread safety. Move encoded string into lambda to ensure
// it outlives the call - set_raw_timings_base64url stores a pointer, so the string
// must remain valid until perform() completes.
this->defer([call, encoded = std::move(encoded)]() mutable {
call.set_raw_timings_base64url(encoded);
call.perform();
});
#endif
request->send(200);
return;
}
request->send(404);
}
std::string WebServer::infrared_all_json_generator(WebServer *web_server, void *source) {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
return web_server->infrared_json_(static_cast<infrared::Infrared *>(source), DETAIL_ALL);
}
std::string WebServer::infrared_json_(infrared::Infrared *obj, JsonDetail start_config) {
json::JsonBuilder builder;
JsonObject root = builder.root();
set_json_icon_state_value(root, obj, "infrared", "", 0, start_config);
auto traits = obj->get_traits();
root[ESPHOME_F("supports_transmitter")] = traits.get_supports_transmitter();
root[ESPHOME_F("supports_receiver")] = traits.get_supports_receiver();
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
return builder.serialize();
}
#endif
#ifdef USE_EVENT
void WebServer::on_event(event::Event *obj) {
if (!this->include_internal_ && obj->is_internal())
@@ -2083,24 +2191,21 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const {
const auto &url = request->url();
const auto method = request->method();
// Static URL checks
static const char *const STATIC_URLS[] = {
"/",
// Static URL checks - use ESPHOME_F to keep strings in flash on ESP8266
if (url == ESPHOME_F("/"))
return true;
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
"/events",
if (url == ESPHOME_F("/events"))
return true;
#endif
#ifdef USE_WEBSERVER_CSS_INCLUDE
"/0.css",
if (url == ESPHOME_F("/0.css"))
return true;
#endif
#ifdef USE_WEBSERVER_JS_INCLUDE
"/0.js",
if (url == ESPHOME_F("/0.js"))
return true;
#endif
};
for (const auto &static_url : STATIC_URLS) {
if (url == static_url)
return true;
}
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
if (method == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network")))
@@ -2120,90 +2225,100 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const {
if (!is_get_or_post)
return false;
// Use lookup tables for domain checks
static const char *const GET_ONLY_DOMAINS[] = {
// Check GET-only domains - use ESPHOME_F to keep strings in flash on ESP8266
if (is_get) {
#ifdef USE_SENSOR
"sensor",
if (match.domain_equals(ESPHOME_F("sensor")))
return true;
#endif
#ifdef USE_BINARY_SENSOR
"binary_sensor",
if (match.domain_equals(ESPHOME_F("binary_sensor")))
return true;
#endif
#ifdef USE_TEXT_SENSOR
"text_sensor",
if (match.domain_equals(ESPHOME_F("text_sensor")))
return true;
#endif
#ifdef USE_EVENT
"event",
if (match.domain_equals(ESPHOME_F("event")))
return true;
#endif
};
static const char *const GET_POST_DOMAINS[] = {
#ifdef USE_SWITCH
"switch",
#endif
#ifdef USE_BUTTON
"button",
#endif
#ifdef USE_FAN
"fan",
#endif
#ifdef USE_LIGHT
"light",
#endif
#ifdef USE_COVER
"cover",
#endif
#ifdef USE_NUMBER
"number",
#endif
#ifdef USE_DATETIME_DATE
"date",
#endif
#ifdef USE_DATETIME_TIME
"time",
#endif
#ifdef USE_DATETIME_DATETIME
"datetime",
#endif
#ifdef USE_TEXT
"text",
#endif
#ifdef USE_SELECT
"select",
#endif
#ifdef USE_CLIMATE
"climate",
#endif
#ifdef USE_LOCK
"lock",
#endif
#ifdef USE_VALVE
"valve",
#endif
#ifdef USE_ALARM_CONTROL_PANEL
"alarm_control_panel",
#endif
#ifdef USE_UPDATE
"update",
#endif
#ifdef USE_WATER_HEATER
"water_heater",
#endif
};
// Check GET-only domains
if (is_get) {
for (const auto &domain : GET_ONLY_DOMAINS) {
if (match.domain_equals(domain))
return true;
}
}
// Check GET+POST domains
if (is_get_or_post) {
for (const auto &domain : GET_POST_DOMAINS) {
if (match.domain_equals(domain))
return true;
}
#ifdef USE_SWITCH
if (match.domain_equals(ESPHOME_F("switch")))
return true;
#endif
#ifdef USE_BUTTON
if (match.domain_equals(ESPHOME_F("button")))
return true;
#endif
#ifdef USE_FAN
if (match.domain_equals(ESPHOME_F("fan")))
return true;
#endif
#ifdef USE_LIGHT
if (match.domain_equals(ESPHOME_F("light")))
return true;
#endif
#ifdef USE_COVER
if (match.domain_equals(ESPHOME_F("cover")))
return true;
#endif
#ifdef USE_NUMBER
if (match.domain_equals(ESPHOME_F("number")))
return true;
#endif
#ifdef USE_DATETIME_DATE
if (match.domain_equals(ESPHOME_F("date")))
return true;
#endif
#ifdef USE_DATETIME_TIME
if (match.domain_equals(ESPHOME_F("time")))
return true;
#endif
#ifdef USE_DATETIME_DATETIME
if (match.domain_equals(ESPHOME_F("datetime")))
return true;
#endif
#ifdef USE_TEXT
if (match.domain_equals(ESPHOME_F("text")))
return true;
#endif
#ifdef USE_SELECT
if (match.domain_equals(ESPHOME_F("select")))
return true;
#endif
#ifdef USE_CLIMATE
if (match.domain_equals(ESPHOME_F("climate")))
return true;
#endif
#ifdef USE_LOCK
if (match.domain_equals(ESPHOME_F("lock")))
return true;
#endif
#ifdef USE_VALVE
if (match.domain_equals(ESPHOME_F("valve")))
return true;
#endif
#ifdef USE_ALARM_CONTROL_PANEL
if (match.domain_equals(ESPHOME_F("alarm_control_panel")))
return true;
#endif
#ifdef USE_UPDATE
if (match.domain_equals(ESPHOME_F("update")))
return true;
#endif
#ifdef USE_WATER_HEATER
if (match.domain_equals(ESPHOME_F("water_heater")))
return true;
#endif
#ifdef USE_INFRARED
if (match.domain_equals(ESPHOME_F("infrared")))
return true;
#endif
}
return false;
@@ -2352,6 +2467,11 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
else if (match.domain_equals(ESPHOME_F("water_heater"))) {
this->handle_water_heater_request(request, match);
}
#endif
#ifdef USE_INFRARED
else if (match.domain_equals(ESPHOME_F("infrared"))) {
this->handle_infrared_request(request, match);
}
#endif
else {
// No matching handler found - send 404

View File

@@ -460,6 +460,13 @@ class WebServer : public Controller,
static std::string water_heater_all_json_generator(WebServer *web_server, void *source);
#endif
#ifdef USE_INFRARED
/// Handle an infrared request under '/infrared/<id>/transmit'.
void handle_infrared_request(AsyncWebServerRequest *request, const UrlMatch &match);
static std::string infrared_all_json_generator(WebServer *web_server, void *source);
#endif
#ifdef USE_EVENT
void on_event(event::Event *obj) override;
@@ -662,6 +669,9 @@ class WebServer : public Controller,
#ifdef USE_WATER_HEATER
std::string water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config);
#endif
#ifdef USE_INFRARED
std::string infrared_json_(infrared::Infrared *obj, JsonDetail start_config);
#endif
#ifdef USE_UPDATE
std::string update_json_(update::UpdateEntity *obj, JsonDetail start_config);
#endif

View File

@@ -404,15 +404,31 @@ std::string format_hex_pretty(const std::string &data, char separator, bool show
return format_hex_pretty_uint8(reinterpret_cast<const uint8_t *>(data.data()), data.length(), separator, show_length);
}
char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) {
if (buffer_size == 0) {
return buffer;
}
// Calculate max bytes we can format: each byte needs 8 chars
size_t max_bytes = (buffer_size - 1) / 8;
if (max_bytes == 0 || length == 0) {
buffer[0] = '\0';
return buffer;
}
size_t bytes_to_format = std::min(length, max_bytes);
for (size_t byte_idx = 0; byte_idx < bytes_to_format; byte_idx++) {
for (size_t bit_idx = 0; bit_idx < 8; bit_idx++) {
buffer[byte_idx * 8 + bit_idx] = ((data[byte_idx] >> (7 - bit_idx)) & 1) + '0';
}
}
buffer[bytes_to_format * 8] = '\0';
return buffer;
}
std::string format_bin(const uint8_t *data, size_t length) {
std::string result;
result.resize(length * 8);
for (size_t byte_idx = 0; byte_idx < length; byte_idx++) {
for (size_t bit_idx = 0; bit_idx < 8; bit_idx++) {
result[byte_idx * 8 + bit_idx] = ((data[byte_idx] >> (7 - bit_idx)) & 1) + '0';
}
}
format_bin_to(&result[0], length * 8 + 1, data, length);
return result;
}

View File

@@ -1096,9 +1096,66 @@ std::string format_hex_pretty(T val, char separator = '.', bool show_length = tr
return format_hex_pretty(reinterpret_cast<uint8_t *>(&val), sizeof(T), separator, show_length);
}
/// Calculate buffer size needed for format_bin_to: "01234567...\0" = bytes * 8 + 1
constexpr size_t format_bin_size(size_t byte_count) { return byte_count * 8 + 1; }
/** Format byte array as binary string to buffer.
*
* Each byte is formatted as 8 binary digits (MSB first).
* Truncates output if data exceeds buffer capacity.
*
* @param buffer Output buffer to write to.
* @param buffer_size Size of the output buffer.
* @param data Pointer to the byte array to format.
* @param length Number of bytes in the array.
* @return Pointer to buffer.
*
* Buffer size needed: length * 8 + 1 (use format_bin_size()).
*
* Example:
* @code
* char buf[9]; // format_bin_size(1)
* format_bin_to(buf, sizeof(buf), data, 1); // "10101011"
* @endcode
*/
char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length);
/// Format byte array as binary to buffer. Automatically deduces buffer size.
template<size_t N> inline char *format_bin_to(char (&buffer)[N], const uint8_t *data, size_t length) {
static_assert(N >= 9, "Buffer must hold at least one binary byte (9 chars)");
return format_bin_to(buffer, N, data, length);
}
/** Format an unsigned integer in binary to buffer, MSB first.
*
* @tparam N Buffer size (must be >= sizeof(T) * 8 + 1).
* @tparam T Unsigned integer type.
* @param buffer Output buffer to write to.
* @param val The unsigned integer value to format.
* @return Pointer to buffer.
*
* Example:
* @code
* char buf[9]; // format_bin_size(sizeof(uint8_t))
* format_bin_to(buf, uint8_t{0xAA}); // "10101010"
* char buf16[17]; // format_bin_size(sizeof(uint16_t))
* format_bin_to(buf16, uint16_t{0x1234}); // "0001001000110100"
* @endcode
*/
template<size_t N, typename T, enable_if_t<std::is_unsigned<T>::value, int> = 0>
inline char *format_bin_to(char (&buffer)[N], T val) {
static_assert(N >= sizeof(T) * 8 + 1, "Buffer too small for type");
val = convert_big_endian(val);
return format_bin_to(buffer, reinterpret_cast<const uint8_t *>(&val), sizeof(T));
}
/// Format the byte array \p data of length \p len in binary.
/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead.
/// Causes heap fragmentation on long-running devices.
std::string format_bin(const uint8_t *data, size_t length);
/// Format an unsigned integer in binary, starting with the most significant byte.
/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead.
/// Causes heap fragmentation on long-running devices.
template<typename T, enable_if_t<std::is_unsigned<T>::value, int> = 0> std::string format_bin(T val) {
val = convert_big_endian(val);
return format_bin(reinterpret_cast<uint8_t *>(&val), sizeof(T));

View File

@@ -12,6 +12,8 @@
#define ESPHOME_strncpy_P strncpy_P
#define ESPHOME_strncat_P strncat_P
#define ESPHOME_snprintf_P snprintf_P
// Type for pointers to PROGMEM strings (for use with ESPHOME_F return values)
using ProgmemStr = const __FlashStringHelper *;
#else
#define ESPHOME_F(string_literal) (string_literal)
#define ESPHOME_PGM_P const char *
@@ -19,4 +21,6 @@
#define ESPHOME_strncpy_P strncpy
#define ESPHOME_strncat_P strncat
#define ESPHOME_snprintf_P snprintf
// Type for pointers to strings (no PROGMEM on non-ESP8266 platforms)
using ProgmemStr = const char *;
#endif

View File

@@ -72,7 +72,6 @@ class StringRef {
constexpr const char *c_str() const { return base_; }
constexpr size_type size() const { return len_; }
constexpr size_type length() const { return len_; }
constexpr bool empty() const { return len_ == 0; }
constexpr const_reference operator[](size_type pos) const { return *(base_ + pos); }
@@ -81,32 +80,6 @@ class StringRef {
operator std::string() const { return str(); }
/// Find first occurrence of substring, returns std::string::npos if not found.
/// Note: Requires the underlying string to be null-terminated.
size_type find(const char *s, size_type pos = 0) const {
if (pos >= len_)
return std::string::npos;
const char *result = std::strstr(base_ + pos, s);
// Verify entire match is within bounds (strstr searches to null terminator)
if (result && result + std::strlen(s) <= base_ + len_)
return static_cast<size_type>(result - base_);
return std::string::npos;
}
size_type find(char c, size_type pos = 0) const {
if (pos >= len_)
return std::string::npos;
const void *result = std::memchr(base_ + pos, static_cast<unsigned char>(c), len_ - pos);
return result ? static_cast<size_type>(static_cast<const char *>(result) - base_) : std::string::npos;
}
/// Return substring as std::string
std::string substr(size_type pos = 0, size_type count = std::string::npos) const {
if (pos >= len_)
return std::string();
size_type actual_count = (count == std::string::npos || pos + count > len_) ? len_ - pos : count;
return std::string(base_ + pos, actual_count);
}
private:
const char *base_;
size_type len_;
@@ -187,43 +160,6 @@ inline std::string operator+(const std::string &lhs, const StringRef &rhs) {
str.append(rhs.c_str(), rhs.size());
return str;
}
// String conversion functions for ADL compatibility (allows stoi(x) where x is StringRef)
// Must be in esphome namespace for ADL to find them. Uses strtol/strtod directly to avoid heap allocation.
namespace internal {
// NOLINTBEGIN(google-runtime-int)
template<typename R, typename F> inline R parse_number(const StringRef &str, size_t *pos, F conv) {
char *end;
R result = conv(str.c_str(), &end);
// Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number
if (pos)
*pos = (end == str.c_str()) ? 0 : static_cast<size_t>(end - str.c_str());
return result;
}
template<typename R, typename F> inline R parse_number(const StringRef &str, size_t *pos, int base, F conv) {
char *end;
R result = conv(str.c_str(), &end, base);
// Set pos to 0 on conversion failure (when no characters consumed), otherwise index after number
if (pos)
*pos = (end == str.c_str()) ? 0 : static_cast<size_t>(end - str.c_str());
return result;
}
// NOLINTEND(google-runtime-int)
} // namespace internal
// NOLINTBEGIN(readability-identifier-naming,google-runtime-int)
inline int stoi(const StringRef &str, size_t *pos = nullptr, int base = 10) {
return static_cast<int>(internal::parse_number<long>(str, pos, base, std::strtol));
}
inline long stol(const StringRef &str, size_t *pos = nullptr, int base = 10) {
return internal::parse_number<long>(str, pos, base, std::strtol);
}
inline float stof(const StringRef &str, size_t *pos = nullptr) {
return internal::parse_number<float>(str, pos, std::strtof);
}
inline double stod(const StringRef &str, size_t *pos = nullptr) {
return internal::parse_number<double>(str, pos, std::strtod);
}
// NOLINTEND(readability-identifier-naming,google-runtime-int)
#ifdef USE_JSON
// NOLINTNEXTLINE(readability-identifier-naming)
inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); }

View File

@@ -44,4 +44,3 @@ gpio_Flags = gpio_ns.enum("Flags", is_class=True)
EntityCategory = esphome_ns.enum("EntityCategory")
Parented = esphome_ns.class_("Parented")
ESPTime = esphome_ns.struct("ESPTime")
StringRef = esphome_ns.class_("StringRef")

View File

@@ -682,6 +682,7 @@ def lint_trailing_whitespace(fname, match):
# Heap-allocating helpers that cause fragmentation on long-running embedded devices.
# These return std::string and should be replaced with stack-based alternatives.
HEAP_ALLOCATING_HELPERS = {
"format_bin": "format_bin_to() with a stack buffer",
"format_hex": "format_hex_to() with a stack buffer",
"format_hex_pretty": "format_hex_pretty_to() with a stack buffer",
"format_mac_address_pretty": "format_mac_addr_upper() with a stack buffer",
@@ -699,6 +700,7 @@ HEAP_ALLOCATING_HELPERS = {
# get_mac_address(?!_) ensures we don't match get_mac_address_into_buffer, etc.
# CPP_RE_EOL captures rest of line so NOLINT comments are detected
r"[^\w]("
r"format_bin(?!_)|"
r"format_hex(?!_)|"
r"format_hex_pretty(?!_)|"
r"format_mac_address_pretty|"

View File

@@ -1,85 +0,0 @@
esphome:
name: select-stringref-test
friendly_name: Select StringRef Test
host:
logger:
level: DEBUG
api:
select:
- platform: template
name: "Test Select"
id: test_select
optimistic: true
options:
- "Option A"
- "Option B"
- "Option C"
initial_option: "Option A"
on_value:
then:
# Test 1: Log the value directly (StringRef -> const char* via c_str())
- logger.log:
format: "Select value: %s"
args: ['x.c_str()']
# Test 2: String concatenation (StringRef + const char* -> std::string)
- lambda: |-
std::string with_suffix = x + " selected";
ESP_LOGI("test", "Concatenated: %s", with_suffix.c_str());
# Test 3: Comparison (StringRef == const char*)
- lambda: |-
if (x == "Option B") {
ESP_LOGI("test", "Option B was selected");
}
# Test 4: Use index parameter (variable name is 'i')
- lambda: |-
ESP_LOGI("test", "Select index: %d", (int)i);
# Test 5: StringRef.length() method
- lambda: |-
ESP_LOGI("test", "Length: %d", (int)x.length());
# Test 6: StringRef.find() method with substring
- lambda: |-
if (x.find("Option") != std::string::npos) {
ESP_LOGI("test", "Found 'Option' in value");
}
# Test 7: StringRef.find() method with character
- lambda: |-
size_t space_pos = x.find(' ');
if (space_pos != std::string::npos) {
ESP_LOGI("test", "Space at position: %d", (int)space_pos);
}
# Test 8: StringRef.substr() method
- lambda: |-
std::string prefix = x.substr(0, 6);
ESP_LOGI("test", "Substr prefix: %s", prefix.c_str());
# Second select with numeric options to test ADL functions
- platform: template
name: "Baud Rate"
id: baud_select
optimistic: true
options:
- "9600"
- "115200"
initial_option: "9600"
on_value:
then:
# Test 9: stoi via ADL
- lambda: |-
int baud = stoi(x);
ESP_LOGI("test", "stoi result: %d", baud);
# Test 10: stol via ADL
- lambda: |-
long baud_long = stol(x);
ESP_LOGI("test", "stol result: %ld", baud_long);
# Test 11: stof via ADL
- lambda: |-
float baud_float = stof(x);
ESP_LOGI("test", "stof result: %.0f", baud_float);
# Test 12: stod via ADL
- lambda: |-
double baud_double = stod(x);
ESP_LOGI("test", "stod result: %.0f", baud_double);

View File

@@ -1,143 +0,0 @@
"""Integration test for select on_value trigger with StringRef parameter."""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_select_stringref_trigger(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test select on_value trigger passes StringRef that works with string operations."""
loop = asyncio.get_running_loop()
# Track log messages to verify StringRef operations work
value_logged_future = loop.create_future()
concatenated_future = loop.create_future()
comparison_future = loop.create_future()
index_logged_future = loop.create_future()
length_future = loop.create_future()
find_substr_future = loop.create_future()
find_char_future = loop.create_future()
substr_future = loop.create_future()
# ADL functions
stoi_future = loop.create_future()
stol_future = loop.create_future()
stof_future = loop.create_future()
stod_future = loop.create_future()
# Patterns to match in logs
value_pattern = re.compile(r"Select value: Option B")
concatenated_pattern = re.compile(r"Concatenated: Option B selected")
comparison_pattern = re.compile(r"Option B was selected")
index_pattern = re.compile(r"Select index: 1")
length_pattern = re.compile(r"Length: 8") # "Option B" is 8 chars
find_substr_pattern = re.compile(r"Found 'Option' in value")
find_char_pattern = re.compile(r"Space at position: 6") # space at index 6
substr_pattern = re.compile(r"Substr prefix: Option")
# ADL function patterns (115200 from baud rate select)
stoi_pattern = re.compile(r"stoi result: 115200")
stol_pattern = re.compile(r"stol result: 115200")
stof_pattern = re.compile(r"stof result: 115200")
stod_pattern = re.compile(r"stod result: 115200")
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if not value_logged_future.done() and value_pattern.search(line):
value_logged_future.set_result(True)
if not concatenated_future.done() and concatenated_pattern.search(line):
concatenated_future.set_result(True)
if not comparison_future.done() and comparison_pattern.search(line):
comparison_future.set_result(True)
if not index_logged_future.done() and index_pattern.search(line):
index_logged_future.set_result(True)
if not length_future.done() and length_pattern.search(line):
length_future.set_result(True)
if not find_substr_future.done() and find_substr_pattern.search(line):
find_substr_future.set_result(True)
if not find_char_future.done() and find_char_pattern.search(line):
find_char_future.set_result(True)
if not substr_future.done() and substr_pattern.search(line):
substr_future.set_result(True)
# ADL functions
if not stoi_future.done() and stoi_pattern.search(line):
stoi_future.set_result(True)
if not stol_future.done() and stol_pattern.search(line):
stol_future.set_result(True)
if not stof_future.done() and stof_pattern.search(line):
stof_future.set_result(True)
if not stod_future.done() and stod_pattern.search(line):
stod_future.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "select-stringref-test"
# List entities to find our select
entities, _ = await client.list_entities_services()
select_entity = next(
(e for e in entities if hasattr(e, "options") and e.name == "Test Select"),
None,
)
assert select_entity is not None, "Test Select entity not found"
baud_entity = next(
(e for e in entities if hasattr(e, "options") and e.name == "Baud Rate"),
None,
)
assert baud_entity is not None, "Baud Rate entity not found"
# Change select to Option B - this should trigger on_value with StringRef
client.select_command(select_entity.key, "Option B")
# Change baud to 115200 - this tests ADL functions (stoi, stol, stof, stod)
client.select_command(baud_entity.key, "115200")
# Wait for all log messages confirming StringRef operations work
try:
await asyncio.wait_for(
asyncio.gather(
value_logged_future,
concatenated_future,
comparison_future,
index_logged_future,
length_future,
find_substr_future,
find_char_future,
substr_future,
stoi_future,
stol_future,
stof_future,
stod_future,
),
timeout=5.0,
)
except TimeoutError:
results = {
"value_logged": value_logged_future.done(),
"concatenated": concatenated_future.done(),
"comparison": comparison_future.done(),
"index_logged": index_logged_future.done(),
"length": length_future.done(),
"find_substr": find_substr_future.done(),
"find_char": find_char_future.done(),
"substr": substr_future.done(),
"stoi": stoi_future.done(),
"stol": stol_future.done(),
"stof": stof_future.done(),
"stod": stod_future.done(),
}
pytest.fail(f"StringRef operations failed - received: {results}")