Merge branch 'dev' into configure_entity

This commit is contained in:
J. Nick Koston
2026-03-06 07:33:40 -10:00
committed by GitHub
101 changed files with 4303 additions and 562 deletions
+1 -1
View File
@@ -1 +1 @@
b97e16a84153b2a4cfc51137cd6121db3c32374504b2bea55144413b3e573052
b6f8c16c1ddd222134bf4a71910b4c832e764e23caf49f9bce3280b079955fcf
+1 -1
View File
@@ -2,7 +2,7 @@
//
// Used by:
// - codeowner-review-request.yml
// - codeowner-approved-label.yml + codeowner-approved-label-update.yml
// - codeowner-approved-label-update.yml
// - auto-label-pr/detectors.js (detectCodeOwner)
/**
@@ -1,13 +1,15 @@
# Fallback for fork PRs: phase 1 (codeowner-approved-label.yml) handles
# non-fork PRs directly but can't write labels on fork PRs (read-only token).
# This workflow re-determines the action and applies it if needed.
# Adds/removes a 'code-owner-approved' label when a component-specific
# codeowner approves (or dismisses) a PR.
#
# Uses pull_request_target so that fork PRs do not require workflow approval.
# The label is reconciled on every PR update; for review events specifically,
# this means the label is applied on the next push after a codeowner review.
name: Codeowner Approved Label Update
name: Codeowner Approved Label
on:
workflow_run:
workflows: ["Codeowner Approved Label"]
types: [completed]
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
issues: write
@@ -15,51 +17,23 @@ permissions:
contents: read
jobs:
update-label:
codeowner-approved:
name: Run
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request_review'
if: ${{ github.repository == 'esphome/esphome' }}
runs-on: ubuntu-latest
steps:
- name: Get PR details
id: pr
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
REPO: ${{ github.repository }}
run: |
pr_data=$(gh pr list --repo "$REPO" --state open --search "$HEAD_SHA" \
--json number,baseRefName --jq '.[0] // empty')
if [ -z "$pr_data" ]; then
echo "No open PR found for SHA $HEAD_SHA, skipping"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
pr_number=$(echo "$pr_data" | jq -r '.number')
base_ref=$(echo "$pr_data" | jq -r '.baseRefName')
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "base_ref=$base_ref" >> "$GITHUB_OUTPUT"
echo "Found PR #$pr_number targeting $base_ref"
- name: Checkout base repository
if: steps.pr.outputs.skip != 'true'
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ steps.pr.outputs.base_ref }}
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/scripts/codeowners.js
CODEOWNERS
- name: Update label
if: steps.pr.outputs.skip != 'true'
- name: Check codeowner approval and update label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js');
@@ -76,6 +50,11 @@ jobs:
github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME
);
if (action === LabelAction.NONE) {
console.log('No label change needed');
return;
}
if (action === LabelAction.ADD) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
@@ -90,6 +69,4 @@ jobs:
} catch (error) {
if (error.status !== 404) throw error;
}
} else {
console.log('No label change needed');
}
@@ -1,78 +0,0 @@
# Adds/removes a 'code-owner-approved' label when a component-specific
# codeowner approves (or dismisses) a PR.
#
# Handles non-fork PRs directly. For fork PRs the GITHUB_TOKEN is read-only,
# so label writes are deferred to codeowner-approved-label-update.yml which
# triggers via workflow_run with write permissions.
name: Codeowner Approved Label
on:
pull_request_review:
types: [submitted, dismissed]
permissions:
issues: write
pull-requests: read
contents: read
jobs:
codeowner-approved:
name: Run
if: ${{ github.repository == 'esphome/esphome' }}
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
.github/scripts/codeowners.js
CODEOWNERS
- name: Check codeowner approval and update label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
script: |
const { loadCodeowners, determineLabelAction, LabelAction } = require('./.github/scripts/codeowners.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const pr_number = parseInt(process.env.PR_NUMBER, 10);
const LABEL_NAME = 'code-owner-approved';
console.log(`Processing PR #${pr_number} for codeowner approval label`);
const codeownersPatterns = loadCodeowners();
const action = await determineLabelAction(
github, owner, repo, pr_number, codeownersPatterns, LABEL_NAME
);
if (action === LabelAction.NONE) {
console.log('No label change needed');
return;
}
try {
if (action === LabelAction.ADD) {
await github.rest.issues.addLabels({
owner, repo, issue_number: pr_number, labels: [LABEL_NAME]
});
console.log(`Added '${LABEL_NAME}' label`);
} else if (action === LabelAction.REMOVE) {
await github.rest.issues.removeLabel({
owner, repo, issue_number: pr_number, name: LABEL_NAME
});
console.log(`Removed '${LABEL_NAME}' label`);
}
} catch (error) {
if (error.status === 403) {
console.log('Fork PR: deferring label write to phase 2 workflow');
} else if (error.status === 404) {
console.log('Label already removed');
} else {
throw error;
}
}
+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 -13
View File
@@ -173,19 +173,8 @@ float ADS1115Component::request_measurement(ADS1115Multiplexer multiplexer, ADS1
}
if (resolution == ADS1015_12_BITS) {
bool negative = (raw_conversion >> 15) == 1;
// shift raw_conversion as it's only 12-bits, left justified
raw_conversion = raw_conversion >> (16 - ADS1015_12_BITS);
// check if number was negative in order to keep the sign
if (negative) {
// the number was negative
// 1) set the negative bit back
raw_conversion |= 0x8000;
// 2) reset the former (shifted) negative bit
raw_conversion &= 0xF7FF;
}
// ADS1015 returns 12-bit value left-justified in 16 bits; shift right and sign-extend
raw_conversion = static_cast<uint16_t>(static_cast<int16_t>(raw_conversion) >> (16 - ADS1015_12_BITS));
}
auto signed_conversion = static_cast<int16_t>(raw_conversion);
+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);
+56
View File
@@ -1,5 +1,9 @@
#include "audio.h"
#include "esphome/core/helpers.h"
#include <cstring>
namespace esphome {
namespace audio {
@@ -58,6 +62,58 @@ const char *audio_file_type_to_string(AudioFileType file_type) {
}
}
AudioFileType detect_audio_file_type(const char *content_type, const char *url) {
// Try Content-Type header first
if (content_type != nullptr && content_type[0] != '\0') {
#ifdef USE_AUDIO_MP3_SUPPORT
if (strcasecmp(content_type, "mp3") == 0 || strcasecmp(content_type, "audio/mp3") == 0 ||
strcasecmp(content_type, "audio/mpeg") == 0) {
return AudioFileType::MP3;
}
#endif
if (strcasecmp(content_type, "audio/wav") == 0) {
return AudioFileType::WAV;
}
#ifdef USE_AUDIO_FLAC_SUPPORT
if (strcasecmp(content_type, "audio/flac") == 0 || strcasecmp(content_type, "audio/x-flac") == 0) {
return AudioFileType::FLAC;
}
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
// Match "audio/ogg" with a codecs parameter containing "opus"
// Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc.
// Plain "audio/ogg" without opus is not matched (almost always Ogg Vorbis)
if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) {
return AudioFileType::OPUS;
}
#endif
}
// Fallback to URL extension
if (url != nullptr && url[0] != '\0') {
if (str_endswith_ignore_case(url, ".wav")) {
return AudioFileType::WAV;
}
#ifdef USE_AUDIO_MP3_SUPPORT
if (str_endswith_ignore_case(url, ".mp3")) {
return AudioFileType::MP3;
}
#endif
#ifdef USE_AUDIO_FLAC_SUPPORT
if (str_endswith_ignore_case(url, ".flac")) {
return AudioFileType::FLAC;
}
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
if (str_endswith_ignore_case(url, ".opus")) {
return AudioFileType::OPUS;
}
#endif
}
return AudioFileType::NONE;
}
void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, int16_t scale_factor,
size_t samples_to_scale) {
// Note the assembly dsps_mulc function has audio glitches if the input and output buffers are the same.
+7
View File
@@ -130,6 +130,13 @@ struct AudioFile {
/// @return const char pointer to the readable file type
const char *audio_file_type_to_string(AudioFileType file_type);
/// @brief Detect audio file type from a Content-Type header value and/or URL extension.
/// Tries Content-Type first, then falls back to URL extension. Either parameter may be null.
/// @param content_type Content-Type header value (may be null or empty)
/// @param url URL to inspect for file extension (may be null or empty)
/// @return The detected AudioFileType, or NONE if unknown
AudioFileType detect_audio_file_type(const char *content_type, const char *url);
/// @brief Scales Q15 fixed point audio samples. Scales in place if audio_samples == output_buffer.
/// @param audio_samples PCM int16 audio samples
/// @param output_buffer Buffer to store the scaled samples
+3 -47
View File
@@ -185,26 +185,8 @@ esp_err_t AudioReader::start(const std::string &uri, AudioFileType &file_type) {
return err;
}
if (str_endswith_ignore_case(url, ".wav")) {
file_type = AudioFileType::WAV;
}
#ifdef USE_AUDIO_MP3_SUPPORT
else if (str_endswith_ignore_case(url, ".mp3")) {
file_type = AudioFileType::MP3;
}
#endif
#ifdef USE_AUDIO_FLAC_SUPPORT
else if (str_endswith_ignore_case(url, ".flac")) {
file_type = AudioFileType::FLAC;
}
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
else if (str_endswith_ignore_case(url, ".opus")) {
file_type = AudioFileType::OPUS;
}
#endif
else {
file_type = AudioFileType::NONE;
file_type = detect_audio_file_type(nullptr, url);
if (file_type == AudioFileType::NONE) {
this->cleanup_connection_();
return ESP_ERR_NOT_SUPPORTED;
}
@@ -232,32 +214,6 @@ AudioReaderState AudioReader::read() {
return AudioReaderState::FAILED;
}
AudioFileType AudioReader::get_audio_type(const char *content_type) {
#ifdef USE_AUDIO_MP3_SUPPORT
if (strcasecmp(content_type, "mp3") == 0 || strcasecmp(content_type, "audio/mp3") == 0 ||
strcasecmp(content_type, "audio/mpeg") == 0) {
return AudioFileType::MP3;
}
#endif
if (strcasecmp(content_type, "audio/wav") == 0) {
return AudioFileType::WAV;
}
#ifdef USE_AUDIO_FLAC_SUPPORT
if (strcasecmp(content_type, "audio/flac") == 0 || strcasecmp(content_type, "audio/x-flac") == 0) {
return AudioFileType::FLAC;
}
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
// Match "audio/ogg" with a codecs parameter containing "opus"
// Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc.
// Plain "audio/ogg" without a codecs parameter is not matched, as those are almost always Ogg Vorbis streams
if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) {
return AudioFileType::OPUS;
}
#endif
return AudioFileType::NONE;
}
esp_err_t AudioReader::http_event_handler(esp_http_client_event_t *evt) {
// Based on https://github.com/maroc81/WeatherLily/tree/main/main/net accessed 20241224
AudioReader *this_reader = (AudioReader *) evt->user_data;
@@ -265,7 +221,7 @@ esp_err_t AudioReader::http_event_handler(esp_http_client_event_t *evt) {
switch (evt->event_id) {
case HTTP_EVENT_ON_HEADER:
if (strcasecmp(evt->header_key, "Content-Type") == 0) {
this_reader->audio_file_type_ = get_audio_type(evt->header_value);
this_reader->audio_file_type_ = detect_audio_file_type(evt->header_value, nullptr);
}
break;
default:
-5
View File
@@ -58,11 +58,6 @@ class AudioReader {
/// @brief Monitors the http client events to attempt determining the file type from the Content-Type header
static esp_err_t http_event_handler(esp_http_client_event_t *evt);
/// @brief Determines the audio file type from the http header's Content-Type key
/// @param content_type string with the Content-Type key
/// @return AudioFileType of the url, if it can be determined. If not, return AudioFileType::NONE.
static AudioFileType get_audio_type(const char *content_type);
AudioReaderState file_read_();
AudioReaderState http_read_();
@@ -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;
+7 -3
View File
@@ -147,13 +147,17 @@ uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) {
}
uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) {
uint32_t coeff = 0;
switch (unit) {
case RMS_UC:
return 0x400000 * 100 / this->data_.coefficient[RMS_UC];
coeff = this->data_.coefficient[RMS_UC];
return coeff ? 0x400000 * 100 / coeff : 0;
case RMS_IAC:
return (0x800000 * 100 / this->data_.coefficient[RMS_IAC]) * 10; // Stay within 32 bits
coeff = this->data_.coefficient[RMS_IAC];
return coeff ? (0x800000 * 100 / coeff) * 10 : 0; // Stay within 32 bits
case POWER_PAC:
return 0x80000000 / this->data_.coefficient[POWER_PAC];
coeff = this->data_.coefficient[POWER_PAC];
return coeff ? 0x80000000 / coeff : 0;
}
return 0;
}
@@ -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
@@ -437,10 +437,15 @@ void FeedbackCover::recompute_position_() {
}
// check if we have an acceleration_wait_time, and remove from position computation
if (now > (this->start_dir_time_ + this->acceleration_wait_time_)) {
this->position +=
dir * (now - std::max(this->start_dir_time_ + this->acceleration_wait_time_, this->last_recompute_time_)) /
(action_dur - this->acceleration_wait_time_);
if (now - this->start_dir_time_ > this->acceleration_wait_time_) {
uint32_t accel_end_time = this->start_dir_time_ + this->acceleration_wait_time_;
uint32_t effective_start;
if (static_cast<int32_t>(accel_end_time - this->last_recompute_time_) >= 0) {
effective_start = accel_end_time;
} else {
effective_start = this->last_recompute_time_;
}
this->position += dir * (now - effective_start) / (action_dur - this->acceleration_wait_time_);
this->position = clamp(this->position, min_pos, max_pos);
}
this->last_recompute_time_ = now;
@@ -26,7 +26,7 @@ void GrowattSolar::update() {
}
// The bus might be slow, or there might be other devices, or other components might be talking to our device.
if (this->waiting_for_response()) {
if (!this->ready_for_immediate_send()) {
this->waiting_to_update_ = true;
return;
}
+10 -6
View File
@@ -24,12 +24,16 @@ void HC8Component::setup() {
}
void HC8Component::update() {
uint32_t now_ms = App.get_loop_component_start_time();
uint32_t warmup_ms = this->warmup_seconds_ * 1000;
if (now_ms < warmup_ms) {
ESP_LOGW(TAG, "HC8 warming up, %" PRIu32 " s left", (warmup_ms - now_ms) / 1000);
this->status_set_warning();
return;
if (!this->warmup_complete_) {
uint32_t now_ms = App.get_loop_component_start_time();
uint32_t warmup_ms = this->warmup_seconds_ * 1000;
if (now_ms < warmup_ms) {
ESP_LOGW(TAG, "HC8 warming up, %" PRIu32 " s left", (warmup_ms - now_ms) / 1000);
this->status_set_warning();
return;
}
this->warmup_complete_ = true;
this->status_clear_warning();
}
while (this->available())
+1
View File
@@ -23,6 +23,7 @@ class HC8Component : public PollingComponent, public uart::UARTDevice {
protected:
sensor::Sensor *co2_sensor_{nullptr};
uint32_t warmup_seconds_{0};
bool warmup_complete_{false};
};
template<typename... Ts> class HC8CalibrateAction : public Action<Ts...>, public Parented<HC8Component> {
+1 -1
View File
@@ -239,7 +239,7 @@ void HE60rCover::recompute_position_() {
return;
const uint32_t now = millis();
if (now > this->last_recompute_time_) {
if (now != this->last_recompute_time_) {
auto diff = (unsigned) (now - last_recompute_time_);
float delta;
switch (this->current_operation) {
+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);
@@ -61,7 +61,7 @@ void MatrixKeypad::loop() {
ESP_LOGD(TAG, "key @ row %d, col %d released", row, col);
for (auto &listener : this->listeners_)
listener->button_released(row, col);
if (!this->keys_.empty()) {
if (this->pressed_key_ < (int) this->keys_.size()) {
uint8_t keycode = this->keys_[this->pressed_key_];
ESP_LOGD(TAG, "key '%c' released", keycode);
for (auto &listener : this->listeners_)
@@ -84,7 +84,7 @@ void MatrixKeypad::loop() {
ESP_LOGD(TAG, "key @ row %d, col %d pressed", row, col);
for (auto &listener : this->listeners_)
listener->button_pressed(row, col);
if (!this->keys_.empty()) {
if (key < (int) this->keys_.size()) {
uint8_t keycode = this->keys_[key];
ESP_LOGD(TAG, "key '%c' pressed", keycode);
for (auto &trigger : this->key_triggers_)
+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);
}
+5
View File
@@ -20,6 +20,7 @@ MULTI_CONF = True
CONF_ROLE = "role"
CONF_MODBUS_ID = "modbus_id"
CONF_SEND_WAIT_TIME = "send_wait_time"
CONF_TURNAROUND_TIME = "turnaround_time"
ModbusRole = modbus_ns.enum("ModbusRole")
MODBUS_ROLES = {
@@ -36,6 +37,9 @@ CONFIG_SCHEMA = (
cv.Optional(
CONF_SEND_WAIT_TIME, default="250ms"
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TURNAROUND_TIME, default="100ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
}
)
@@ -57,6 +61,7 @@ async def to_code(config):
cg.add(var.set_flow_control_pin(pin))
cg.add(var.set_send_wait_time(config[CONF_SEND_WAIT_TIME]))
cg.add(var.set_turnaround_time(config[CONF_TURNAROUND_TIME]))
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
+199 -95
View File
@@ -15,10 +15,69 @@ void Modbus::setup() {
if (this->flow_control_pin_ != nullptr) {
this->flow_control_pin_->setup();
}
}
void Modbus::loop() {
const uint32_t now = App.get_loop_component_start_time();
this->frame_delay_ms_ =
std::max(2, // 1750us minimum per spec - rounded up to 2ms.
// 3.5 characters * 11 bits per character * 1000ms/sec / (bits/sec) (Standard modbus frame delay)
(uint16_t) (3.5 * 11 * 1000 / this->parent_->get_baud_rate()) + 1);
this->long_rx_buffer_delay_ms_ =
(this->parent_->get_rx_full_threshold() * 11 * 1000 / this->parent_->get_baud_rate()) + 1;
}
void Modbus::loop() {
// First process all available incoming data.
this->receive_and_parse_modbus_bytes_();
// If the response frame is finished (including interframe delay) - we timeout.
// The long_rx_buffer_delay accounts for long responses (larger than the UART rx_full_threshold) to avoid timeouts
// when the buffer is filling the back half of the response
const uint16_t timeout = std::max(
(uint16_t) this->frame_delay_ms_,
(uint16_t) (this->rx_buffer_.size() >= this->parent_->get_rx_full_threshold() ? this->long_rx_buffer_delay_ms_
: 0));
// We use millis() here and elsewhere instead of App.get_loop_component_start_time() to avoid stale timestamps
// It's critical in all timestamp comparisons that the left timestamp comes before the right one in time
// If we use a cached value in place of millis() and last_modbus_byte_ is updated inside our loop
// then the comparison is backwards (small negative which wraps to large positive) and will cause a false timeout
// So in this component we don't use any cached timestamp values to avoid these annoying bugs
if (millis() - this->last_modbus_byte_ > timeout) {
this->clear_rx_buffer_(LOG_STR("timeout after partial response"), true);
}
// If we're past the send_wait_time timeout and response buffer doesn't have the start of the expected response
if (this->waiting_for_response_ != 0 &&
millis() - this->last_send_ > this->last_send_tx_offset_ + this->send_wait_time_ &&
(this->rx_buffer_.empty() || this->rx_buffer_[0] != this->waiting_for_response_)) {
ESP_LOGW(TAG, "Stop waiting for response from %" PRIu8 " %" PRIu32 "ms after last send",
this->waiting_for_response_, millis() - this->last_send_);
this->waiting_for_response_ = 0;
}
// If there's no response pending and there's commands in the buffer
this->send_next_frame_();
}
bool Modbus::tx_blocked() {
const uint32_t now = millis();
// We block transmission in any of these case:
// 1. There are bytes in the UART Rx buffer
// 2. There are bytes in our Rx buffer
// 3. We're waiting for a response
// 4. The last sent byte isn't more than frame_delay ms ago (i.e. wait to tell receivers that our previous Tx is done)
// 5. The last received byte isn't more than frame_delay ms ago (i.e. wait to be sure there isn't more Rx coming)
// 6. If we're a client - also wait for the turnaround delay, to give the servers time to process the previous message
return this->available() || !this->rx_buffer_.empty() || (this->waiting_for_response_ != 0) ||
(now - this->last_send_ < this->last_send_tx_offset_ + this->frame_delay_ms_ +
(this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0)) ||
(now - this->last_modbus_byte_ <
this->frame_delay_ms_ + (this->role == ModbusRole::CLIENT ? this->turnaround_delay_ms_ : 0));
}
bool Modbus::tx_buffer_empty() { return this->tx_buffer_.empty(); }
void Modbus::receive_and_parse_modbus_bytes_() {
// Read all available bytes in batches to reduce UART call overhead.
size_t avail = this->available();
uint8_t buf[64];
@@ -28,33 +87,20 @@ void Modbus::loop() {
break;
}
avail -= to_read;
for (size_t i = 0; i < to_read; i++) {
if (this->parse_modbus_byte_(buf[i])) {
this->last_modbus_byte_ = now;
if (this->rx_buffer_.empty()) {
ESP_LOGV(TAG, "Received first byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i],
millis() - this->last_send_);
} else {
size_t at = this->rx_buffer_.size();
if (at > 0) {
ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse failed", at);
this->rx_buffer_.clear();
}
ESP_LOGVV(TAG, "Received byte %" PRIu8 " (0X%x) %" PRIu32 "ms after last send", buf[i], buf[i],
millis() - this->last_send_);
}
}
}
if (now - this->last_modbus_byte_ > 50) {
size_t at = this->rx_buffer_.size();
if (at > 0) {
ESP_LOGV(TAG, "Clearing buffer of %d bytes - timeout", at);
this->rx_buffer_.clear();
}
// stop blocking new send commands after sent_wait_time_ ms after response received
if (now - this->last_send_ > send_wait_time_) {
if (waiting_for_response > 0) {
ESP_LOGV(TAG, "Stop waiting for response from %d", waiting_for_response);
// If the bytes in the rx buffer do not parse, clear out the buffer
if (!this->parse_modbus_byte_(buf[i])) {
this->clear_rx_buffer_(LOG_STR("parse failed"), true);
}
waiting_for_response = 0;
this->last_modbus_byte_ = millis();
}
}
}
@@ -63,7 +109,7 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
size_t at = this->rx_buffer_.size();
this->rx_buffer_.push_back(byte);
const uint8_t *raw = &this->rx_buffer_[0];
ESP_LOGVV(TAG, "Modbus received Byte %d (0X%x)", byte, byte);
// Byte 0: modbus address (match all)
if (at == 0)
return true;
@@ -101,7 +147,7 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
if (computed_crc != remote_crc)
return true;
ESP_LOGD(TAG, "Modbus user-defined function %02X found", function_code);
ESP_LOGD(TAG, "User-defined function %02X found", function_code);
} else {
// data starts at 2 and length is 4 for read registers commands
@@ -152,9 +198,19 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
uint16_t remote_crc = uint16_t(raw[data_offset + data_len]) | (uint16_t(raw[data_offset + data_len + 1]) << 8);
if (computed_crc != remote_crc) {
if (this->disable_crc_) {
ESP_LOGD(TAG, "Modbus CRC Check failed, but ignored! %02X!=%02X", computed_crc, remote_crc);
ESP_LOGD(TAG, "CRC check failed %" PRIu32 "ms after last send; ignoring", millis() - this->last_send_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc,
format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size()));
} else {
ESP_LOGW(TAG, "Modbus CRC Check failed! %02X!=%02X", computed_crc, remote_crc);
ESP_LOGW(TAG, "CRC check failed %" PRIu32 "ms after last send", millis() - this->last_send_);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGVV(TAG, " (%02X != %02X) %s", computed_crc, remote_crc,
format_hex_pretty_to(hex_buf, this->rx_buffer_.data(), this->rx_buffer_.size()));
return false;
}
}
@@ -164,52 +220,101 @@ bool Modbus::parse_modbus_byte_(uint8_t byte) {
for (auto *device : this->devices_) {
if (device->address_ == address) {
found = true;
// Is it an error response?
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) {
ESP_LOGD(TAG, "Modbus error function code: 0x%X exception: %d", function_code, raw[2]);
if (waiting_for_response != 0) {
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, raw[2]);
} else {
// Ignore modbus exception not related to a pending command
ESP_LOGD(TAG, "Ignoring Modbus error - not expecting a response");
}
continue;
}
if (this->role == ModbusRole::SERVER) {
if (function_code == ModbusFunctionCode::READ_HOLDING_REGISTERS ||
function_code == ModbusFunctionCode::READ_INPUT_REGISTERS) {
device->on_modbus_read_registers(function_code, uint16_t(data[1]) | (uint16_t(data[0]) << 8),
uint16_t(data[3]) | (uint16_t(data[2]) << 8));
continue;
}
if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
} else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER ||
function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) {
device->on_modbus_write_registers(function_code, data);
continue;
}
} else { // We're a client
// Is it an error response?
if ((function_code & FUNCTION_CODE_EXCEPTION_MASK) == FUNCTION_CODE_EXCEPTION_MASK) {
uint8_t exception = raw[2];
ESP_LOGW(TAG,
"Error function code: 0x%X exception: %" PRIu8 ", address: %" PRIu8 ", %" PRIu32
"ms after last send",
function_code, exception, address, millis() - this->last_send_);
if (this->waiting_for_response_ == address) {
device->on_modbus_error(function_code & FUNCTION_CODE_MASK, exception);
} else {
// Ignore modbus exception not related to a pending command
ESP_LOGD(TAG, "Ignoring error - not expecting a response from %" PRIu8 "", address);
}
} else { // Not an error response
if (this->waiting_for_response_ == address) {
device->on_modbus_data(data);
} else {
// Ignore modbus response not related to a pending command
ESP_LOGW(TAG, "Ignoring response - not expecting a response from %" PRIu8 ", %" PRIu32 "ms after last send",
address, millis() - this->last_send_);
}
}
}
// fallthrough for other function codes
device->on_modbus_data(data);
}
}
waiting_for_response = 0;
if (!found) {
ESP_LOGW(TAG, "Got Modbus frame from unknown address 0x%02X! ", address);
if (!found && this->role == ModbusRole::CLIENT) {
ESP_LOGW(TAG, "Got frame from unknown address %" PRIu8 ", %" PRIu32 "ms after last send", address,
millis() - this->last_send_);
}
// reset buffer
ESP_LOGV(TAG, "Clearing buffer of %d bytes - parse succeeded", at);
this->rx_buffer_.clear();
this->clear_rx_buffer_(LOG_STR("parse succeeded"));
if (this->waiting_for_response_ == address)
this->waiting_for_response_ = 0;
return true;
}
void Modbus::send_next_frame_() {
if (this->tx_buffer_.empty())
return;
if (this->tx_blocked())
return;
const ModbusDeviceCommand &frame = this->tx_buffer_.front();
if (this->role == ModbusRole::CLIENT) {
this->waiting_for_response_ = frame.data.get()[0];
}
if (this->flow_control_pin_ != nullptr) {
this->flow_control_pin_->digital_write(true);
this->write_array(frame.data.get(), frame.size);
this->flush();
this->flow_control_pin_->digital_write(false);
this->last_send_tx_offset_ = 0;
} else {
this->write_array(frame.data.get(), frame.size);
this->last_send_tx_offset_ = frame.size * 11 * 1000 / this->parent_->get_baud_rate() + 1;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Write: %s %" PRIu32 "ms after last send", format_hex_pretty_to(hex_buf, frame.data.get(), frame.size),
millis() - this->last_send_);
this->last_send_ = millis();
this->tx_buffer_.pop_front();
if (!this->tx_buffer_.empty()) {
ESP_LOGV(TAG, "Write queue contains %" PRIu32 " items.", this->tx_buffer_.size());
}
}
void Modbus::dump_config() {
ESP_LOGCONFIG(TAG,
"Modbus:\n"
" Send Wait Time: %d ms\n"
" Turnaround Time: %d ms\n"
" Frame Delay: %d ms\n"
" Long Rx Buffer Delay: %d ms\n"
" CRC Disabled: %s",
this->send_wait_time_, YESNO(this->disable_crc_));
this->send_wait_time_, this->turnaround_delay_ms_, this->frame_delay_ms_,
this->long_rx_buffer_delay_ms_, YESNO(this->disable_crc_));
LOG_PIN(" Flow Control Pin: ", this->flow_control_pin_);
}
float Modbus::get_setup_priority() const {
@@ -228,15 +333,6 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address
return;
}
static constexpr size_t ADDR_SIZE = 1;
static constexpr size_t FC_SIZE = 1;
static constexpr size_t START_ADDR_SIZE = 2;
static constexpr size_t NUM_ENTITIES_SIZE = 2;
static constexpr size_t BYTE_COUNT_SIZE = 1;
static constexpr size_t MAX_PAYLOAD_SIZE = std::numeric_limits<uint8_t>::max();
static constexpr size_t CRC_SIZE = 2;
static constexpr size_t MAX_FRAME_SIZE =
ADDR_SIZE + FC_SIZE + START_ADDR_SIZE + NUM_ENTITIES_SIZE + BYTE_COUNT_SIZE + MAX_PAYLOAD_SIZE + CRC_SIZE;
uint8_t data[MAX_FRAME_SIZE];
size_t pos = 0;
@@ -259,29 +355,16 @@ void Modbus::send(uint8_t address, uint8_t function_code, uint16_t start_address
} else {
payload_len = 2; // Write single register or coil
}
if (payload_len + pos + 2 > MAX_FRAME_SIZE) { // Check if payload fits (accounting for CRC)
ESP_LOGE(TAG, "Payload too large to send: %d bytes", payload_len);
return;
}
for (int i = 0; i < payload_len; i++) {
data[pos++] = payload[i];
}
}
auto crc = crc16(data, pos);
data[pos++] = crc >> 0;
data[pos++] = crc >> 8;
if (this->flow_control_pin_ != nullptr)
this->flow_control_pin_->digital_write(true);
this->write_array(data, pos);
this->flush();
if (this->flow_control_pin_ != nullptr)
this->flow_control_pin_->digital_write(false);
waiting_for_response = address;
last_send_ = millis();
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus write: %s", format_hex_pretty_to(hex_buf, data, pos));
this->queue_raw_(data, pos);
}
// Helper function for lambdas
@@ -290,23 +373,44 @@ void Modbus::send_raw(const std::vector<uint8_t> &payload) {
if (payload.empty()) {
return;
}
// Frame size: payload + CRC(2)
if (payload.size() + 2 > MAX_FRAME_SIZE) {
ESP_LOGE(TAG, "Attempted to send frame larger than max frame size of %d bytes", MAX_FRAME_SIZE);
return;
}
// Use stack buffer - Modbus frames are small and bounded
uint8_t data[MAX_FRAME_SIZE];
if (this->flow_control_pin_ != nullptr)
this->flow_control_pin_->digital_write(true);
std::memcpy(data, payload.data(), payload.size());
auto crc = crc16(payload.data(), payload.size());
this->write_array(payload);
this->write_byte(crc & 0xFF);
this->write_byte((crc >> 8) & 0xFF);
this->flush();
if (this->flow_control_pin_ != nullptr)
this->flow_control_pin_->digital_write(false);
waiting_for_response = payload[0];
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
this->queue_raw_(data, payload.size());
}
// Assume data and length is valid and append CRC, then queue for sending. Used internally to avoid unnecessary copying
// of data into vectors
void Modbus::queue_raw_(const uint8_t *data, uint16_t len) {
if (this->tx_buffer_.size() < MODBUS_TX_BUFFER_SIZE) {
this->tx_buffer_.emplace_back(data, len);
} else {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR
char hex_buf[format_hex_pretty_size(MODBUS_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus write raw: %s", format_hex_pretty_to(hex_buf, payload.data(), payload.size()));
last_send_ = millis();
ESP_LOGE(TAG, "Write buffer full, dropped: %s", format_hex_pretty_to(hex_buf, data, len));
}
}
void Modbus::clear_rx_buffer_(const LogString *reason, bool warn) {
size_t at = this->rx_buffer_.size();
if (at > 0) {
if (warn) {
ESP_LOGW(TAG, "Clearing buffer of %" PRIu32 " bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason),
millis() - this->last_send_);
} else {
ESP_LOGV(TAG, "Clearing buffer of %" PRIu32 " bytes - %s %" PRIu32 "ms after last send", at, LOG_STR_ARG(reason),
millis() - this->last_send_);
}
this->rx_buffer_.clear();
}
}
} // namespace modbus
+46 -9
View File
@@ -5,11 +5,16 @@
#include "esphome/components/modbus/modbus_definitions.h"
#include <cstring>
#include <memory>
#include <vector>
#include <queue>
namespace esphome {
namespace modbus {
static constexpr uint16_t MODBUS_TX_BUFFER_SIZE = 15;
enum ModbusRole {
CLIENT,
SERVER,
@@ -17,6 +22,19 @@ enum ModbusRole {
class ModbusDevice;
struct ModbusDeviceCommand {
// Frame with exact-size allocation to avoid std::vector overhead
std::unique_ptr<uint8_t[]> data;
uint16_t size; // Modbus RTU max is 256 bytes
ModbusDeviceCommand(const uint8_t *src, uint16_t len) : data(std::make_unique<uint8_t[]>(len + 2)), size(len + 2) {
std::memcpy(this->data.get(), src, len);
auto crc = crc16(data.get(), len);
data[len + 0] = crc >> 0;
data[len + 1] = crc >> 8;
}
};
class Modbus : public uart::UARTDevice, public Component {
public:
Modbus() = default;
@@ -30,28 +48,45 @@ class Modbus : public uart::UARTDevice, public Component {
void register_device(ModbusDevice *device) { this->devices_.push_back(device); }
float get_setup_priority() const override;
bool tx_buffer_empty();
bool tx_blocked();
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr);
void send_raw(const std::vector<uint8_t> &payload);
void set_role(ModbusRole role) { this->role = role; }
void set_flow_control_pin(GPIOPin *flow_control_pin) { this->flow_control_pin_ = flow_control_pin; }
uint8_t waiting_for_response{0};
void set_send_wait_time(uint16_t time_in_ms) { send_wait_time_ = time_in_ms; }
void set_disable_crc(bool disable_crc) { disable_crc_ = disable_crc; }
void set_send_wait_time(uint16_t time_in_ms) { this->send_wait_time_ = time_in_ms; }
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_ms_ = time_in_ms; }
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
ModbusRole role;
protected:
GPIOPin *flow_control_pin_{nullptr};
bool parse_modbus_byte_(uint8_t byte);
uint16_t send_wait_time_{250};
bool disable_crc_;
std::vector<uint8_t> rx_buffer_;
void receive_and_parse_modbus_bytes_();
void clear_rx_buffer_(const LogString *reason, bool warn = false);
void send_next_frame_();
void queue_raw_(const uint8_t *data, uint16_t len);
uint32_t last_modbus_byte_{0};
uint32_t last_send_{0};
uint32_t last_send_tx_offset_{0};
uint16_t frame_delay_ms_{5};
uint16_t long_rx_buffer_delay_ms_{0};
uint16_t send_wait_time_{250};
uint16_t turnaround_delay_ms_{100};
uint8_t waiting_for_response_{0};
bool disable_crc_{false};
GPIOPin *flow_control_pin_{nullptr};
std::vector<uint8_t> rx_buffer_;
std::vector<ModbusDevice *> devices_;
// std::deque is appropriate here since we need a FIFO buffer, and we can't know ahead of time how many
// requests will be queued. Each modbus component may queue multiple requests, and the sequence of scheduling
// may change at run time.
std::deque<ModbusDeviceCommand> tx_buffer_;
};
class ModbusDevice {
@@ -76,7 +111,9 @@ class ModbusDevice {
this->send_raw(error_response);
}
// If more than one device is connected block sending a new command before a response is received
bool waiting_for_response() { return parent_->waiting_for_response != 0; }
ESPDEPRECATED("Use ready_for_immediate_send() instead. Removed in 2026.9.0", "2026.3.0")
bool waiting_for_response() { return !ready_for_immediate_send(); }
bool ready_for_immediate_send() { return parent_->tx_buffer_empty() && !parent_->tx_blocked(); }
protected:
friend Modbus;
@@ -81,6 +81,8 @@ const uint8_t MAX_NUM_OF_REGISTERS_TO_WRITE = 123; // 0x7B
// 6.3 03 (0x03) Read Holding Registers
// 6.4 04 (0x04) Read Input Registers
const uint8_t MAX_NUM_OF_REGISTERS_TO_READ = 125; // 0x7D
static constexpr uint16_t MAX_FRAME_SIZE = 256;
/// End of Modbus definitions
} // namespace modbus
} // namespace esphome
@@ -48,6 +48,7 @@ CONF_SERVER_REGISTERS = "server_registers"
MULTI_CONF = True
modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller")
modbus_ns = cg.esphome_ns.namespace("modbus")
ModbusController = modbus_controller_ns.class_(
"ModbusController", cg.PollingComponent, modbus.ModbusDevice
)
@@ -56,7 +57,7 @@ SensorItem = modbus_controller_ns.struct("SensorItem")
ServerCourtesyResponse = modbus_controller_ns.struct("ServerCourtesyResponse")
ServerRegister = modbus_controller_ns.struct("ServerRegister")
ModbusFunctionCode_ns = modbus_controller_ns.namespace("ModbusFunctionCode")
ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode")
ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode")
MODBUS_FUNCTION_CODE = {
"read_coils": ModbusFunctionCode.READ_COILS,
@@ -18,7 +18,7 @@ void ModbusController::setup() { this->create_register_ranges_(); }
bool ModbusController::send_next_command_() {
uint32_t last_send = millis() - this->last_command_timestamp_;
if ((last_send > this->command_throttle_) && !waiting_for_response() && !this->command_queue_.empty()) {
if ((last_send > this->command_throttle_) && this->ready_for_immediate_send() && !this->command_queue_.empty()) {
auto &command = this->command_queue_.front();
// remove from queue if command was sent too often
+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;
+3 -3
View File
@@ -337,7 +337,7 @@ void Nextion::loop() {
this->started_ms_ = App.get_loop_component_start_time();
if (this->startup_override_ms_ > 0 &&
this->started_ms_ + this->startup_override_ms_ < App.get_loop_component_start_time()) {
App.get_loop_component_start_time() - this->started_ms_ > this->startup_override_ms_) {
ESP_LOGV(TAG, "Manual ready set");
this->connection_state_.nextion_reports_is_setup_ = true;
}
@@ -853,10 +853,10 @@ void Nextion::process_nextion_commands_() {
const uint32_t ms = App.get_loop_component_start_time();
if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() &&
this->nextion_queue_.front()->queue_time + this->max_q_age_ms_ < ms) {
ms - this->nextion_queue_.front()->queue_time > this->max_q_age_ms_) {
for (size_t i = 0; i < this->nextion_queue_.size(); i++) {
NextionComponentBase *component = this->nextion_queue_[i]->component;
if (this->nextion_queue_[i]->queue_time + this->max_q_age_ms_ < ms) {
if (ms - this->nextion_queue_[i]->queue_time > this->max_q_age_ms_) {
if (this->nextion_queue_[i]->queue_time == 0) {
ESP_LOGD(TAG, "Remove old queue '%s':'%s' (t=0)", component->get_queue_type_string().c_str(),
component->get_variable_name().c_str());
+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]);
}
@@ -330,15 +330,16 @@ void PacketTransport::update() {
if (!this->ping_pong_enable_) {
return;
}
auto now = millis() / 1000;
if (this->last_key_time_ + this->ping_pong_recyle_time_ < now) {
uint32_t now = millis();
uint32_t ping_request_age = now - this->last_key_time_;
if (ping_request_age > this->ping_pong_recyle_time_ * 1000u) {
this->resend_ping_key_ = this->ping_pong_enable_;
ESP_LOGV(TAG, "Ping request, age %" PRIu32, now - this->last_key_time_);
ESP_LOGV(TAG, "Ping request, age %" PRIu32, ping_request_age);
this->last_key_time_ = now;
}
for (const auto &provider : this->providers_) {
uint32_t key_response_age = now - provider.second.last_key_response_time;
if (key_response_age > (this->ping_pong_recyle_time_ * 2u)) {
if (key_response_age > (this->ping_pong_recyle_time_ * 2000u)) {
#ifdef USE_STATUS_SENSOR
if (provider.second.status_sensor != nullptr && provider.second.status_sensor->state) {
ESP_LOGI(TAG, "Ping status for %s timeout at %" PRIu32 " with age %" PRIu32, provider.first.c_str(), now,
@@ -496,7 +497,7 @@ void PacketTransport::process_(std::span<const uint8_t> data) {
if (decoder.decode(PING_KEY, key) == DECODE_OK) {
if (key == this->ping_key_) {
ping_key_seen = true;
provider.last_key_response_time = millis() / 1000;
provider.last_key_response_time = millis();
ESP_LOGV(TAG, "Found good ping key %X at timestamp %" PRIu32, (unsigned) key, provider.last_key_response_time);
} else {
ESP_LOGV(TAG, "Unknown ping key %X", (unsigned) key);
+3 -3
View File
@@ -91,7 +91,7 @@ def _parse_platform_version(value):
# The default/recommended arduino framework version
# - https://github.com/earlephilhower/arduino-pico/releases
# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 0)
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 1)
# The raspberrypi platform version to use for arduino frameworks
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
@@ -101,8 +101,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460"
def _arduino_check_versions(value):
value = value.copy()
lookups = {
"dev": (cv.Version(5, 5, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(5, 5, 0), None),
"dev": (cv.Version(5, 5, 1), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(5, 5, 1), None),
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
}
+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
@@ -9,21 +9,16 @@ namespace esphome {
namespace runtime_stats {
RuntimeStatsCollector::RuntimeStatsCollector() : log_interval_(60000), next_log_time_(0) {
RuntimeStatsCollector::RuntimeStatsCollector() : log_interval_(60000), next_log_time_(60000) {
global_runtime_stats = this;
}
void RuntimeStatsCollector::record_component_time(Component *component, uint32_t duration_us, uint32_t current_time) {
void RuntimeStatsCollector::record_component_time(Component *component, uint32_t duration_us) {
if (component == nullptr)
return;
// Record stats using component pointer as key
this->component_stats_[component].record_time(duration_us);
if (this->next_log_time_ == 0) {
this->next_log_time_ = current_time + this->log_interval_;
return;
}
}
void RuntimeStatsCollector::log_stats_() {
@@ -88,10 +83,7 @@ void RuntimeStatsCollector::log_stats_() {
}
void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) {
if (this->next_log_time_ == 0)
return;
if (current_time >= this->next_log_time_) {
if ((int32_t) (current_time - this->next_log_time_) >= 0) {
this->log_stats_();
this->reset_stats_();
this->next_log_time_ = current_time + this->log_interval_;
@@ -7,6 +7,7 @@
#include <map>
#include <cstdint>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -80,10 +81,13 @@ class RuntimeStatsCollector {
public:
RuntimeStatsCollector();
void set_log_interval(uint32_t log_interval) { this->log_interval_ = log_interval; }
void set_log_interval(uint32_t log_interval) {
this->log_interval_ = log_interval;
this->next_log_time_ = millis() + log_interval;
}
uint32_t get_log_interval() const { return this->log_interval_; }
void record_component_time(Component *component, uint32_t duration_us, uint32_t current_time);
void record_component_time(Component *component, uint32_t duration_us);
// Process any pending stats printing (should be called after component loop)
void process_pending_stats(uint32_t current_time);
@@ -101,7 +105,7 @@ class RuntimeStatsCollector {
// We use Component* as the key since each component is unique
std::map<Component *, ComponentRuntimeStats> component_stats_;
uint32_t log_interval_;
uint32_t next_log_time_;
uint32_t next_log_time_{0};
};
} // namespace runtime_stats
@@ -177,10 +177,14 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c
uint16_t has_target_int = encode_uint16(data[1], data[0]);
this->has_target_binary_sensor_->publish_state(has_target_int);
if (has_target_int == 0) {
this->breath_rate_sensor_->publish_state(0.0);
this->heart_rate_sensor_->publish_state(0.0);
this->distance_sensor_->publish_state(0.0);
this->num_targets_sensor_->publish_state(0);
if (this->breath_rate_sensor_ != nullptr)
this->breath_rate_sensor_->publish_state(0.0);
if (this->heart_rate_sensor_ != nullptr)
this->heart_rate_sensor_->publish_state(0.0);
if (this->distance_sensor_ != nullptr)
this->distance_sensor_->publish_state(0.0);
if (this->num_targets_sensor_ != nullptr)
this->num_targets_sensor_->publish_state(0);
}
}
break;
+1 -13
View File
@@ -466,7 +466,7 @@ void HOT ST7735::write_display_data_() {
}
void ST7735::spi_master_write_addr_(uint16_t addr1, uint16_t addr2) {
static uint8_t byte[4];
uint8_t byte[4];
byte[0] = (addr1 >> 8) & 0xFF;
byte[1] = addr1 & 0xFF;
byte[2] = (addr2 >> 8) & 0xFF;
@@ -476,17 +476,5 @@ void ST7735::spi_master_write_addr_(uint16_t addr1, uint16_t addr2) {
this->write_array(byte, 4);
}
void ST7735::spi_master_write_color_(uint16_t color, uint16_t size) {
static uint8_t byte[1024];
int index = 0;
for (int i = 0; i < size; i++) {
byte[index++] = (color >> 8) & 0xFF;
byte[index++] = color & 0xFF;
}
this->dc_pin_->digital_write(true);
write_array(byte, size * 2);
}
} // namespace st7735
} // namespace esphome
-1
View File
@@ -68,7 +68,6 @@ class ST7735 : public display::DisplayBuffer,
void set_addr_window_(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
void draw_absolute_pixel_internal(int x, int y, Color color) override;
void spi_master_write_addr_(uint16_t addr1, uint16_t addr2);
void spi_master_write_color_(uint16_t color, uint16_t size);
int get_width_internal() override;
int get_height_internal() override;
+19 -10
View File
@@ -1,11 +1,16 @@
#include "st7789v.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome {
namespace st7789v {
static const char *const TAG = "st7789v";
static const size_t TEMP_BUFFER_SIZE = 128;
#ifdef USE_ESP32
static constexpr size_t TEMP_BUFFER_SIZE = 1024;
#else
static constexpr size_t TEMP_BUFFER_SIZE = 512;
#endif
void ST7789V::setup() {
#ifdef USE_POWER_SUPPLY
@@ -236,7 +241,7 @@ void ST7789V::write_data_(uint8_t value) {
}
void ST7789V::write_addr_(uint16_t addr1, uint16_t addr2) {
static uint8_t byte[4];
uint8_t byte[4];
byte[0] = (addr1 >> 8) & 0xFF;
byte[1] = addr1 & 0xFF;
byte[2] = (addr2 >> 8) & 0xFF;
@@ -247,15 +252,19 @@ void ST7789V::write_addr_(uint16_t addr1, uint16_t addr2) {
}
void ST7789V::write_color_(uint16_t color, uint16_t size) {
static uint8_t byte[1024];
int index = 0;
for (int i = 0; i < size; i++) {
byte[index++] = (color >> 8) & 0xFF;
byte[index++] = color & 0xFF;
}
uint8_t byte[TEMP_BUFFER_SIZE];
uint16_t remaining = size;
this->dc_pin_->digital_write(true);
write_array(byte, size * 2);
while (remaining > 0) {
uint16_t batch = std::min(remaining, static_cast<uint16_t>(sizeof(byte) / 2));
int index = 0;
for (int i = 0; i < batch; i++) {
byte[index++] = (color >> 8) & 0xFF;
byte[index++] = color & 0xFF;
}
this->write_array(byte, batch * 2);
remaining -= batch;
}
}
size_t ST7789V::get_buffer_length_() {
+9 -6
View File
@@ -72,16 +72,19 @@ void ST7920::goto_xy_(uint16_t x, uint16_t y) {
}
void HOT ST7920::write_display_data() {
uint8_t i, j, b;
for (j = 0; j < (uint8_t) (this->get_height_internal() / 2); j++) {
int i, j;
uint8_t b;
int width_bytes = this->get_width_internal() / 8;
int half_height = this->get_height_internal() / 2;
for (j = 0; j < half_height; j++) {
this->goto_xy_(0, j);
this->enable();
for (i = 0; i < 16; i++) { // 16 bytes from line #0+
b = this->buffer_[i + j * 16];
for (i = 0; i < width_bytes; i++) {
b = this->buffer_[i + j * width_bytes];
this->send_(LCD_DATA, b);
}
for (i = 0; i < 16; i++) { // 16 bytes from line #32+
b = this->buffer_[i + (j + 32) * 16];
for (i = 0; i < width_bytes; i++) {
b = this->buffer_[i + (j + half_height) * width_bytes];
this->send_(LCD_DATA, b);
}
this->disable();
+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();
+2 -2
View File
@@ -118,8 +118,8 @@ void TMP1075Sensor::send_alert_limit_high_() {
}
static uint16_t temp2regvalue(const float temp) {
const uint16_t regvalue = temp / 0.0625f;
return regvalue << 4;
const int16_t regvalue = static_cast<int16_t>(temp / 0.0625f);
return static_cast<uint16_t>(regvalue << 4);
}
static float regvalue2temp(const uint16_t regvalue) {
+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 -2
View File
@@ -171,8 +171,8 @@ void USBUartChannel::flush() {
// Safe to call from the main loop only.
// The 100 ms timeout guards against a device that stops responding mid-flush;
// in that case the main loop is blocked for the full duration.
uint32_t deadline = millis() + 100; // 100 ms safety timeout
while ((!this->output_queue_.empty() || this->output_started_.load()) && millis() < deadline) {
uint32_t start = millis(); // 100 ms safety timeout
while ((!this->output_queue_.empty() || this->output_started_.load()) && millis() - start < 100) {
// Kick start_output() in case data arrived but no transfer is in flight yet.
this->parent_->start_output(this);
yield();
+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
@@ -48,7 +48,7 @@ class WhirlpoolClimate : public climate_ir::ClimateIR {
/// Handle received IR Buffer
bool on_receive(remote_base::RemoteReceiveData data) override;
/// Set the time of the last transmission.
int32_t last_transmit_time_{};
uint32_t last_transmit_time_{};
bool send_swing_cmd_{false};
Model model_;
+7 -1
View File
@@ -210,7 +210,13 @@ WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend(
def wifi_network_ap(value):
if value is None:
value = {}
return WIFI_NETWORK_AP(value)
config = WIFI_NETWORK_AP(value)
if CONF_MANUAL_IP in config and CORE.is_rp2040:
raise cv.Invalid(
"Manual AP IP configuration is not supported on RP2040. "
"The AP uses the default IP 192.168.4.1"
)
return config
WIFI_NETWORK_STA = WIFI_NETWORK_BASE.extend(
+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"
@@ -18,6 +20,25 @@ namespace esphome::wifi {
static const char *const TAG = "wifi_pico_w";
// Check if STA is fully connected (WiFi joined + has IP address).
// Do NOT use WiFi.status() or WiFi.connected() for this — in AP-only mode they
// unconditionally return true regardless of STA state, causing false positives
// when the fallback AP is active.
static bool wifi_sta_connected() {
int link = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA);
IPAddress local = WiFi.localIP();
if (link == CYW43_LINK_JOIN && local.isSet()) {
// Verify the IP is a real STA IP, not the AP's IP leaking through
IPAddress ap_ip = WiFi.softAPIP();
if (local == ap_ip) {
ESP_LOGV(TAG, "wifi_sta_connected: localIP %s matches AP IP, ignoring", local.toString().c_str());
return false;
}
return true;
}
return false;
}
// Track previous state for detecting changes
static bool s_sta_was_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static bool s_sta_had_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -27,17 +48,21 @@ bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
if (sta.has_value()) {
if (sta.value()) {
cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_STA, true, CYW43_COUNTRY_WORLDWIDE);
} else {
// Leave the STA network so the radio is free for scanning.
// Use cyw43_wifi_leave directly to avoid corrupting Arduino framework state.
cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
}
}
bool ap_state = false;
if (ap.has_value()) {
if (ap.value()) {
cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE);
ap_state = true;
} else {
cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, false, CYW43_COUNTRY_WORLDWIDE);
}
this->ap_started_ = ap.value();
}
this->ap_started_ = ap_state;
return true;
}
@@ -129,8 +154,8 @@ WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const {
int status = cyw43_wifi_link_status(&cyw43_state, CYW43_ITF_STA);
switch (status) {
case CYW43_LINK_JOIN:
// WiFi joined, check if we have an IP address via the Arduino framework's WiFi class
if (WiFi.status() == WL_CONNECTED) {
// WiFi joined, check if STA has an IP address via wifi_sta_connected()
if (wifi_sta_connected()) {
return WiFiSTAConnectStatus::CONNECTED;
}
return WiFiSTAConnectStatus::CONNECTING;
@@ -188,19 +213,9 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(const optional<ManualIP> &manual_ip) {
esphome::network::IPAddress ip_address, gateway, subnet, dns;
if (manual_ip.has_value()) {
ip_address = manual_ip->static_ip;
gateway = manual_ip->gateway;
subnet = manual_ip->subnet;
dns = manual_ip->static_ip;
} else {
ip_address = network::IPAddress(192, 168, 4, 1);
gateway = network::IPAddress(192, 168, 4, 1);
subnet = network::IPAddress(255, 255, 255, 0);
dns = network::IPAddress(192, 168, 4, 1);
}
WiFi.config(ip_address, dns, gateway, subnet);
// AP IP is configured by WiFi.beginAP() internally using defaults (192.168.4.1).
// Manual AP IP has never worked on RP2040 — WiFi.config() configures the STA
// interface, not the AP. This is now rejected at config validation time.
return true;
}
@@ -219,18 +234,25 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) {
}
#endif
WiFi.beginAP(ap.ssid_.c_str(), ap.password_.c_str(), ap.has_channel() ? ap.get_channel() : 1);
// Pass nullptr for empty password — CYW43 uses the password pointer (not length)
// to choose between OPEN and WPA2 auth mode.
const char *ap_password = ap.password_.empty() ? nullptr : ap.password_.c_str();
WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1);
return true;
}
network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.localIP()}; }
network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.softAPIP()}; }
#endif // USE_WIFI_AP
bool WiFiComponent::wifi_disconnect_() {
// Use Arduino WiFi.disconnect() instead of raw cyw43_wifi_leave() to properly
// clean up the lwIP netif, DHCP client, and internal Arduino state.
WiFi.disconnect();
// Use cyw43_wifi_leave() directly instead of WiFi.disconnect().
// WiFi.disconnect() sets _wifiHWInitted=false in the Arduino framework. beginAP()
// uses _wifiHWInitted to determine AP+STA vs AP-only mode — with it false,
// beginAP() enters AP-only mode (IP 192.168.42.1) instead of AP_STA mode
// (IP 192.168.4.1). In AP-only mode, _beginInternal() redirects all subsequent
// STA connect attempts to beginAP(), creating an infinite loop.
cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA);
return true;
}
@@ -251,14 +273,22 @@ const char *WiFiComponent::wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer
buffer[len] = '\0';
return buffer.data();
}
int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; }
int8_t WiFiComponent::wifi_rssi() { return this->is_connected_() ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; }
int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); }
network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {
network::IPAddresses addresses;
uint8_t index = 0;
// Filter out AP interface addresses — addrList includes all lwIP netifs.
// The AP netif IP lingers even after the AP radio is disabled.
IPAddress ap_ip = WiFi.softAPIP();
for (auto addr : addrList) {
addresses[index++] = addr.ipFromNetifNum();
IPAddress ip(addr.ipFromNetifNum());
if (ip == ap_ip) {
continue;
}
assert(index < addresses.size());
addresses[index++] = ip;
}
return addresses;
}
@@ -288,9 +318,7 @@ void WiFiComponent::wifi_loop_() {
// Poll for connection state changes
// The arduino-pico WiFi library doesn't have event callbacks like ESP8266/ESP32,
// so we need to poll the link status to detect state changes.
// Use WiFi.connected() which checks both the WiFi link and IP address via the
// Arduino framework's own netif (not the SDK's uninitialized one).
bool is_connected = WiFi.connected();
bool is_connected = wifi_sta_connected();
// Detect connection state change
if (is_connected && !s_sta_was_connected) {
+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};
+1 -1
View File
@@ -534,7 +534,7 @@ uint32_t WarnIfComponentBlockingGuard::finish() {
// 1ms granularity, so results were essentially random noise.
if (global_runtime_stats != nullptr) {
uint32_t duration_us = micros() - this->started_us_;
global_runtime_stats->record_component_time(this->component_, duration_us, curr_time);
global_runtime_stats->record_component_time(this->component_, duration_us);
}
#endif
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) {
+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::configure_entity(const char *name, uint32_t object_id_hash, uin
// 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;
@@ -83,7 +84,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_));
@@ -91,7 +112,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 {
@@ -165,8 +193,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,
@@ -109,12 +113,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
@@ -80,6 +81,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).
@@ -87,14 +90,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"
@@ -105,9 +134,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),
)
@@ -119,8 +148,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")
@@ -162,6 +193,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>
+1 -1
View File
@@ -196,7 +196,7 @@ board_build.filesystem_size = 0.5m
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460
platform_packages =
; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.5.0/rp2040-5.5.0.zip
earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.5.1/rp2040-5.5.1.zip
framework = arduino
lib_deps =
+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
+2
View File
@@ -1,3 +1,5 @@
modbus:
id: mod_bus1
flow_control_pin: ${flow_control_pin}
send_wait_time: 500ms
turnaround_time: 100ms
+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);
@@ -71,6 +71,7 @@ RESPONSE_SCHEMA = cv.Schema(
{
cv.Required(CONF_EXPECT_TX): [cv.hex_uint8_t],
cv.Required(CONF_INJECT_RX): [cv.hex_uint8_t],
cv.Optional(CONF_DELAY, default="0ms"): cv.positive_time_period_milliseconds,
}
)
@@ -151,7 +152,8 @@ async def to_code(config):
for response in config[CONF_RESPONSES]:
tx_data = response[CONF_EXPECT_TX]
rx_data = response[CONF_INJECT_RX]
cg.add(var.add_response(tx_data, rx_data))
delay_ms = response[CONF_DELAY]
cg.add(var.add_response(tx_data, rx_data, delay_ms))
for periodic in config[CONF_PERIODIC_RX]:
data = periodic[CONF_DATA]
@@ -36,8 +36,8 @@ void MockUartComponent::loop() {
// component (e.g., LD2410) a chance to process each batch independently.
if (this->injection_index_ < this->injections_.size()) {
auto &injection = this->injections_[this->injection_index_];
uint32_t target_time = this->scenario_start_ms_ + this->cumulative_delay_ms_ + injection.delay_ms;
if (now >= target_time) {
uint32_t total_delay = this->cumulative_delay_ms_ + injection.delay_ms;
if (now - this->scenario_start_ms_ >= total_delay) {
ESP_LOGD(TAG, "Injecting %zu RX bytes (injection %u)", injection.rx_data.size(), this->injection_index_);
this->inject_to_rx_buffer(injection.rx_data);
this->cumulative_delay_ms_ += injection.delay_ms;
@@ -52,6 +52,15 @@ void MockUartComponent::loop() {
periodic.last_inject_ms = now;
}
}
// Process delayed responses
for (auto &response : this->responses_) {
if (response.delay_ms > 0 && response.last_match_ms > 0 && now - response.last_match_ms >= response.delay_ms) {
ESP_LOGD(TAG, "Injecting %zu RX bytes for delayed response", response.inject_rx.size());
this->inject_to_rx_buffer(response.inject_rx);
response.last_match_ms = 0; // Reset to prevent repeated injection
}
}
}
void MockUartComponent::start_scenario() {
@@ -149,8 +158,9 @@ void MockUartComponent::add_injection(const std::vector<uint8_t> &rx_data, uint3
this->injections_.push_back({rx_data, delay_ms});
}
void MockUartComponent::add_response(const std::vector<uint8_t> &expect_tx, const std::vector<uint8_t> &inject_rx) {
this->responses_.push_back({expect_tx, inject_rx});
void MockUartComponent::add_response(const std::vector<uint8_t> &expect_tx, const std::vector<uint8_t> &inject_rx,
uint32_t delay_ms) {
this->responses_.push_back({expect_tx, inject_rx, delay_ms, 0});
}
void MockUartComponent::add_periodic_rx(const std::vector<uint8_t> &data, uint32_t interval_ms) {
@@ -166,7 +176,13 @@ void MockUartComponent::try_match_response_() {
size_t offset = this->tx_buffer_.size() - response.expect_tx.size();
if (std::equal(response.expect_tx.begin(), response.expect_tx.end(), this->tx_buffer_.begin() + offset)) {
ESP_LOGD(TAG, "TX match found, injecting %zu RX bytes", response.inject_rx.size());
this->inject_to_rx_buffer(response.inject_rx);
if (response.delay_ms > 0) {
ESP_LOGD(TAG, "Delaying response by %u ms", response.delay_ms);
// Schedule the response injection as a future injection
response.last_match_ms = App.get_loop_component_start_time();
} else {
this->inject_to_rx_buffer(response.inject_rx);
}
this->tx_buffer_.clear();
return;
}
@@ -34,7 +34,8 @@ class MockUartComponent : public uart::UARTComponent, public Component {
// Scenario configuration - called from generated code
void add_injection(const std::vector<uint8_t> &rx_data, uint32_t delay_ms);
void add_response(const std::vector<uint8_t> &expect_tx, const std::vector<uint8_t> &inject_rx);
void add_response(const std::vector<uint8_t> &expect_tx, const std::vector<uint8_t> &inject_rx,
uint32_t delay_ms = 0);
void add_periodic_rx(const std::vector<uint8_t> &data, uint32_t interval_ms);
void start_scenario();
@@ -64,6 +65,8 @@ class MockUartComponent : public uart::UARTComponent, public Component {
struct Response {
std::vector<uint8_t> expect_tx;
std::vector<uint8_t> inject_rx;
uint32_t delay_ms;
uint32_t last_match_ms{0};
};
std::vector<Response> responses_;
std::vector<uint8_t> tx_buffer_;
@@ -25,20 +25,64 @@ uart_mock:
auto_start: false
debug:
responses:
- expect_tx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 1 on device 1
- expect_tx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_register)
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x03, 0xF9, 0xD5] # Return value 0x0103 (hex) = 259 (dec)
- expect_tx: [0x01, 0x03, 0x00, 0x05, 0x00, 0x01, 0x94, 0x0B] # Read holding register 5 on device 1 (delayed_response)
delay: 100ms # Shorter than modbus send_wait_time of 200ms, should succeed
inject_rx: [0x01, 0x03, 0x02, 0x00, 0xFF, 0xF8, 0x04] # Return value 0x00FF (hex) = 255 (dec)
- expect_tx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2 (late_response)
delay: 300ms # Longer than modbus send_wait_time of 200ms, should cause timeout
inject_rx: [0x02, 0x03, 0x02, 0x00, 0xF0, 0xFC, 0x00] # Return value 0x00F0 (hex) = 240 (dec)
- expect_tx: [0x03, 0x03, 0x00, 0x09, 0x00, 0x01, 0x55, 0xEA] # Read holding register 9 on device 3 (no_response)
inject_rx: [] # No response, should cause timeout
- expect_tx: [0x01, 0x03, 0x00, 0x0A, 0x00, 0x01, 0xA4, 0x08] # Read holding register A on device 1 (exception_response)
inject_rx: [0x01, 0x83, 0x02, 0xC0, 0xF1] # Exception response with code 2 (illegal data address)
modbus:
uart_id: virtual_uart_dev
send_wait_time: 200ms
turnaround_time: 10ms
modbus_controller:
address: 1
- address: 1
id: modbus_controller_ok
max_cmd_retries: 0
update_interval: 1s
- address: 2
id: modbus_controller_slow
max_cmd_retries: 0
update_interval: 1s
- address: 3
id: modbus_controller_offline
max_cmd_retries: 0
update_interval: 1s
sensor:
- platform: modbus_controller
name: "basic_register"
address: 0x03
register_type: holding
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "delayed_response"
address: 0x05
register_type: holding
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "late_response"
address: 0x07
register_type: holding
modbus_controller_id: modbus_controller_slow
- platform: modbus_controller
name: "no_response"
address: 0x09
register_type: holding
modbus_controller_id: modbus_controller_offline
- platform: modbus_controller
name: "exception_response"
address: 0x0A
register_type: holding
modbus_controller_id: modbus_controller_ok
button:
- platform: template
@@ -46,10 +46,12 @@ uart_mock:
modbus:
uart_id: virtual_uart_dev
turnaround_time: 10ms
sensor:
- platform: sdm_meter
address: 2
update_interval: 1s
phase_a:
voltage:
name: sdm_voltage
+62 -5
View File
@@ -39,9 +39,17 @@ async def test_uart_mock_modbus(
# Track sensor state updates (after initial state is swallowed)
sensor_states: dict[str, list[float]] = {
"basic_register": [],
"delayed_response": [],
"late_response": [],
"no_response": [],
"exception_response": [],
}
basic_register_changed = loop.create_future()
delayed_response_changed = loop.create_future()
late_response_changed = loop.create_future()
no_response_changed = loop.create_future()
exception_response_changed = loop.create_future()
def on_state(state: EntityState) -> None:
if isinstance(state, SensorState) and not state.missing_state:
@@ -54,6 +62,23 @@ async def test_uart_mock_modbus(
and not basic_register_changed.done()
):
basic_register_changed.set_result(True)
elif (
sensor_name == "delayed_response"
and state.state == 255.0
and not delayed_response_changed.done()
):
delayed_response_changed.set_result(True)
elif (
sensor_name == "late_response" and not late_response_changed.done()
):
late_response_changed.set_result(True)
elif sensor_name == "no_response" and not no_response_changed.done():
no_response_changed.set_result(True)
elif (
sensor_name == "exception_response"
and not exception_response_changed.done()
):
exception_response_changed.set_result(True)
async with (
run_compiled(yaml_config),
@@ -79,20 +104,52 @@ async def test_uart_mock_modbus(
assert start_btn is not None, "Start Scenario button not found"
client.button_command(start_btn.key)
try:
await asyncio.wait_for(delayed_response_changed, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for delayed_response change. Received sensor states:\n"
f" delayed_response: {sensor_states['delayed_response']}\n"
)
try:
await asyncio.wait_for(late_response_changed, timeout=2.0)
pytest.fail(
f"late_response change should not have been triggered, but was. Received sensor states:\n"
f" late_response: {sensor_states['late_response']}\n"
)
except TimeoutError:
pass # Expected timeout since we never inject a response for late_response
try:
await asyncio.wait_for(no_response_changed, timeout=2.0)
pytest.fail(
f"no_response change should not have been triggered, but was. Received sensor states:\n"
f" no_response: {sensor_states['no_response']}\n"
)
except TimeoutError:
pass # Expected timeout since we never inject a response for no_response
# Wait for basic register to be updated with successful parse
try:
await asyncio.wait_for(basic_register_changed, timeout=15.0)
await asyncio.wait_for(basic_register_changed, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for Basic Register change. Received sensor states:\n"
f" basic_register: {sensor_states['basic_register']}\n"
)
try:
await asyncio.wait_for(exception_response_changed, timeout=2.0)
pytest.fail(
f"exception_response change should not have been triggered, but was. Received sensor states:\n"
f" exception_response: {sensor_states['exception_response']}\n"
)
except TimeoutError:
pass
@pytest.mark.asyncio
@pytest.mark.xfail(
reason="There is a bug in UART which will timeout for long responses."
)
async def test_uart_mock_modbus_timing(
yaml_config: str,
run_compiled: RunCompiledFunction,
@@ -155,7 +212,7 @@ async def test_uart_mock_modbus_timing(
# Wait for voltage to be updated with successful parse
try:
await asyncio.wait_for(voltage_changed, timeout=15.0)
await asyncio.wait_for(voltage_changed, timeout=2.0)
except TimeoutError:
pytest.fail(
f"Timeout waiting for SDM voltage change. Received sensor states:\n"
@@ -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
@@ -904,6 +905,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],

Some files were not shown because too many files have changed in this diff Show More