Merge branch 'dev' into rp2040-upload-improvements

This commit is contained in:
J. Nick Koston
2026-03-06 07:05:45 -10:00
committed by GitHub
56 changed files with 3661 additions and 142 deletions
+1
View File
@@ -55,6 +55,7 @@ esphome/components/audio/* @kahrendt
esphome/components/audio_adc/* @kbx81
esphome/components/audio_dac/* @kbx81
esphome/components/audio_file/* @kahrendt
esphome/components/audio_file/media_source/* @kahrendt
esphome/components/axs15231/* @clydebarrow
esphome/components/b_parasite/* @rbaron
esphome/components/ballu/* @bazuchan
+2 -1
View File
@@ -354,7 +354,8 @@ class APIConnection final : public APIServerConnectionBase {
// Set common EntityBase properties
#ifdef USE_ENTITY_ICON
msg.icon = entity->get_icon_ref();
char icon_buf[MAX_ICON_LENGTH];
msg.icon = StringRef(entity->get_icon_to(icon_buf));
#endif
msg.disabled_by_default = entity->is_disabled_by_default();
msg.entity_category = static_cast<enums::EntityCategory>(entity->get_entity_category());
@@ -269,7 +269,7 @@ APIError APINoiseFrameHelper::state_action_() {
}
if (state_ == State::SERVER_HELLO) {
// send server hello
const std::string &name = App.get_name();
const auto &name = App.get_name();
char mac[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac);
@@ -0,0 +1,38 @@
import esphome.codegen as cg
from esphome.components import media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
CODEOWNERS = ["@kahrendt"]
AUTO_LOAD = ["audio"]
DEPENDENCIES = ["audio_file"]
audio_file_ns = cg.esphome_ns.namespace("audio_file")
AudioFileMediaSource = audio_file_ns.class_(
"AudioFileMediaSource", cg.Component, media_source.MediaSource
)
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioFileMediaSource,
)
.extend(
{
cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
cv.boolean, cv.requires_component(psram.DOMAIN)
),
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await media_source.register_media_source(var, config)
if CONF_TASK_STACK_IN_PSRAM in config:
cg.add(var.set_task_stack_in_psram(config[CONF_TASK_STACK_IN_PSRAM]))
@@ -0,0 +1,283 @@
#include "audio_file_media_source.h"
#ifdef USE_ESP32
#include "esphome/components/audio/audio_decoder.h"
#include <cstring>
namespace esphome::audio_file {
namespace { // anonymous namespace for internal linkage
struct AudioSinkAdapter : public audio::AudioSinkCallback {
media_source::MediaSource *source;
audio::AudioStreamInfo stream_info;
size_t audio_sink_write(uint8_t *data, size_t length, TickType_t ticks_to_wait) override {
return this->source->write_output(data, length, pdTICKS_TO_MS(ticks_to_wait), this->stream_info);
}
};
} // namespace
#if defined(USE_AUDIO_OPUS_SUPPORT)
static constexpr uint32_t DECODE_TASK_STACK_SIZE = 5 * 1024;
#else
static constexpr uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024;
#endif
static const char *const TAG = "audio_file_media_source";
enum EventGroupBits : uint32_t {
// Requests to start playback (set by play_uri, handled by loop)
REQUEST_START = (1 << 0),
// Commands from main loop to decode task
COMMAND_STOP = (1 << 1),
COMMAND_PAUSE = (1 << 2),
// Decode task lifecycle signals (one-shot, cleared by loop)
TASK_STARTING = (1 << 7),
TASK_RUNNING = (1 << 8),
TASK_STOPPING = (1 << 9),
TASK_STOPPED = (1 << 10),
TASK_ERROR = (1 << 11),
// Decode task state (level-triggered, set/cleared by decode task)
TASK_PAUSED = (1 << 12),
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
};
void AudioFileMediaSource::dump_config() {
ESP_LOGCONFIG(TAG, "Audio File Media Source:");
ESP_LOGCONFIG(TAG, " Task Stack in PSRAM: %s", this->task_stack_in_psram_ ? "Yes" : "No");
}
void AudioFileMediaSource::setup() {
this->disable_loop();
this->event_group_ = xEventGroupCreate();
if (this->event_group_ == nullptr) {
ESP_LOGE(TAG, "Failed to create event group");
this->mark_failed();
return;
}
}
void AudioFileMediaSource::loop() {
EventBits_t event_bits = xEventGroupGetBits(this->event_group_);
if (event_bits & REQUEST_START) {
xEventGroupClearBits(this->event_group_, REQUEST_START);
this->decoding_state_ = AudioFileDecodingState::START_TASK;
}
switch (this->decoding_state_) {
case AudioFileDecodingState::START_TASK: {
if (!this->decode_task_.is_created()) {
xEventGroupClearBits(this->event_group_, ALL_BITS);
if (!this->decode_task_.create(decode_task, "AudioFileDec", DECODE_TASK_STACK_SIZE, this, 1,
this->task_stack_in_psram_)) {
ESP_LOGE(TAG, "Failed to create task");
this->status_momentary_error("task_create", 1000);
this->set_state_(media_source::MediaSourceState::ERROR);
this->decoding_state_ = AudioFileDecodingState::IDLE;
return;
}
}
this->decoding_state_ = AudioFileDecodingState::DECODING;
break;
}
case AudioFileDecodingState::DECODING: {
if (event_bits & TASK_STARTING) {
ESP_LOGD(TAG, "Starting");
xEventGroupClearBits(this->event_group_, TASK_STARTING);
}
if (event_bits & TASK_RUNNING) {
ESP_LOGV(TAG, "Started");
xEventGroupClearBits(this->event_group_, TASK_RUNNING);
this->set_state_(media_source::MediaSourceState::PLAYING);
}
if ((event_bits & TASK_PAUSED) && this->get_state() != media_source::MediaSourceState::PAUSED) {
this->set_state_(media_source::MediaSourceState::PAUSED);
} else if (!(event_bits & TASK_PAUSED) && this->get_state() == media_source::MediaSourceState::PAUSED) {
this->set_state_(media_source::MediaSourceState::PLAYING);
}
if (event_bits & TASK_STOPPING) {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, TASK_STOPPING);
}
if (event_bits & TASK_ERROR) {
// Report error so the orchestrator knows playback failed; task will have already logged the specific error
this->set_state_(media_source::MediaSourceState::ERROR);
}
if (event_bits & TASK_STOPPED) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, ALL_BITS);
this->decode_task_.deallocate();
this->set_state_(media_source::MediaSourceState::IDLE);
this->decoding_state_ = AudioFileDecodingState::IDLE;
}
break;
}
case AudioFileDecodingState::IDLE: {
if (this->get_state() == media_source::MediaSourceState::ERROR && !this->status_has_error()) {
this->set_state_(media_source::MediaSourceState::IDLE);
}
break;
}
}
if ((this->decoding_state_ == AudioFileDecodingState::IDLE) &&
(this->get_state() == media_source::MediaSourceState::IDLE)) {
this->disable_loop();
}
}
// Called from the orchestrator's main loop, so no synchronization needed with loop()
bool AudioFileMediaSource::play_uri(const std::string &uri) {
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener() ||
xEventGroupGetBits(this->event_group_) & REQUEST_START) {
return false;
}
// Check if source is already playing
if (this->get_state() != media_source::MediaSourceState::IDLE) {
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
return false;
}
// Validate URI starts with "audio-file://"
if (!uri.starts_with("audio-file://")) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
// Strip "audio-file://" prefix and find the file
const char *file_id = uri.c_str() + 13; // "audio-file://" is 13 characters
for (const auto &named_file : get_named_audio_files()) {
if (strcmp(named_file.file_id, file_id) == 0) {
this->current_file_ = named_file.file;
xEventGroupSetBits(this->event_group_, EventGroupBits::REQUEST_START);
this->enable_loop();
return true;
}
}
ESP_LOGE(TAG, "Unknown file: '%s'", file_id);
return false;
}
// Called from the orchestrator's main loop, so no synchronization needed with loop()
void AudioFileMediaSource::handle_command(media_source::MediaSourceCommand command) {
if (this->decoding_state_ != AudioFileDecodingState::DECODING) {
return;
}
switch (command) {
case media_source::MediaSourceCommand::STOP:
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_STOP);
break;
case media_source::MediaSourceCommand::PAUSE:
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
break;
case media_source::MediaSourceCommand::PLAY:
xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
break;
default:
break;
}
}
void AudioFileMediaSource::decode_task(void *params) {
AudioFileMediaSource *this_source = static_cast<AudioFileMediaSource *>(params);
do { // do-while(false) ensures RAII objects are destroyed on all exit paths via break
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STARTING);
// 0 bytes for input transfer buffer makes it an inplace buffer
std::unique_ptr<audio::AudioDecoder> decoder = make_unique<audio::AudioDecoder>(0, 4096);
esp_err_t err = decoder->start(this_source->current_file_->file_type);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start decoder: %s", esp_err_to_name(err));
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR | EventGroupBits::TASK_STOPPING);
break;
}
// Add the file as a const data source
decoder->add_source(this_source->current_file_->data, this_source->current_file_->length);
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_RUNNING);
AudioSinkAdapter audio_sink;
bool has_stream_info = false;
while (true) {
EventBits_t event_bits = xEventGroupGetBits(this_source->event_group_);
if (event_bits & EventGroupBits::COMMAND_STOP) {
break;
}
bool paused = event_bits & EventGroupBits::COMMAND_PAUSE;
decoder->set_pause_output_state(paused);
if (paused) {
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
vTaskDelay(pdMS_TO_TICKS(20));
} else {
xEventGroupClearBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
}
// Will stop gracefully once finished with the current file
audio::AudioDecoderState decoder_state = decoder->decode(true);
if (decoder_state == audio::AudioDecoderState::FINISHED) {
break;
} else if (decoder_state == audio::AudioDecoderState::FAILED) {
ESP_LOGE(TAG, "Decoder failed");
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
break;
}
if (!has_stream_info && decoder->get_audio_stream_info().has_value()) {
has_stream_info = true;
audio::AudioStreamInfo stream_info = decoder->get_audio_stream_info().value();
ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %d", stream_info.get_bits_per_sample(),
stream_info.get_channels(), stream_info.get_sample_rate());
if (stream_info.get_bits_per_sample() != 16 || stream_info.get_channels() > 2) {
ESP_LOGE(TAG, "Incompatible audio stream. Only 16 bits per sample and 1 or 2 channels are supported");
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
break;
}
audio_sink.source = this_source;
audio_sink.stream_info = stream_info;
esp_err_t err = decoder->add_sink(&audio_sink);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to add sink: %s", esp_err_to_name(err));
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
break;
}
}
}
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPING);
} while (false);
// All RAII objects from the do-while block (decoder, audio_sink, etc.) are now destroyed.
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPED);
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
}
} // namespace esphome::audio_file
#endif // USE_ESP32
@@ -0,0 +1,50 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/audio/audio.h"
#include "esphome/components/audio_file/audio_file.h"
#include "esphome/components/media_source/media_source.h"
#include "esphome/core/component.h"
#include "esphome/core/static_task.h"
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
namespace esphome::audio_file {
enum class AudioFileDecodingState : uint8_t {
START_TASK,
DECODING,
IDLE,
};
class AudioFileMediaSource : public Component, public media_source::MediaSource {
public:
void setup() override;
void loop() override;
void dump_config() override;
// MediaSource interface implementation
bool play_uri(const std::string &uri) override;
void handle_command(media_source::MediaSourceCommand command) override;
bool can_handle(const std::string &uri) const override { return uri.starts_with("audio-file://"); }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
protected:
static void decode_task(void *params);
audio::AudioFile *current_file_{nullptr};
AudioFileDecodingState decoding_state_{AudioFileDecodingState::IDLE};
EventGroupHandle_t event_group_{nullptr};
StaticTask decode_task_;
bool task_stack_in_psram_{false};
};
} // namespace esphome::audio_file
#endif // USE_ESP32
+48 -5
View File
@@ -1,29 +1,64 @@
import esphome.codegen as cg
from esphome.components.logger import request_log_listener
from esphome.components.uart import (
UARTComponent,
debug_to_code,
maybe_empty_debug,
uart_ns,
)
from esphome.components.zephyr import zephyr_add_prj_conf
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LOGS, CONF_TYPE
from esphome.const import (
CONF_DEBUG,
CONF_ID,
CONF_LOGS,
CONF_RX_BUFFER_SIZE,
CONF_TX_BUFFER_SIZE,
CONF_TYPE,
)
from esphome.types import ConfigType
AUTO_LOAD = ["zephyr_ble_server"]
AUTO_LOAD = ["zephyr_ble_server", "uart"]
CODEOWNERS = ["@tomaszduda23"]
ble_nus_ns = cg.esphome_ns.namespace("ble_nus")
BLENUS = ble_nus_ns.class_("BLENUS", cg.Component)
BLENUS = ble_nus_ns.class_("BLENUS", cg.Component, UARTComponent)
CONF_UART = "uart"
def validate_rx_buffer(config: ConfigType) -> ConfigType:
config = config.copy()
if config[CONF_TYPE] == CONF_LOGS:
if CONF_RX_BUFFER_SIZE in config:
raise cv.Invalid("logs does not support rx_buffer_size")
elif CONF_RX_BUFFER_SIZE not in config:
config[CONF_RX_BUFFER_SIZE] = 512
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(BLENUS),
cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of(
*[CONF_LOGS], lower=True
*[CONF_LOGS, CONF_UART], lower=True
),
cv.Optional(CONF_TX_BUFFER_SIZE, default=512): cv.All(
cv.validate_bytes, cv.int_range(min=160, max=8192)
),
cv.Optional(CONF_RX_BUFFER_SIZE): cv.All(
cv.validate_bytes, cv.int_range(min=160, max=8192)
),
cv.Optional(CONF_DEBUG): maybe_empty_debug,
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_with_framework("zephyr"),
validate_rx_buffer,
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
zephyr_add_prj_conf("BT_NUS", True)
expose_log = config[CONF_TYPE] == CONF_LOGS
@@ -31,3 +66,11 @@ async def to_code(config):
if expose_log:
request_log_listener() # Request a log listener slot for BLE NUS log streaming
await cg.register_component(var, config)
cg.add_define("ESPHOME_BLE_NUS_TX_RING_BUFFER_SIZE", config[CONF_TX_BUFFER_SIZE])
if CONF_RX_BUFFER_SIZE in config:
cg.add_define(
"ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE", config[CONF_RX_BUFFER_SIZE]
)
if CONF_DEBUG in config:
cg.add_global(uart_ns.using)
await debug_to_code(config[CONF_DEBUG], var)
+110 -15
View File
@@ -11,25 +11,111 @@
namespace esphome::ble_nus {
constexpr size_t BLE_TX_BUF_SIZE = 2048;
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
BLENUS *global_ble_nus;
RING_BUF_DECLARE(global_ble_tx_ring_buf, BLE_TX_BUF_SIZE);
RING_BUF_DECLARE(global_ble_tx_ring_buf, ESPHOME_BLE_NUS_TX_RING_BUFFER_SIZE);
#ifdef ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE
RING_BUF_DECLARE(global_ble_rx_ring_buf, ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE);
#endif
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
static const char *const TAG = "ble_nus";
size_t BLENUS::write_array(const uint8_t *data, size_t len) {
void BLENUS::write_array(const uint8_t *data, size_t len) {
if (atomic_get(&this->tx_status_) == TX_DISABLED) {
return 0;
return;
}
auto sent = ring_buf_put(&global_ble_tx_ring_buf, data, len);
if (sent < len) {
ESP_LOGE(TAG, "TX dropping %u bytes", len - sent);
return;
}
#ifdef USE_UART_DEBUGGER
for (size_t i = 0; i < len; i++) {
this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]);
}
#endif
}
bool BLENUS::peek_byte(uint8_t *data) {
#ifdef ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE
if (this->has_peek_) {
*data = this->peek_buffer_;
return true;
}
if (this->read_byte(&this->peek_buffer_)) {
*data = this->peek_buffer_;
this->has_peek_ = true;
return true;
}
return false;
#else
return false;
#endif
}
bool BLENUS::read_array(uint8_t *data, size_t len) {
#ifdef ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE
if (len == 0) {
return true;
}
if (this->available() < len) {
return false;
}
// First, use the peek buffer if available
if (this->has_peek_) {
data[0] = this->peek_buffer_;
this->has_peek_ = false;
data++;
if (--len == 0) { // Decrement len first, then check it...
return true; // No more to read
}
}
if (ring_buf_get(&global_ble_rx_ring_buf, data, len) != len) {
ESP_LOGE(TAG, "UART BLE unexpected size");
return false;
}
#ifdef USE_UART_DEBUGGER
for (size_t i = 0; i < len; i++) {
this->debug_callback_.call(uart::UART_DIRECTION_RX, data[i]);
}
#endif
return true;
#else
return false;
#endif
}
size_t BLENUS::available() {
#ifdef ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE
uint32_t size = ring_buf_size_get(&global_ble_rx_ring_buf);
ESP_LOGVV(TAG, "UART BLE available %u", size);
return size + (this->has_peek_ ? 1 : 0);
#else
return 0;
#endif
}
void BLENUS::flush() {
constexpr uint32_t timeout_5sec = 5000;
uint32_t start = millis();
while (atomic_get(&this->tx_status_) != TX_DISABLED && !ring_buf_is_empty(&global_ble_tx_ring_buf)) {
if (millis() - start > timeout_5sec) {
ESP_LOGW(TAG, "Flush timeout");
return;
}
delay(1);
}
return ring_buf_put(&global_ble_tx_ring_buf, data, len);
}
void BLENUS::connected(bt_conn *conn, uint8_t err) {
if (err == 0) {
global_ble_nus->conn_.store(bt_conn_ref(conn));
global_ble_nus->connected_ = true;
}
}
@@ -38,6 +124,7 @@ void BLENUS::disconnected(bt_conn *conn, uint8_t reason) {
bt_conn_unref(global_ble_nus->conn_.load());
// Connection array is global static.
// Reference can be kept even if disconnected.
global_ble_nus->connected_ = false;
}
}
@@ -63,12 +150,19 @@ void BLENUS::send_enabled_callback(bt_nus_send_status status) {
break;
}
}
void BLENUS::rx_callback(bt_conn *conn, const uint8_t *const data, uint16_t len) {
ESP_LOGD(TAG, "Received %d bytes.", len);
ESP_LOGV(TAG, "Received %d bytes.", len);
#ifdef ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE
auto recv_len = ring_buf_put(&global_ble_rx_ring_buf, data, len);
if (recv_len < len) {
ESP_LOGE(TAG, "RX dropping %u bytes", len - recv_len);
}
#endif
}
void BLENUS::setup() {
#ifdef ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE
this->rx_buffer_size_ = ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE;
#endif
bt_nus_cb callbacks = {
.received = rx_callback,
.sent = tx_callback,
@@ -106,16 +200,17 @@ void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t
#endif
void BLENUS::dump_config() {
ESP_LOGCONFIG(TAG,
"ble nus:\n"
" log: %s",
YESNO(this->expose_log_));
uint32_t mtu = 0;
bt_conn *conn = this->conn_.load();
if (conn) {
if (conn && this->connected_) {
mtu = bt_nus_get_mtu(conn);
}
ESP_LOGCONFIG(TAG, " MTU: %u", mtu);
ESP_LOGCONFIG(TAG,
"ble nus:\n"
" log: %s\n"
" connected: %s\n"
" MTU: %u",
YESNO(this->expose_log_), YESNO(this->connected_.load()), mtu);
}
void BLENUS::loop() {
+14 -2
View File
@@ -2,6 +2,7 @@
#ifdef USE_ZEPHYR
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#include "esphome/components/uart/uart_component.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -10,7 +11,7 @@
namespace esphome::ble_nus {
class BLENUS : public Component {
class BLENUS : public uart::UARTComponent, public Component {
enum TxStatus {
TX_DISABLED,
TX_ENABLED,
@@ -21,7 +22,12 @@ class BLENUS : public Component {
void setup() override;
void dump_config() override;
void loop() override;
size_t write_array(const uint8_t *data, size_t len);
void write_array(const uint8_t *data, size_t len) override;
bool peek_byte(uint8_t *data) override;
bool read_array(uint8_t *data, size_t len) override;
size_t available() override;
void flush() override;
void check_logger_conflict() override {}
void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; }
#ifdef USE_LOGGER
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len);
@@ -37,6 +43,12 @@ class BLENUS : public Component {
std::atomic<bt_conn *> conn_ = nullptr;
bool expose_log_ = false;
atomic_t tx_status_ = ATOMIC_INIT(TX_DISABLED);
std::atomic<bool> connected_{};
#ifdef ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE
// RX buffer for peek functionality
uint8_t peek_buffer_{0};
bool has_peek_{false};
#endif
};
} // namespace esphome::ble_nus
@@ -415,11 +415,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTReadResponse resp;
resp.address = this->address_;
resp.handle = param->read.handle;
resp.set_data(param->read.value, param->read.value_len);
this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTReadResponse::MESSAGE_TYPE);
api_connection->send_message(resp, api::BluetoothGATTReadResponse::MESSAGE_TYPE);
break;
}
case ESP_GATTC_WRITE_CHAR_EVT:
@@ -429,10 +432,13 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTWriteResponse resp;
resp.address = this->address_;
resp.handle = param->write.handle;
this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTWriteResponse::MESSAGE_TYPE);
api_connection->send_message(resp, api::BluetoothGATTWriteResponse::MESSAGE_TYPE);
break;
}
case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: {
@@ -442,10 +448,13 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = param->unreg_for_notify.handle;
this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE);
api_connection->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE);
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
@@ -455,20 +464,26 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status);
break;
}
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyResponse resp;
resp.address = this->address_;
resp.handle = param->reg_for_notify.handle;
this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE);
api_connection->send_message(resp, api::BluetoothGATTNotifyResponse::MESSAGE_TYPE);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_,
param->notify.handle);
auto *api_connection = this->proxy_->get_api_connection();
if (api_connection == nullptr)
break;
api::BluetoothGATTNotifyDataResponse resp;
resp.address = this->address_;
resp.handle = param->notify.handle;
resp.set_data(param->notify.value, param->notify.value_len);
this->proxy_->get_api_connection()->send_message(resp, api::BluetoothGATTNotifyDataResponse::MESSAGE_TYPE);
api_connection->send_message(resp, api::BluetoothGATTNotifyDataResponse::MESSAGE_TYPE);
break;
}
default:
@@ -420,6 +420,8 @@ void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_
}
void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDevicePairingResponse call;
call.address = address;
call.paired = paired;
@@ -429,6 +431,8 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_
}
void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) {
if (this->api_connection_ == nullptr)
return;
api::BluetoothDeviceUnpairingResponse call;
call.address = address;
call.success = success;
@@ -54,8 +54,10 @@ bool E131AddressableLightEffect::process_(int universe, const E131Packet &packet
int32_t output_offset = (universe - first_universe_) * get_lights_per_universe();
// limit amount of lights per universe and received
// packet.count is the number of DMX bytes including start code; divide by channels to get the number of lights
int lights_in_packet = (packet.count > 0) ? (packet.count - 1) / channels_ : 0;
int output_end =
std::min(it->size(), std::min(output_offset + get_lights_per_universe(), output_offset + packet.count - 1));
std::min(it->size(), std::min(output_offset + get_lights_per_universe(), output_offset + lights_in_packet));
auto *input_data = packet.values + 1;
auto effect_name = get_name();
+2
View File
@@ -464,6 +464,8 @@ def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This featur
unsupported = [unsupported]
def validator_(obj):
if not CORE.is_esp32:
raise cv.Invalid(f"{msg_prefix} is only available on ESP32")
variant = get_esp32_variant()
if supported is not None and variant not in supported:
raise cv.Invalid(
+1 -1
View File
@@ -33,7 +33,7 @@ def esp32_p4_validate_supports(value):
is_input = mode[CONF_INPUT]
if num < 0 or num > 54:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)")
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-54)")
if is_input:
# All ESP32 pins support input mode
pass
+2 -2
View File
@@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__)
def esp32_s3_validate_gpio_pin(value):
if value < 0 or value > 48:
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-46)")
raise cv.Invalid(f"Invalid pin number: {value} (must be 0-48)")
if value in _ESP_32S3_SPI_PSRAM_PINS:
raise cv.Invalid(
@@ -55,7 +55,7 @@ def esp32_s3_validate_supports(value):
is_input = mode[CONF_INPUT]
if num < 0 or num > 48:
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-46)")
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-48)")
if is_input:
# All ESP32 pins support input mode
pass
+1 -1
View File
@@ -273,7 +273,7 @@ bool ESP32BLE::ble_setup_() {
device_name = this->name_;
}
} else {
const std::string &app_name = App.get_name();
const auto &app_name = App.get_name();
size_t name_len = app_name.length();
if (name_len > 20) {
if (App.is_name_add_mac_suffix_enabled()) {
@@ -470,6 +470,7 @@ network::IPAddresses EthernetComponent::get_ip_addresses() {
uint8_t count = 0;
count = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s);
assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES);
assert(count < addresses.size());
for (int i = 0; i < count; i++) {
addresses[i + 1] = network::IPAddress(&if_ip6s[i]);
}
@@ -115,6 +115,7 @@ class EthernetComponent : public Component {
const char *get_eth_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
eth_duplex_t get_duplex_mode();
eth_speed_t get_link_speed();
esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; }
bool powerdown();
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
+10 -12
View File
@@ -133,24 +133,22 @@ void HlkFm22xComponent::recv_command_() {
checksum ^= byte;
length |= byte;
if (length > HLK_FM22X_MAX_RESPONSE_SIZE) {
ESP_LOGE(TAG, "Response too large: %u bytes", length);
// Discard exactly the remaining payload and checksum for this frame
for (uint16_t i = 0; i < length + 1 && this->available() > 0; ++i)
this->read();
return;
}
// Read up to buffer size; discard excess bytes while still computing checksum
// GET_ALL_FACE_IDS can return all enrolled face data (hundreds of bytes)
// but handlers only need the first few bytes
size_t to_store = std::min(static_cast<size_t>(length), HLK_FM22X_MAX_RESPONSE_SIZE);
for (uint16_t idx = 0; idx < length; ++idx) {
byte = this->read();
checksum ^= byte;
this->recv_buf_[idx] = byte;
if (idx < to_store) {
this->recv_buf_[idx] = byte;
}
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(HLK_FM22X_MAX_RESPONSE_SIZE)];
ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type,
format_hex_pretty_to(hex_buf, this->recv_buf_.data(), length));
format_hex_pretty_to(hex_buf, this->recv_buf_.data(), to_store));
#endif
byte = this->read();
@@ -160,10 +158,10 @@ void HlkFm22xComponent::recv_command_() {
}
switch (response_type) {
case HlkFm22xResponseType::NOTE:
this->handle_note_(this->recv_buf_.data(), length);
this->handle_note_(this->recv_buf_.data(), to_store);
break;
case HlkFm22xResponseType::REPLY:
this->handle_reply_(this->recv_buf_.data(), length);
this->handle_reply_(this->recv_buf_.data(), to_store);
break;
default:
ESP_LOGW(TAG, "Unexpected response type: 0x%.2X", response_type);
+12 -3
View File
@@ -4,6 +4,7 @@
#include <fstream>
#include "preferences.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome {
namespace host {
@@ -14,7 +15,12 @@ static const char *const TAG = "host.preferences";
void HostPreferences::setup_() {
if (this->setup_complete_)
return;
this->filename_.append(getenv("HOME"));
const char *home = getenv("HOME");
if (home == nullptr) {
ESP_LOGE(TAG, "HOME environment variable is not set");
abort();
}
this->filename_.append(home);
this->filename_.append("/.esphome");
this->filename_.append("/prefs");
fs::create_directories(this->filename_);
@@ -44,9 +50,12 @@ void HostPreferences::setup_() {
bool HostPreferences::sync() {
this->setup_();
FILE *fp = fopen(this->filename_.c_str(), "wb");
std::map<uint32_t, std::vector<uint8_t>>::iterator it;
if (fp == nullptr) {
ESP_LOGE(TAG, "Failed to open preferences file for writing: %s", this->filename_.c_str());
return false;
}
for (it = this->data.begin(); it != this->data.end(); ++it) {
for (auto it = this->data.begin(); it != this->data.end(); ++it) {
fwrite(&it->first, sizeof(uint32_t), 1, fp);
uint8_t len = it->second.size();
fwrite(&len, sizeof(len), 1, fp);
+2
View File
@@ -111,6 +111,8 @@ void MAX6956::write_brightness_mode() {
}
void MAX6956::set_pin_brightness(uint8_t pin, float brightness) {
if (pin < MAX6956_MIN || pin > MAX6956_MAX)
return;
uint8_t reg_addr = MAX6956_CURRENT_START + (pin - MAX6956_MIN) / 2;
uint8_t config = 0;
uint8_t shift = 4 * (pin % 2);
+1 -1
View File
@@ -59,7 +59,7 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
service.proto = MDNS_STR(SERVICE_TCP);
service.port = api::global_api_server->get_port();
const std::string &friendly_name = App.get_friendly_name();
const auto &friendly_name = App.get_friendly_name();
bool friendly_name_empty = friendly_name.empty();
// Calculate exact capacity for txt_records
+2 -1
View File
@@ -10,7 +10,7 @@ namespace mipi_dsi {
static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64;
static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx) {
auto *sem = static_cast<SemaphoreHandle_t *>(user_ctx);
auto sem = static_cast<SemaphoreHandle_t>(user_ctx);
BaseType_t need_yield = pdFALSE;
xSemaphoreGiveFromISR(sem, &need_yield);
return (need_yield == pdTRUE);
@@ -190,6 +190,7 @@ void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint
if (bitness != this->color_depth_) {
display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset,
x_pad);
return;
}
this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad);
}
+7 -9
View File
@@ -209,12 +209,11 @@ bool MQTTComponent::send_discovery_() {
if (this->is_disabled_by_default_())
root[MQTT_ENABLED_BY_DEFAULT] = false;
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
const auto icon_ref = this->get_icon_ref_();
if (!icon_ref.empty()) {
root[MQTT_ICON] = icon_ref;
char icon_buf[MAX_ICON_LENGTH];
const char *icon = this->get_icon_to_(icon_buf);
if (icon[0] != '\0') {
root[MQTT_ICON] = icon;
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
const auto entity_category = this->get_entity()->get_entity_category();
if (entity_category != ENTITY_CATEGORY_NONE) {
@@ -268,7 +267,7 @@ bool MQTTComponent::send_discovery_() {
root[MQTT_UNIQUE_ID] = unique_id_buf;
}
const std::string &node_name = App.get_name();
const auto &node_name = App.get_name();
if (discovery_info.object_id_generator == MQTT_DEVICE_NAME_OBJECT_ID_GENERATOR) {
// node_name (max 31) + "_" (1) + object_id (max 128) + null
char object_id_full[ESPHOME_DEVICE_NAME_MAX_LEN + 1 + OBJECT_ID_MAX_LEN + 1];
@@ -276,8 +275,8 @@ bool MQTTComponent::send_discovery_() {
root[MQTT_OBJECT_ID] = object_id_full;
}
const std::string &friendly_name_ref = App.get_friendly_name();
const std::string &node_friendly_name = friendly_name_ref.empty() ? node_name : friendly_name_ref;
const auto &friendly_name_ref = App.get_friendly_name();
const auto &node_friendly_name = friendly_name_ref.empty() ? node_name : friendly_name_ref;
const char *node_area = App.get_area();
JsonObject device_info = root[MQTT_DEVICE].to<JsonObject>();
@@ -413,7 +412,6 @@ const StringRef &MQTTComponent::friendly_name_() const { return this->get_entity
StringRef MQTTComponent::get_default_object_id_to_(std::span<char, OBJECT_ID_MAX_LEN> buf) const {
return this->get_entity()->get_object_id_to(buf);
}
StringRef MQTTComponent::get_icon_ref_() const { return this->get_entity()->get_icon_ref(); }
bool MQTTComponent::is_disabled_by_default_() const { return this->get_entity()->is_disabled_by_default(); }
bool MQTTComponent::compute_is_internal_() {
if (this->custom_state_topic_.has_value()) {
+2 -2
View File
@@ -298,8 +298,8 @@ class MQTTComponent : public Component {
/// Get the friendly name of this MQTT component.
const StringRef &friendly_name_() const;
/// Get the icon field of this component as StringRef
StringRef get_icon_ref_() const;
/// Get the icon field of this component into a stack buffer
const char *get_icon_to_(std::span<char, MAX_ICON_LENGTH> buf) const { return this->get_entity()->get_icon_to(buf); }
/// Get whether the underlying Entity is disabled by default
bool is_disabled_by_default_() const;
+1 -1
View File
@@ -132,7 +132,7 @@ void OpenThreadSrpComponent::setup() {
// set the host name
uint16_t size;
char *existing_host_name = otSrpClientBuffersGetHostNameString(instance, &size);
const std::string &host_name = App.get_name();
const auto &host_name = App.get_name();
uint16_t host_name_len = host_name.size();
if (host_name_len > size) {
ESP_LOGW(TAG, "Hostname is too long, choose a shorter project name");
+1 -1
View File
@@ -90,7 +90,7 @@ class InstanceLock {
otInstance *get_instance();
private:
// Use a private constructor in order to force thehandling
// Use a private constructor in order to force the handling
// of acquisition failure
InstanceLock() {}
};
@@ -197,6 +197,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() {
esp_netif_t *netif = esp_netif_get_default_netif();
count = esp_netif_get_all_ip6(netif, if_ip6s);
assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES);
assert(count < addresses.size());
for (int i = 0; i < count; i++) {
addresses[i + 1] = network::IPAddress(&if_ip6s[i]);
}
+25
View File
@@ -0,0 +1,25 @@
# Auto-generated by generate_boards.py — do not edit manually
# To regenerate: python esphome/components/rp2040/generate_boards.py <arduino-pico-path>
# arduino-pico maps pins >= {{ cyw43_gpio_offset }} to CYW43 wireless chip GPIOs
CYW43_GPIO_OFFSET = {{ cyw43_gpio_offset }}
CYW43_MAX_GPIO = {{ cyw43_max_gpio }}
DEFAULT_MAX_PIN = {{ default_max_pin }}
RP2040_BASE_PINS = {}
RP2040_BOARD_PINS = {
{%- for name, pins in board_pins %}
{{ name | repr }}: {{ pins | format_pins }},
{%- endfor %}
}
BOARDS = {
{%- for name, info in boards %}
{{ name | repr }}: {
{%- for key, value in info.items() %}
{{ key | repr }}: {{ value | repr }},
{%- endfor %}
},
{%- endfor %}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
"""Generate boards.py from arduino-pico board definitions.
Usage: python esphome/components/rp2040/generate_boards.py <arduino-pico-path>
"""
import json
from pathlib import Path
import re
import sys
from jinja2 import Environment, FileSystemLoader
# Map arduino-pico pin defines to ESPHome-friendly names
PIN_NAME_MAP = {
"LED": "LED",
"WIRE0_SDA": "SDA",
"WIRE0_SCL": "SCL",
"WIRE1_SDA": "SDA1",
"WIRE1_SCL": "SCL1",
"SPI0_MISO": "MISO",
"SPI0_MOSI": "MOSI",
"SPI0_SCK": "SCK",
"SPI0_SS": "SS",
"SERIAL1_TX": "TX",
"SERIAL1_RX": "RX",
}
# arduino-pico maps pins >= 64 to CYW43 wireless chip GPIOs (pin - 64)
CYW43_GPIO_OFFSET = 64
# CYW43 has 3 GPIOs: 0=LED, 1=VBUS_SENSE, 2=REG_ON
CYW43_GPIO_COUNT = 3
# Max GPIO pin per MCU (hardware specs from datasheets)
MCU_MAX_PIN = {
"rp2040": 29, # GPIO 0-29
"rp2350": 47, # GPIO 0-47 (RP2350A)
}
DEFAULT_MAX_PIN = 29
PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)")
def parse_variant_pins(variant_dir: Path) -> dict[str, int]:
"""Parse pins_arduino.h and return mapped pin names."""
header = variant_dir / "pins_arduino.h"
if not header.exists():
return {}
pins = {}
for match in PIN_DEFINE_RE.finditer(header.read_text(encoding="utf-8")):
raw_name = match.group(1)
value = int(match.group(2))
if raw_name in PIN_NAME_MAP:
pins[PIN_NAME_MAP[raw_name]] = value
return pins
def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]:
"""Load all board definitions and return (board_pins, boards) dicts."""
json_dir = arduino_pico_path / "tools" / "json"
variants_dir = arduino_pico_path / "variants"
board_pins = {}
boards = {}
variant_pins_cache: dict[str, dict[str, int]] = {}
for json_file in sorted(json_dir.glob("*.json")):
board_name = json_file.stem
with open(json_file, encoding="utf-8") as f:
data = json.load(f)
build = data.get("build", {})
mcu = build.get("mcu", "rp2040")
variant = build.get("variant", board_name)
name = data.get("name", board_name)
vendor = data.get("vendor", "")
display_name = f"{vendor} {name}".strip() if vendor else name
boards[board_name] = {
"name": display_name,
"mcu": mcu,
"max_pin": MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN),
}
# Get pins for this variant
if variant not in variant_pins_cache:
variant_dir = variants_dir / variant
variant_pins_cache[variant] = parse_variant_pins(variant_dir)
pins = variant_pins_cache[variant]
if pins:
max_pin = boards[board_name]["max_pin"]
cyw43_max = CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1
# Filter out placeholder values (e.g. 99 = "not connected")
filtered = {
name: value
for name, value in pins.items()
if value <= max_pin or CYW43_GPIO_OFFSET <= value <= cyw43_max
}
if filtered:
board_pins[board_name] = filtered
# Compute max_virtual_pin per board from pin maps
for board_name, pins in board_pins.items():
if isinstance(pins, str):
continue
virtual_pins = [v for v in pins.values() if v >= CYW43_GPIO_OFFSET]
if virtual_pins and board_name in boards:
boards[board_name]["max_virtual_pin"] = max(virtual_pins)
# Deduplicate: if board pins match its variant's pins, use string alias
for board_name in list(board_pins.keys()):
if board_name not in boards:
continue
build_variant = _get_variant(json_dir / f"{board_name}.json")
if (
build_variant
and build_variant != board_name
and build_variant in board_pins
and board_pins[board_name] == board_pins[build_variant]
):
board_pins[board_name] = build_variant
return board_pins, boards
def _get_variant(json_file: Path) -> str | None:
"""Get variant name from a board JSON file."""
if not json_file.exists():
return None
with open(json_file, encoding="utf-8") as f:
data = json.load(f)
return data.get("build", {}).get("variant")
_TEMPLATE_DIR = Path(__file__).parent
def _format_pins(pins: dict[str, int] | str) -> str:
"""Jinja2 filter to format a pin dict or alias as Python source."""
if isinstance(pins, str):
return repr(pins)
items = ", ".join(f"{k!r}: {v}" for k, v in sorted(pins.items()))
return f"{{{items}}}"
_jinja_env = Environment(
loader=FileSystemLoader(_TEMPLATE_DIR), keep_trailing_newline=True
)
_jinja_env.filters["format_pins"] = _format_pins
_jinja_env.filters["repr"] = repr
def generate(arduino_pico_path: Path) -> str:
"""Generate boards.py content."""
board_pins, boards = load_boards(arduino_pico_path)
template = _jinja_env.get_template("boards.jinja2")
return template.render(
cyw43_gpio_offset=CYW43_GPIO_OFFSET,
cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1,
default_max_pin=DEFAULT_MAX_PIN,
board_pins=sorted(board_pins.items()),
boards=sorted(boards.items()),
)
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <arduino-pico-path>", file=sys.stderr)
sys.exit(1)
arduino_pico_path = Path(sys.argv[1])
if not (arduino_pico_path / "tools" / "json").exists():
print(f"Error: {arduino_pico_path}/tools/json not found", file=sys.stderr)
sys.exit(1)
output = generate(arduino_pico_path)
output_file = Path(__file__).parent / "boards.py"
output_file.write_text(output, encoding="utf-8")
print(f"Generated {output_file}")
if __name__ == "__main__":
main()
+16 -6
View File
@@ -54,19 +54,29 @@ def _translate_pin(value):
return _lookup_pin(value)
def _board_max_virtual_pin(board):
"""Get the max CYW43 virtual pin for this board, or None if no virtual pins."""
return boards.BOARDS.get(board, {}).get("max_virtual_pin")
def validate_gpio_pin(value):
value = _translate_pin(value)
board = CORE.data[KEY_RP2040][KEY_BOARD]
if board == "rpipicow" and value == 32:
return value # Special case for Pico-w LED pin
if value < 0 or value > 29:
raise cv.Invalid(f"RP2040: Invalid pin number: {value}")
max_virtual = _board_max_virtual_pin(board)
if max_virtual is not None and boards.CYW43_GPIO_OFFSET <= value <= max_virtual:
return value
max_pin = boards.BOARDS.get(board, {}).get("max_pin", boards.DEFAULT_MAX_PIN)
if value < 0 or value > max_pin:
raise cv.Invalid(f"Invalid pin number: {value} (max {max_pin} for this board)")
return value
def validate_supports(value):
board = CORE.data[KEY_RP2040][KEY_BOARD]
if board != "rpipicow" or value[CONF_NUMBER] != 32:
if (
_board_max_virtual_pin(board) is None
or value[CONF_NUMBER] < boards.CYW43_GPIO_OFFSET
):
return value
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
@@ -75,7 +85,7 @@ def validate_supports(value):
is_pullup = mode[CONF_PULLUP]
is_pulldown = mode[CONF_PULLDOWN]
if not is_output or is_input or is_open_drain or is_pullup or is_pulldown:
raise cv.Invalid("Only output mode is supported for Pico-w LED pin")
raise cv.Invalid("Only output mode is supported for CYW43 virtual pins")
return value
+2 -1
View File
@@ -155,7 +155,8 @@ void SX126x::configure() {
}
// check silicon version to make sure hw is ok
this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, 16);
this->read_register_(REG_VERSION_STRING, (uint8_t *) this->version_, sizeof(this->version_));
this->version_[sizeof(this->version_) - 1] = '\0';
if (strncmp(this->version_, "SX126", 5) != 0 && strncmp(this->version_, "LLCC68", 6) != 0) {
this->mark_failed();
return;
+5
View File
@@ -260,6 +260,11 @@ SX127xError SX127x::transmit_packet(const std::vector<uint8_t> &packet) {
return SX127xError::INVALID_PARAMS;
}
if (this->dio0_pin_ == nullptr) {
ESP_LOGE(TAG, "DIO0 pin not configured, cannot wait for transmit completion");
return SX127xError::INVALID_PARAMS;
}
SX127xError ret = SX127xError::NONE;
if (this->modulation_ == MOD_LORA) {
this->set_mode_standby();
+4 -4
View File
@@ -183,10 +183,10 @@ class UARTComponent {
virtual void check_logger_conflict() = 0;
bool check_read_timeout_(size_t len = 1);
InternalGPIOPin *tx_pin_;
InternalGPIOPin *rx_pin_;
InternalGPIOPin *flow_control_pin_;
size_t rx_buffer_size_;
InternalGPIOPin *tx_pin_{};
InternalGPIOPin *rx_pin_{};
InternalGPIOPin *flow_control_pin_{};
size_t rx_buffer_size_{};
size_t rx_full_threshold_{1};
size_t rx_timeout_{0};
uint32_t baud_rate_{0};
+2 -1
View File
@@ -568,7 +568,8 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J
}
#endif
#ifdef USE_ENTITY_ICON
root[ESPHOME_F("icon")] = obj->get_icon_ref().c_str();
char icon_buf[MAX_ICON_LENGTH];
root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf);
#endif
root[ESPHOME_F("entity_category")] = obj->get_entity_category();
bool is_disabled = obj->is_disabled_by_default();
@@ -75,7 +75,7 @@ void WebServer::set_js_url(const char *js_url) { this->js_url_ = js_url; }
void WebServer::handle_index_request(AsyncWebServerRequest *request) {
AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("text/html"));
const std::string &title = App.get_name();
const auto &title = App.get_name();
stream->print(ESPHOME_F("<!DOCTYPE html><html lang=\"en\"><head><meta charset=UTF-8><meta "
"name=viewport content=\"width=device-width, initial-scale=1,user-scalable=no\"><title>"));
stream->print(title.c_str());
+1 -1
View File
@@ -913,7 +913,7 @@ void WiFiComponent::setup_ap_config_() {
static constexpr size_t AP_SSID_PREFIX_LEN = 25;
static constexpr size_t AP_SSID_SUFFIX_LEN = 7;
const std::string &app_name = App.get_name();
const auto &app_name = App.get_name();
const char *name_ptr = app_name.c_str();
size_t name_len = app_name.length();
@@ -6,6 +6,7 @@
#include <user_interface.h>
#include <cassert>
#include <utility>
#include <algorithm>
#ifdef USE_WIFI_WPA2_EAP
@@ -205,12 +206,13 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {
network::IPAddresses addresses;
uint8_t index = 0;
for (auto &addr : addrList) {
assert(index < addresses.size());
addresses[index++] = addr.ipFromNetifNum();
}
return addresses;
}
bool WiFiComponent::wifi_apply_hostname_() {
const std::string &hostname = App.get_name();
const auto &hostname = App.get_name();
bool ret = wifi_station_set_hostname(const_cast<char *>(hostname.c_str()));
if (!ret) {
ESP_LOGV(TAG, "Set hostname failed");
@@ -585,6 +585,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {
uint8_t count = 0;
count = esp_netif_get_all_ip6(s_sta_netif, if_ip6s);
assert(count <= CONFIG_LWIP_IPV6_NUM_ADDRESSES);
assert(count < addresses.size());
for (int i = 0; i < count; i++) {
addresses[i + 1] = network::IPAddress(&if_ip6s[i]);
}
@@ -3,6 +3,8 @@
#ifdef USE_WIFI
#ifdef USE_RP2040
#include <cassert>
#include "lwip/dns.h"
#include "lwip/err.h"
#include "lwip/netif.h"
@@ -285,6 +287,7 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {
if (ip == ap_ip) {
continue;
}
assert(index < addresses.size());
addresses[index++] = ip;
}
return addresses;
+12 -5
View File
@@ -400,14 +400,21 @@ def string_strict(value):
def icon(value):
"""Validate that a given config value is a valid icon."""
from esphome.core.config import ICON_MAX_LENGTH
value = string_strict(value)
if not value:
return value
if re.match("^[\\w\\-]+:[\\w\\-]+$", value):
return value
raise Invalid(
'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"'
)
if not re.match("^[\\w\\-]+:[\\w\\-]+$", value):
raise Invalid(
'Icons must match the format "[icon pack]:[icon]", e.g. "mdi:home-assistant"'
)
if len(value) > ICON_MAX_LENGTH:
raise Invalid(
f"Icon string is too long ({len(value)} chars, max {ICON_MAX_LENGTH}). "
"Icons are stored in PROGMEM with a 64-byte buffer limit."
)
return value
def sub_device_id(value: str | None) -> core.ID | None:
+32 -22
View File
@@ -138,26 +138,36 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu
class Application {
public:
void pre_setup(const std::string &name, const std::string &friendly_name, bool name_add_mac_suffix) {
#ifdef ESPHOME_NAME_ADD_MAC_SUFFIX
/// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC.
void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) {
arch_init();
this->name_add_mac_suffix_ = name_add_mac_suffix;
if (name_add_mac_suffix) {
// MAC address length: 12 hex chars + null terminator
constexpr size_t mac_address_len = 13;
// MAC address suffix length (last 6 characters of 12-char MAC address string)
constexpr size_t mac_address_suffix_len = 6;
char mac_addr[mac_address_len];
get_mac_address_into_buffer(mac_addr);
const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len;
this->name_ = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len);
if (!friendly_name.empty()) {
this->friendly_name_ = make_name_with_suffix(friendly_name, ' ', mac_suffix_ptr, mac_address_suffix_len);
}
} else {
this->name_ = name;
this->friendly_name_ = friendly_name;
this->name_add_mac_suffix_ = true;
// MAC address length: 12 hex chars + null terminator
constexpr size_t mac_address_len = 13;
// MAC address suffix length (last 6 characters of 12-char MAC address string)
constexpr size_t mac_address_suffix_len = 6;
char mac_addr[mac_address_len];
get_mac_address_into_buffer(mac_addr);
// Overwrite the placeholder suffix in the mutable static buffers with actual MAC
// name is always non-empty (validated by validate_hostname in Python config)
memcpy(name + name_len - mac_address_suffix_len, mac_addr + mac_address_suffix_len, mac_address_suffix_len);
if (friendly_name_len > 0) {
memcpy(friendly_name + friendly_name_len - mac_address_suffix_len, mac_addr + mac_address_suffix_len,
mac_address_suffix_len);
}
this->name_ = StringRef(name, name_len);
this->friendly_name_ = StringRef(friendly_name, friendly_name_len);
}
#else
/// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash.
void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) {
arch_init();
this->name_add_mac_suffix_ = false;
this->name_ = StringRef(name, name_len);
this->friendly_name_ = StringRef(friendly_name, friendly_name_len);
}
#endif
#ifdef USE_DEVICES
void register_device(Device *device) { this->devices_.push_back(device); }
@@ -274,10 +284,10 @@ class Application {
void loop();
/// Get the name of this Application set by pre_setup().
const std::string &get_name() const { return this->name_; }
const StringRef &get_name() const { return this->name_; }
/// Get the friendly name of this Application set by pre_setup().
const std::string &get_friendly_name() const { return this->friendly_name_; }
const StringRef &get_friendly_name() const { return this->friendly_name_; }
/// Get the area of this Application set by pre_setup().
const char *get_area() const {
@@ -627,9 +637,9 @@ class Application {
#endif
#endif
// std::string members (typically 24-32 bytes each)
std::string name_;
std::string friendly_name_;
// StringRef members (8 bytes each: pointer + size)
StringRef name_;
StringRef friendly_name_;
// 4-byte members
uint32_t last_loop_{0};
+59 -5
View File
@@ -50,6 +50,7 @@ from esphome.core import (
)
from esphome.helpers import (
copy_file_if_changed,
cpp_string_escape,
fnv1a_32bit_hash,
get_str_env,
walk_files,
@@ -58,6 +59,38 @@ from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# C++ variable names and separators for app name buffers (used with MAC suffix)
_APP_NAME_BUF_VAR = "esphome_app_name_buf"
_APP_NAME_MAC_SEP = "-"
_APP_FRIENDLY_NAME_BUF_VAR = "esphome_app_friendly_name_buf"
_APP_FRIENDLY_NAME_MAC_SEP = " "
# Placeholder suffix for MAC address (last 6 hex chars)
_MAC_SUFFIX_PLACEHOLDER = "XXXXXX"
def make_app_name_cpp(
value: str, var_name: str, sep: str, *, add_mac_suffix: bool
) -> tuple[str, str | None, int]:
"""Compute C++ expression and optional global declaration for an app name.
Returns (cpp_expr, global_decl_or_none, byte_length).
- cpp_expr: The C++ expression to pass to pre_setup (var name or string literal).
- global_decl: A static char[] declaration string, or None if not needed.
- byte_length: The UTF-8 byte length of the string value.
"""
if add_mac_suffix:
buf_value = "" if not value else f"{value}{sep}{_MAC_SUFFIX_PLACEHOLDER}"
escaped = cpp_string_escape(buf_value)
return (
var_name,
f"static char {var_name}[] = {escaped};",
len(buf_value.encode("utf-8")),
)
if not value:
return '""', None, 0
return cpp_string_escape(value), None, len(value.encode("utf-8"))
StartupTrigger = cg.esphome_ns.class_(
"StartupTrigger", cg.Component, automation.Trigger.template()
)
@@ -78,6 +111,8 @@ VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"}
def validate_hostname(config):
# Keep in sync with ESPHOME_DEVICE_NAME_MAX_LEN in esphome/core/entity_base.h
if not config[CONF_NAME]:
raise cv.Invalid("Hostname must not be empty", path=[CONF_NAME])
max_length = 31
if config[CONF_NAME_ADD_MAC_SUFFIX]:
max_length -= 7 # "-AABBCC" is appended when add mac suffix option is used
@@ -188,6 +223,10 @@ else:
# Keep in sync with ESPHOME_FRIENDLY_NAME_MAX_LEN in esphome/core/entity_base.h
FRIENDLY_NAME_MAX_LEN = 120
# Max icon string length (63 chars + null = 64-byte PROGMEM buffer)
# Keep in sync with MAX_ICON_LENGTH in esphome/core/entity_base.h
ICON_MAX_LENGTH = 63
AREA_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(Area),
@@ -551,13 +590,28 @@ async def to_code(config: ConfigType) -> None:
# Construct App via placement new — see application.cpp for storage details
cg.add_global(cg.RawStatement("#include <new>"))
cg.add(cg.RawExpression("new (&App) Application()"))
cg.add(
cg.App.pre_setup(
config[CONF_NAME],
config[CONF_FRIENDLY_NAME],
config[CONF_NAME_ADD_MAC_SUFFIX],
name = config[CONF_NAME]
friendly_name = config[CONF_FRIENDLY_NAME]
name_add_mac_suffix = config[CONF_NAME_ADD_MAC_SUFFIX]
def _emit_app_name(
value: str, var_name: str, sep: str
) -> tuple[cg.Expression, int]:
"""Emit codegen for an app name and return (expression, byte_length)."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
value, var_name, sep, add_mac_suffix=name_add_mac_suffix
)
if global_decl is not None:
cg.add_global(cg.RawStatement(global_decl))
return cg.RawExpression(cpp_expr), byte_len
name_expr, name_len = _emit_app_name(name, _APP_NAME_BUF_VAR, _APP_NAME_MAC_SEP)
friendly_expr, friendly_len = _emit_app_name(
friendly_name, _APP_FRIENDLY_NAME_BUF_VAR, _APP_FRIENDLY_NAME_MAC_SEP
)
if name_add_mac_suffix:
cg.add_define("ESPHOME_NAME_ADD_MAC_SUFFIX")
cg.add(cg.App.pre_setup(name_expr, name_len, friendly_expr, friendly_len))
# Define component count for static allocation
cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids))
+3
View File
@@ -13,6 +13,7 @@
#define ESPHOME_PROJECT_VERSION "v2"
#define ESPHOME_PROJECT_VERSION_30 "v2"
#define ESPHOME_VARIANT "ESP32"
#define ESPHOME_NAME_ADD_MAC_SUFFIX
#define ESPHOME_DEBUG_SCHEDULER
#define ESPHOME_DEBUG_API
@@ -356,6 +357,8 @@
#endif
#ifdef USE_NRF52
#define ESPHOME_BLE_NUS_TX_RING_BUFFER_SIZE 512
#define ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE 512
#define USE_ESPHOME_TASK_LOG_BUFFER
#define USE_LOGGER_EARLY_MESSAGE
#define USE_LOGGER_UART_SELECTION_USB_CDC
+37 -7
View File
@@ -1,6 +1,7 @@
#include "esphome/core/entity_base.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
#include "esphome/core/string_ref.h"
namespace esphome {
@@ -22,13 +23,13 @@ void EntityBase::set_name(const char *name, uint32_t object_id_hash) {
// Bug-for-bug compatibility with OLD behavior:
// - With MAC suffix: OLD code used App.get_friendly_name() directly (no fallback)
// - Without MAC suffix: OLD code used pre-computed object_id with fallback to device name
const std::string &friendly = App.get_friendly_name();
const auto &friendly = App.get_friendly_name();
if (App.is_name_add_mac_suffix_enabled()) {
// MAC suffix enabled - use friendly_name directly (even if empty) for compatibility
this->name_ = StringRef(friendly);
this->name_ = friendly;
} else {
// No MAC suffix - fallback to device name if friendly_name is empty
this->name_ = StringRef(!friendly.empty() ? friendly : App.get_name());
this->name_ = !friendly.empty() ? friendly : App.get_name();
}
}
this->flags_.has_own_name = false;
@@ -72,7 +73,27 @@ std::string EntityBase::get_unit_of_measurement() const {
return std::string(this->get_unit_of_measurement_ref().c_str());
}
// Entity icon (from index)
// Entity icon — buffer-based API for PROGMEM safety on ESP8266
const char *EntityBase::get_icon_to([[maybe_unused]] std::span<char, MAX_ICON_LENGTH> buffer) const {
#ifdef USE_ENTITY_ICON
const uint8_t idx = this->icon_idx_;
#else
const uint8_t idx = 0;
#endif
#ifdef USE_ESP8266
if (idx == 0)
return "";
const char *icon = entity_icon_lookup(idx);
ESPHOME_strncpy_P(buffer.data(), icon, buffer.size() - 1);
buffer[buffer.size() - 1] = '\0';
return buffer.data();
#else
return entity_icon_lookup(idx);
#endif
}
#ifndef USE_ESP8266
// Deprecated icon accessors — not available on ESP8266 (rodata is RAM)
StringRef EntityBase::get_icon_ref() const {
#ifdef USE_ENTITY_ICON
return StringRef(entity_icon_lookup(this->icon_idx_));
@@ -80,7 +101,14 @@ StringRef EntityBase::get_icon_ref() const {
return StringRef(entity_icon_lookup(0));
#endif
}
std::string EntityBase::get_icon() const { return std::string(this->get_icon_ref().c_str()); }
std::string EntityBase::get_icon() const {
#ifdef USE_ENTITY_ICON
return std::string(entity_icon_lookup(this->icon_idx_));
#else
return std::string(entity_icon_lookup(0));
#endif
}
#endif // !USE_ESP8266
// Entity Object ID - computed on-demand from name
std::string EntityBase::get_object_id() const {
@@ -154,8 +182,10 @@ ESPPreferenceObject EntityBase::make_entity_preference_(size_t size, uint32_t ve
#ifdef USE_ENTITY_ICON
void log_entity_icon(const char *tag, const char *prefix, const EntityBase &obj) {
if (!obj.get_icon_ref().empty()) {
ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, obj.get_icon_ref().c_str());
char icon_buf[MAX_ICON_LENGTH];
const char *icon = obj.get_icon_to(icon_buf);
if (icon[0] != '\0') {
ESP_LOGCONFIG(tag, "%s Icon: '%s'", prefix, icon);
}
}
#endif
+28 -5
View File
@@ -36,6 +36,10 @@ static constexpr size_t OBJECT_ID_MAX_LEN = 128;
// Maximum state length that Home Assistant will accept without raising ValueError
static constexpr size_t MAX_STATE_LEN = 255;
// Maximum icon string buffer size (63 chars + null terminator)
// Icons are stored in PROGMEM; on ESP8266 they must be copied to a stack buffer.
static constexpr size_t MAX_ICON_LENGTH = 64;
enum EntityCategory : uint8_t {
ENTITY_CATEGORY_NONE = 0,
ENTITY_CATEGORY_CONFIG = 1,
@@ -124,12 +128,31 @@ class EntityBase {
"2026.3.0")
std::string get_unit_of_measurement() const;
// Get/set this entity's icon
ESPDEPRECATED(
"Use get_icon_ref() instead for better performance (avoids string copy). Will be removed in ESPHome 2026.5.0",
"2025.11.0")
std::string get_icon() const;
// Get this entity's icon into a stack buffer.
// On ESP32: returns pointer to PROGMEM string directly (buffer unused).
// On ESP8266: copies from PROGMEM to buffer, returns buffer pointer.
const char *get_icon_to(std::span<char, MAX_ICON_LENGTH> buffer) const;
#ifdef USE_ESP8266
// On ESP8266, rodata is RAM. Icons are in PROGMEM and cannot be accessed
// directly as const char*. Use get_icon_to() with a stack buffer instead.
template<typename T = int> StringRef get_icon_ref() const {
static_assert(sizeof(T) == 0,
"get_icon_ref() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
return StringRef("");
}
template<typename T = int> std::string get_icon() const {
static_assert(sizeof(T) == 0,
"get_icon() unavailable on ESP8266 (rodata is RAM). Use get_icon_to() with a stack buffer.");
return "";
}
#else
// Deprecated: use get_icon_to() instead. Icons are in PROGMEM.
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
StringRef get_icon_ref() const;
ESPDEPRECATED("Use get_icon_to() instead. Will be removed in ESPHome 2026.9.0", "2026.3.0")
std::string get_icon() const;
#endif
#ifdef USE_DEVICES
// Get/set this entity's device id
+41 -6
View File
@@ -17,6 +17,7 @@ from esphome.const import (
CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.config import ICON_MAX_LENGTH
from esphome.cpp_generator import MockObj, RawStatement, add, get_variable
import esphome.final_validate as fv
from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case
@@ -78,6 +79,8 @@ def _generate_category_code(
table_var: str,
lookup_fn: str,
strings: dict[str, int],
*,
progmem_strings: bool = False,
) -> str:
"""Generate C++ code for one string category (PROGMEM pointer table + lookup).
@@ -85,14 +88,40 @@ def _generate_category_code(
in flash (via PROGMEM) and read with progmem_read_ptr(). String literals
themselves remain in RAM but benefit from linker string deduplication.
Index 0 means "not set" and returns empty string.
When progmem_strings=True, each string is declared as a separate PROGMEM
char array. This ensures the string data itself is in flash on ESP8266
(where .rodata is RAM). On other platforms PROGMEM is a no-op.
"""
if not strings:
return ""
sorted_strings = sorted(strings.items(), key=lambda x: x[1])
entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings)
count = len(sorted_strings)
if progmem_strings:
# Emit individual PROGMEM char arrays so string data lives in flash
lines: list[str] = []
var_names: list[str] = []
for i, (s, _) in enumerate(sorted_strings):
var_name = f"{table_var}_STR_{i}"
var_names.append(var_name)
lines.append(
f"static const char {var_name}[] PROGMEM = {cpp_string_escape(s)};"
)
entries = ", ".join(var_names)
# Empty string must also be PROGMEM — on ESP8266, callers use strncpy_P
empty_var = f"{table_var}_EMPTY"
lines.append(f'static const char {empty_var}[] PROGMEM = "";')
lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
lines.append(f"const char *{lookup_fn}(uint8_t index) {{")
lines.append(f" if (index == 0 || index > {count}) return {empty_var};")
lines.append(f" return progmem_read_ptr(&{table_var}[index - 1]);")
lines.append("}")
return "\n".join(lines) + "\n"
entries = ", ".join(cpp_string_escape(s) for s, _ in sorted_strings)
return (
f"static const char *const {table_var}[] PROGMEM = {{{entries}}};\n"
f"const char *{lookup_fn}(uint8_t index) {{\n"
@@ -103,9 +132,9 @@ def _generate_category_code(
_CATEGORY_CONFIGS = (
("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes"),
("ENTITY_UOM_TABLE", "entity_uom_lookup", "units"),
("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons"),
("ENTITY_DC_TABLE", "entity_device_class_lookup", "device_classes", False),
("ENTITY_UOM_TABLE", "entity_uom_lookup", "units", False),
("ENTITY_ICON_TABLE", "entity_icon_lookup", "icons", True),
)
@@ -117,8 +146,10 @@ async def _generate_tables_job() -> None:
"""
pool = _get_pool()
parts = ["namespace esphome {"]
for table_var, lookup_fn, attr in _CATEGORY_CONFIGS:
code = _generate_category_code(table_var, lookup_fn, getattr(pool, attr))
for table_var, lookup_fn, attr, progmem_strs in _CATEGORY_CONFIGS:
code = _generate_category_code(
table_var, lookup_fn, getattr(pool, attr), progmem_strings=progmem_strs
)
if code:
parts.append(code)
parts.append("} // namespace esphome")
@@ -160,6 +191,10 @@ def register_unit_of_measurement(value: str) -> int:
def register_icon(value: str) -> int:
"""Register an icon string and return its 1-based index."""
if value and len(value) > ICON_MAX_LENGTH:
raise ValueError(
f"Icon string too long ({len(value)} chars, max {ICON_MAX_LENGTH}): '{value}'"
)
return _register_string(value, _get_pool().icons, _MAX_ICONS, "icon")
+1 -1
View File
@@ -1,6 +1,6 @@
#pragma once
#if defined(USE_ESP32)
#if defined(USE_ESP32) || defined(USE_ZEPHYR)
#include <atomic>
#include <cstddef>
+4
View File
@@ -3,3 +3,7 @@ audio_file:
file:
type: local
path: $component_dir/test.wav
media_source:
- platform: audio_file
id: audio_file_source
@@ -0,0 +1,4 @@
ble_nus:
type: uart
tx_buffer_size: 160
rx_buffer_size: 160
+3 -1
View File
@@ -12,7 +12,9 @@
using namespace esphome;
void setup() {
App.pre_setup("livingroom", "LivingRoom", false);
static char name[] = "livingroom";
static char friendly_name[] = "LivingRoom";
App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1);
auto *log = new logger::Logger(115200); // NOLINT
log->pre_setup();
log->set_uart_selection(logger::UART_SELECTION_UART0);
@@ -0,0 +1,273 @@
"""Tests for rp2040 generate_boards.py."""
from __future__ import annotations
import json
from pathlib import Path
import textwrap
import pytest
from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins
PICO_PINS_HEADER = textwrap.dedent("""\
#pragma once
#define PIN_LED (25u)
#define PIN_SERIAL1_TX (0u)
#define PIN_SERIAL1_RX (1u)
#define PIN_WIRE0_SDA (4u)
#define PIN_WIRE0_SCL (5u)
#define PIN_WIRE1_SDA (26u)
#define PIN_WIRE1_SCL (27u)
#define PIN_SPI0_MISO (16u)
#define PIN_SPI0_MOSI (19u)
#define PIN_SPI0_SCK (18u)
#define PIN_SPI0_SS (17u)
#include "../generic/common.h"
""")
PICOW_PINS_HEADER = textwrap.dedent("""\
#pragma once
#include <cyw43_wrappers.h>
#define PIN_LED (64u)
#define PIN_WIRE0_SDA (4u)
#define PIN_WIRE0_SCL (5u)
#include "../generic/common.h"
""")
@pytest.fixture()
def arduino_pico(tmp_path: Path) -> Path:
"""Create a minimal arduino-pico directory structure."""
json_dir = tmp_path / "tools" / "json"
json_dir.mkdir(parents=True)
variants_dir = tmp_path / "variants"
variants_dir.mkdir()
generic_dir = variants_dir / "generic"
generic_dir.mkdir()
(generic_dir / "common.h").write_text("#pragma once\n")
return tmp_path
def _add_board(
arduino_pico: Path,
board_name: str,
mcu: str = "rp2040",
variant: str | None = None,
vendor: str = "",
name: str | None = None,
pins_header: str | None = None,
) -> None:
"""Add a board JSON and variant to the fake arduino-pico tree."""
if variant is None:
variant = board_name
if name is None:
name = board_name
json_dir = arduino_pico / "tools" / "json"
variants_dir = arduino_pico / "variants"
board_json = {
"build": {
"mcu": mcu,
"variant": variant,
},
"name": name,
"vendor": vendor,
}
(json_dir / f"{board_name}.json").write_text(json.dumps(board_json))
variant_dir = variants_dir / variant
variant_dir.mkdir(exist_ok=True)
if pins_header is not None:
(variant_dir / "pins_arduino.h").write_text(pins_header)
def test_parse_basic_pins(tmp_path: Path) -> None:
variant_dir = tmp_path / "rpipico"
variant_dir.mkdir()
(variant_dir / "pins_arduino.h").write_text(PICO_PINS_HEADER)
pins = parse_variant_pins(variant_dir)
assert pins["LED"] == 25
assert pins["SDA"] == 4
assert pins["SCL"] == 5
assert pins["SDA1"] == 26
assert pins["SCL1"] == 27
assert pins["MISO"] == 16
assert pins["MOSI"] == 19
assert pins["SCK"] == 18
assert pins["SS"] == 17
assert pins["TX"] == 0
assert pins["RX"] == 1
def test_parse_cyw43_led_pin(tmp_path: Path) -> None:
variant_dir = tmp_path / "rpipicow"
variant_dir.mkdir()
(variant_dir / "pins_arduino.h").write_text(PICOW_PINS_HEADER)
pins = parse_variant_pins(variant_dir)
assert pins["LED"] == 64
def test_parse_missing_header(tmp_path: Path) -> None:
variant_dir = tmp_path / "noheader"
variant_dir.mkdir()
assert parse_variant_pins(variant_dir) == {}
def test_parse_unmapped_defines_ignored(tmp_path: Path) -> None:
variant_dir = tmp_path / "custom"
variant_dir.mkdir()
(variant_dir / "pins_arduino.h").write_text(
"#define PIN_NEOPIXEL (16u)\n#define PIN_LED (25u)\n"
)
pins = parse_variant_pins(variant_dir)
assert "NEOPIXEL" not in pins
assert pins["LED"] == 25
def test_load_basic_board(arduino_pico: Path) -> None:
_add_board(
arduino_pico,
"rpipico",
vendor="Raspberry Pi",
name="Pico",
pins_header=PICO_PINS_HEADER,
)
board_pins, boards = load_boards(arduino_pico)
assert "rpipico" in boards
assert boards["rpipico"]["name"] == "Raspberry Pi Pico"
assert boards["rpipico"]["mcu"] == "rp2040"
assert boards["rpipico"]["max_pin"] == 29
assert "rpipico" in board_pins
assert board_pins["rpipico"]["LED"] == 25
assert board_pins["rpipico"]["SDA"] == 4
def test_load_rp2350_board(arduino_pico: Path) -> None:
_add_board(
arduino_pico,
"rpipico2",
mcu="rp2350",
vendor="Raspberry Pi",
name="Pico 2",
pins_header=PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["rpipico2"]["mcu"] == "rp2350"
assert boards["rpipico2"]["max_pin"] == 47
def test_cyw43_board_has_max_virtual_pin(arduino_pico: Path) -> None:
_add_board(
arduino_pico,
"rpipicow",
vendor="Raspberry Pi",
name="Pico W",
pins_header=PICOW_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert boards["rpipicow"]["max_virtual_pin"] == 64
def test_non_cyw43_board_has_no_max_virtual_pin(arduino_pico: Path) -> None:
_add_board(
arduino_pico,
"rpipico",
vendor="Raspberry Pi",
name="Pico",
pins_header=PICO_PINS_HEADER,
)
_, boards = load_boards(arduino_pico)
assert "max_virtual_pin" not in boards["rpipico"]
def test_board_without_variant_header(arduino_pico: Path) -> None:
_add_board(arduino_pico, "novariant", name="No Variant")
board_pins, boards = load_boards(arduino_pico)
assert "novariant" in boards
assert "novariant" not in board_pins
def test_shared_variant_deduplicates(arduino_pico: Path) -> None:
"""Two boards sharing the same variant should alias."""
_add_board(arduino_pico, "base_board", pins_header=PICO_PINS_HEADER)
_add_board(arduino_pico, "alias_board", variant="base_board")
board_pins, _ = load_boards(arduino_pico)
assert board_pins["base_board"] == parse_variant_pins(
arduino_pico / "variants" / "base_board"
)
assert board_pins["alias_board"] == "base_board"
def test_display_name_with_vendor(arduino_pico: Path) -> None:
_add_board(arduino_pico, "testboard", vendor="Acme", name="Widget")
_, boards = load_boards(arduino_pico)
assert boards["testboard"]["name"] == "Acme Widget"
def test_display_name_without_vendor(arduino_pico: Path) -> None:
_add_board(arduino_pico, "testboard", vendor="", name="Widget")
_, boards = load_boards(arduino_pico)
assert boards["testboard"]["name"] == "Widget"
def test_unknown_mcu_gets_default_max_pin(arduino_pico: Path) -> None:
_add_board(arduino_pico, "future", mcu="rp2450", pins_header=PICO_PINS_HEADER)
_, boards = load_boards(arduino_pico)
assert boards["future"]["max_pin"] == 29
def test_placeholder_pins_filtered_out(arduino_pico: Path) -> None:
"""Pins with placeholder values like 99 should be filtered out."""
header = textwrap.dedent("""\
#pragma once
#define PIN_LED (25u)
#define PIN_WIRE0_SDA (4u)
#define PIN_WIRE0_SCL (5u)
#define PIN_WIRE1_SDA (99u)
#define PIN_WIRE1_SCL (99u)
""")
_add_board(arduino_pico, "placeholder", pins_header=header)
board_pins, boards = load_boards(arduino_pico)
assert "SDA1" not in board_pins["placeholder"]
assert "SCL1" not in board_pins["placeholder"]
assert board_pins["placeholder"]["LED"] == 25
assert "max_virtual_pin" not in boards["placeholder"]
def test_placeholder_pins_not_treated_as_virtual(arduino_pico: Path) -> None:
"""Pin 99 should not cause max_virtual_pin to be set."""
header = textwrap.dedent("""\
#pragma once
#define PIN_LED (64u)
#define PIN_WIRE0_SDA (4u)
#define PIN_WIRE0_SCL (5u)
#define PIN_SPI0_MISO (99u)
""")
_add_board(arduino_pico, "badpin", pins_header=header)
board_pins, boards = load_boards(arduino_pico)
assert "MISO" not in board_pins["badpin"]
assert boards["badpin"]["max_virtual_pin"] == 64
+77
View File
@@ -23,6 +23,7 @@ from esphome.const import (
from esphome.core import CORE, config
from esphome.core.config import (
Area,
make_app_name_cpp,
preload_core_config,
valid_include,
valid_project_name,
@@ -969,3 +970,79 @@ def test_config_hash_different_for_different_configs() -> None:
hash2 = CORE.config_hash
assert hash1 != hash2
def test_make_app_name_cpp_no_mac_simple() -> None:
"""Test simple name without MAC suffix returns string literal."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
"my-device", "buf", "-", add_mac_suffix=False
)
assert cpp_expr == '"my-device"'
assert global_decl is None
assert byte_len == 9
def test_make_app_name_cpp_no_mac_empty() -> None:
"""Test empty name without MAC suffix."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
"", "buf", "-", add_mac_suffix=False
)
assert cpp_expr == '""'
assert global_decl is None
assert byte_len == 0
def test_make_app_name_cpp_mac_suffix() -> None:
"""Test name with MAC suffix emits static buffer."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
"my-device", "esphome_app_name_buf", "-", add_mac_suffix=True
)
assert cpp_expr == "esphome_app_name_buf"
assert global_decl is not None
assert "static char esphome_app_name_buf[]" in global_decl
assert "my-device-XXXXXX" in global_decl
assert byte_len == len("my-device-XXXXXX")
def test_make_app_name_cpp_mac_suffix_empty() -> None:
"""Test empty name with MAC suffix emits empty static buffer."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
"", "esphome_app_name_buf", "-", add_mac_suffix=True
)
assert cpp_expr == "esphome_app_name_buf"
assert global_decl is not None
assert "static char esphome_app_name_buf[]" in global_decl
assert byte_len == 0
def test_make_app_name_cpp_mac_suffix_space_sep() -> None:
"""Test friendly name uses space separator for MAC suffix."""
cpp_expr, global_decl, byte_len = make_app_name_cpp(
"My Device", "esphome_app_friendly_name_buf", " ", add_mac_suffix=True
)
assert cpp_expr == "esphome_app_friendly_name_buf"
assert global_decl is not None
assert "My Device XXXXXX" in global_decl
assert byte_len == len("My Device XXXXXX")
def test_make_app_name_cpp_non_ascii_utf8_length() -> None:
"""Test non-ASCII characters use UTF-8 byte length."""
_, global_decl, byte_len = make_app_name_cpp(
"café", "buf", "-", add_mac_suffix=False
)
assert byte_len == len("café".encode()) # 5 bytes, not 4 chars
assert global_decl is None
def test_make_app_name_cpp_non_ascii_mac_suffix_utf8_length() -> None:
"""Test non-ASCII with MAC suffix uses UTF-8 byte length."""
_, _, byte_len = make_app_name_cpp("café", "buf", "-", add_mac_suffix=True)
assert byte_len == len("café-XXXXXX".encode())
def test_make_app_name_cpp_special_chars_escaped() -> None:
"""Test special characters are properly escaped in C++ string."""
cpp_expr, _, _ = make_app_name_cpp('my "device"', "buf", "-", add_mac_suffix=False)
# cpp_string_escape uses octal escapes for quotes
assert '"' not in cpp_expr[1:-1] # no unescaped quotes inside the outer quotes
@@ -23,6 +23,7 @@ from esphome.core.entity_helpers import (
_setup_entity_impl,
entity_duplicate_validator,
get_base_entity_object_id,
register_icon,
setup_entity,
)
from esphome.cpp_generator import MockObj
@@ -909,6 +910,22 @@ def test_register_string_overflow() -> None:
_register_string("overflow", category, 3, "test")
def test_register_icon_max_length() -> None:
"""Test register_icon rejects icons exceeding 63 characters."""
# 63 chars should succeed
max_icon = "mdi:" + "a" * 59 # 63 total
idx = register_icon(max_icon)
assert idx > 0
# 64 chars should fail
too_long = "mdi:" + "a" * 60 # 64 total
with pytest.raises(ValueError, match="Icon string too long"):
register_icon(too_long)
# Empty string returns 0
assert register_icon("") == 0
@pytest.mark.asyncio
async def test_setup_entity_with_entity_category(
setup_test_environment: list[str],
@@ -148,6 +148,18 @@ def test_icon__invalid():
config_validation.icon("foo")
def test_icon__max_length():
"""Test that icons exceeding 63 characters are rejected."""
# Exactly 63 chars should pass
max_icon = "mdi:" + "a" * 59 # 63 chars total
assert config_validation.icon(max_icon) == max_icon
# 64 chars should fail
too_long = "mdi:" + "a" * 60 # 64 chars total
with pytest.raises(Invalid, match="Icon string is too long"):
config_validation.icon(too_long)
@pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True))
def test_boolean__valid_true(value):
assert config_validation.boolean(value) is True