Merge remote-tracking branch 'upstream/ci/import-time-check' into ci/lazy-import-offenders

# Conflicts:
#	esphome/loader.py
This commit is contained in:
J. Nick Koston
2026-04-28 08:51:00 -05:00
194 changed files with 5430 additions and 982 deletions
@@ -0,0 +1,72 @@
name: Close PR From Fork Default Branch
on:
# pull_request_target is required so we have permission to comment and close PRs from forks.
pull_request_target:
types: [opened, reopened]
permissions:
pull-requests: write
issues: write
jobs:
close:
name: Close PR opened from fork's default branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
&& github.event.pull_request.head.ref == github.event.repository.default_branch
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const author = context.payload.pull_request.user.login;
const defaultBranch = context.payload.repository.default_branch;
const headRepo = context.payload.pull_request.head.repo.full_name;
const body = [
`Hi @${author}, thanks for opening a pull request! :tada:`,
``,
`It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`,
``,
`- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`,
`- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`,
`- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`,
`- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`,
``,
`Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`,
``,
`\`\`\`bash`,
`# Make sure your fork's ${defaultBranch} is up to date with upstream`,
`git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`,
`git fetch upstream`,
`git checkout ${defaultBranch}`,
`git reset --hard upstream/${defaultBranch}`,
`git push --force-with-lease origin ${defaultBranch}`,
``,
`# Create a new branch for your change and cherry-pick / re-apply your commits there`,
`git checkout -b my-feature-branch upstream/${defaultBranch}`,
`# ...re-apply your changes, then:`,
`git push origin my-feature-branch`,
`\`\`\``,
``,
`Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`,
``,
`Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`,
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
await github.rest.pulls.update({
owner,
repo,
pull_number: prNumber,
state: 'closed',
});
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.11
rev: v0.15.12
hooks:
# Run the linter.
- id: ruff
+6
View File
@@ -56,6 +56,7 @@ esphome/components/audio_adc/* @kbx81
esphome/components/audio_dac/* @kbx81
esphome/components/audio_file/* @kahrendt
esphome/components/audio_file/media_source/* @kahrendt
esphome/components/audio_http/* @kahrendt
esphome/components/axs15231/* @clydebarrow
esphome/components/b_parasite/* @rbaron
esphome/components/ballu/* @bazuchan
@@ -439,6 +440,11 @@ esphome/components/sen0321/* @notjj
esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
esphome/components/sendspin/text_sensor/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
+1 -1
View File
@@ -597,7 +597,7 @@ async def component_resume_action_to_code(
comp = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, comp)
if CONF_UPDATE_INTERVAL in config:
template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int)
template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32)
cg.add(var.set_update_interval(template_))
return var
@@ -13,7 +13,11 @@ from esphome.const import (
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@grahambrown11", "@hwstar"]
@@ -181,7 +185,7 @@ async def setup_alarm_control_panel_core_(var, config):
async def register_alarm_control_panel(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_alarm_control_panel(var))
queue_entity_register("alarm_control_panel", config)
CORE.register_platform_component("alarm_control_panel", var)
await setup_alarm_control_panel_core_(var, config)
+6 -1
View File
@@ -62,7 +62,12 @@ void Animation::set_frame(int frame) {
}
void Animation::update_data_start_() {
const uint32_t image_size = this->get_width_stride() * this->height_;
uint32_t image_size = this->get_width_stride() * this->height_;
// RGB565 with an alpha channel stores the alpha plane immediately after the RGB
// plane within each frame, so the per-frame stride includes the alpha bytes.
if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
image_size += static_cast<uint32_t>(this->width_) * this->height_;
}
this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_;
}
+11
View File
@@ -1025,6 +1025,13 @@ message CameraImageRequest {
bool stream = 2;
}
// ==================== TEMPERATURE UNIT ====================
enum TemperatureUnit {
TEMPERATURE_UNIT_CELSIUS = 0;
TEMPERATURE_UNIT_FAHRENHEIT = 1;
TEMPERATURE_UNIT_KELVIN = 2;
}
// ==================== CLIMATE ====================
enum ClimateMode {
CLIMATE_MODE_OFF = 0;
@@ -1110,6 +1117,7 @@ message ListEntitiesClimateResponse {
float visual_max_humidity = 25;
uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"];
uint32 feature_flags = 27;
TemperatureUnit temperature_unit = 28;
}
message ClimateStateResponse {
option (id) = 47;
@@ -1203,6 +1211,7 @@ message ListEntitiesWaterHeaterResponse {
repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"];
// Bitmask of WaterHeaterFeature flags
uint32 supported_features = 12;
TemperatureUnit temperature_unit = 13;
}
message WaterHeaterStateResponse {
@@ -1410,6 +1419,8 @@ enum LockState {
LOCK_STATE_JAMMED = 3;
LOCK_STATE_LOCKING = 4;
LOCK_STATE_UNLOCKING = 5;
LOCK_STATE_OPENING = 6;
LOCK_STATE_OPEN = 7;
}
enum LockCommand {
LOCK_UNLOCK = 0;
+4
View File
@@ -1439,6 +1439,7 @@ uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id);
#endif
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast<uint32_t>(this->temperature_unit));
return pos;
}
uint32_t ListEntitiesClimateResponse::calculate_size() const {
@@ -1488,6 +1489,7 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const {
size += ProtoSize::calc_uint32(2, this->device_id);
#endif
size += ProtoSize::calc_uint32(2, this->feature_flags);
size += this->temperature_unit ? 3 : 0;
return size;
}
uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -1645,6 +1647,7 @@ uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast<uint32_t>(it), true);
}
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast<uint32_t>(this->temperature_unit));
return pos;
}
uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const {
@@ -1667,6 +1670,7 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const {
size += this->supported_modes->size() * 2;
}
size += ProtoSize::calc_uint32(1, this->supported_features);
size += this->temperature_unit ? 2 : 0;
return size;
}
uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+11 -2
View File
@@ -92,6 +92,11 @@ enum SupportsResponseType : uint32_t {
SUPPORTS_RESPONSE_STATUS = 100,
};
#endif
enum TemperatureUnit : uint32_t {
TEMPERATURE_UNIT_CELSIUS = 0,
TEMPERATURE_UNIT_FAHRENHEIT = 1,
TEMPERATURE_UNIT_KELVIN = 2,
};
#ifdef USE_CLIMATE
enum ClimateMode : uint32_t {
CLIMATE_MODE_OFF = 0,
@@ -176,6 +181,8 @@ enum LockState : uint32_t {
LOCK_STATE_JAMMED = 3,
LOCK_STATE_LOCKING = 4,
LOCK_STATE_UNLOCKING = 5,
LOCK_STATE_OPENING = 6,
LOCK_STATE_OPEN = 7,
};
enum LockCommand : uint32_t {
LOCK_UNLOCK = 0,
@@ -1372,7 +1379,7 @@ class CameraImageRequest final : public ProtoDecodableMessage {
class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 46;
static constexpr uint8_t ESTIMATED_SIZE = 150;
static constexpr uint8_t ESTIMATED_SIZE = 153;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); }
#endif
@@ -1394,6 +1401,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
float visual_min_humidity{0.0f};
float visual_max_humidity{0.0f};
uint32_t feature_flags{0};
enums::TemperatureUnit temperature_unit{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -1471,7 +1479,7 @@ class ClimateCommandRequest final : public CommandProtoMessage {
class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 132;
static constexpr uint8_t ESTIMATED_SIZE = 63;
static constexpr uint8_t ESTIMATED_SIZE = 65;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); }
#endif
@@ -1480,6 +1488,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
float target_temperature_step{0.0f};
const water_heater::WaterHeaterModeMask *supported_modes{};
uint32_t supported_features{0};
enums::TemperatureUnit temperature_unit{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
+18
View File
@@ -297,6 +297,18 @@ template<> const char *proto_enum_to_string<enums::SupportsResponseType>(enums::
}
}
#endif
template<> const char *proto_enum_to_string<enums::TemperatureUnit>(enums::TemperatureUnit value) {
switch (value) {
case enums::TEMPERATURE_UNIT_CELSIUS:
return ESPHOME_PSTR("TEMPERATURE_UNIT_CELSIUS");
case enums::TEMPERATURE_UNIT_FAHRENHEIT:
return ESPHOME_PSTR("TEMPERATURE_UNIT_FAHRENHEIT");
case enums::TEMPERATURE_UNIT_KELVIN:
return ESPHOME_PSTR("TEMPERATURE_UNIT_KELVIN");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#ifdef USE_CLIMATE
template<> const char *proto_enum_to_string<enums::ClimateMode>(enums::ClimateMode value) {
switch (value) {
@@ -475,6 +487,10 @@ template<> const char *proto_enum_to_string<enums::LockState>(enums::LockState v
return ESPHOME_PSTR("LOCK_STATE_LOCKING");
case enums::LOCK_STATE_UNLOCKING:
return ESPHOME_PSTR("LOCK_STATE_UNLOCKING");
case enums::LOCK_STATE_OPENING:
return ESPHOME_PSTR("LOCK_STATE_OPENING");
case enums::LOCK_STATE_OPEN:
return ESPHOME_PSTR("LOCK_STATE_OPEN");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -1539,6 +1555,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast<enums::TemperatureUnit>(this->temperature_unit));
return out.c_str();
}
const char *ClimateStateResponse::dump_to(DumpBuffer &out) const {
@@ -1612,6 +1629,7 @@ const char *ListEntitiesWaterHeaterResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("supported_modes"), static_cast<enums::WaterHeaterMode>(it), 4);
}
dump_field(out, ESPHOME_PSTR("supported_features"), this->supported_features);
dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast<enums::TemperatureUnit>(this->temperature_unit));
return out.c_str();
}
const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const {
+4 -4
View File
@@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args):
cg.add(var.set_sensing_distance(template_))
if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME):
template_ = await cg.templatable(selfcheck, args, cg.int32)
template_ = await cg.templatable(selfcheck, args, cg.int_)
cg.add(var.set_poweron_selfcheck_time(template_))
if protect := config.get(CONF_PROTECT_TIME):
template_ = await cg.templatable(protect, args, cg.int32)
template_ = await cg.templatable(protect, args, cg.int_)
cg.add(var.set_protect_time(template_))
if trig_base := config.get(CONF_TRIGGER_BASE):
template_ = await cg.templatable(trig_base, args, cg.int32)
template_ = await cg.templatable(trig_base, args, cg.int_)
cg.add(var.set_trigger_base(template_))
if trig_keep := config.get(CONF_TRIGGER_KEEP):
template_ = await cg.templatable(trig_keep, args, cg.int32)
template_ = await cg.templatable(trig_keep, args, cg.int_)
cg.add(var.set_trigger_keep(template_))
if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None:
+1 -1
View File
@@ -220,7 +220,7 @@ async def to_code(config):
data = _get_data()
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.1.1")
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
if not data.flac_support:
@@ -0,0 +1,163 @@
#include "audio_http_media_source.h"
#ifdef USE_ESP32
#include "esphome/core/log.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <algorithm>
namespace esphome::audio_http {
static const char *const TAG = "audio_http_media_source";
// Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers.
static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder
static constexpr uint32_t HTTP_TIMEOUT_MS = 5000; // HTTP connect/read timeout
static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blocking time per on_audio_write() call
static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer
static constexpr uint8_t READER_TASK_PRIORITY = 2;
static constexpr uint8_t DECODER_TASK_PRIORITY = 2;
static constexpr size_t READER_TASK_STACK_SIZE = 4096;
static constexpr size_t DECODER_TASK_STACK_SIZE = 5120;
static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20;
static constexpr const char *const HTTP_URI_PREFIX = "http://";
static constexpr const char *const HTTPS_URI_PREFIX = "https://";
void AudioHTTPMediaSource::dump_config() {
ESP_LOGCONFIG(TAG,
"Audio HTTP Media Source:\n"
" Buffer Size: %zu bytes\n"
" Decoder Task Stack in PSRAM: %s",
this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_));
}
void AudioHTTPMediaSource::setup() {
this->disable_loop();
micro_decoder::DecoderConfig config;
config.ring_buffer_size = this->buffer_size_;
// Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring
// while the decoder is still draining it, instead of oscillating between empty and full.
config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2);
config.http_timeout_ms = HTTP_TIMEOUT_MS;
config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS;
config.reader_write_timeout_ms = READER_WRITE_TIMEOUT_MS;
config.reader_priority = READER_TASK_PRIORITY;
config.decoder_priority = DECODER_TASK_PRIORITY;
config.reader_stack_size = READER_TASK_STACK_SIZE;
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
if (this->decoder_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate decoder");
this->mark_failed();
return;
}
this->decoder_->set_listener(this); // We inherit from micro_decoder::DecoderListener
}
void AudioHTTPMediaSource::loop() { this->decoder_->loop(); }
bool AudioHTTPMediaSource::can_handle(const std::string &uri) const {
return uri.starts_with(HTTP_URI_PREFIX) || uri.starts_with(HTTPS_URI_PREFIX);
}
// Called from the orchestrator's main loop, so no synchronization needed with loop()
bool AudioHTTPMediaSource::play_uri(const std::string &uri) {
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) {
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 "http://" or "https://"
if (!uri.starts_with(HTTP_URI_PREFIX) && !uri.starts_with(HTTPS_URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
if (this->decoder_->play_url(uri)) {
this->pause_.store(false, std::memory_order_relaxed);
this->enable_loop();
return true;
}
ESP_LOGE(TAG, "Failed to start playback of '%s'", uri.c_str());
return false;
}
// Called from the orchestrator's main loop, so no synchronization needed with loop()
void AudioHTTPMediaSource::handle_command(media_source::MediaSourceCommand command) {
switch (command) {
case media_source::MediaSourceCommand::STOP:
this->decoder_->stop();
break;
case media_source::MediaSourceCommand::PAUSE:
// Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state
// machine from getting stuck in PAUSED when no playback is active (which would block the
// next play_uri() call via its IDLE-state precondition).
if (this->get_state() != media_source::MediaSourceState::PLAYING)
break;
// PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily
// yields, which fills the ring buffer and applies back pressure that effectively pauses both
// the decoder and HTTP reader tasks.
this->set_state_(media_source::MediaSourceState::PAUSED);
this->pause_.store(true, std::memory_order_relaxed);
break;
case media_source::MediaSourceCommand::PLAY:
// Only resume from PAUSED; don't fabricate a PLAYING state from IDLE/ERROR.
if (this->get_state() != media_source::MediaSourceState::PAUSED)
break;
this->set_state_(media_source::MediaSourceState::PLAYING);
this->pause_.store(false, std::memory_order_relaxed);
break;
default:
break;
}
}
// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for
// being thread-safe with respect to its own audio writer.
size_t AudioHTTPMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) {
if (this->pause_.load(std::memory_order_relaxed)) {
vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS));
return 0;
}
return this->write_output(data, length, timeout_ms, this->stream_info_);
}
// Called from the decoder task before the first on_audio_write().
void AudioHTTPMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) {
this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate());
}
// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main
// loop thread and it's safe to call set_state_() directly.
void AudioHTTPMediaSource::on_state_change(micro_decoder::DecoderState state) {
switch (state) {
case micro_decoder::DecoderState::IDLE:
this->set_state_(media_source::MediaSourceState::IDLE);
this->disable_loop();
break;
case micro_decoder::DecoderState::PLAYING:
this->set_state_(media_source::MediaSourceState::PLAYING);
break;
case micro_decoder::DecoderState::FAILED:
this->set_state_(media_source::MediaSourceState::ERROR);
break;
default:
break;
}
}
} // namespace esphome::audio_http
#endif // USE_ESP32
@@ -0,0 +1,59 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/audio/audio.h"
#include "esphome/components/media_source/media_source.h"
#include "esphome/core/component.h"
#include <micro_decoder/decoder_source.h>
#include <micro_decoder/types.h>
#include <atomic>
#include <memory>
#include <string>
namespace esphome::audio_http {
// Inherits from two unrelated listener-style interfaces:
// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator
// (the orchestrator calls set_listener() on us with a MediaSourceListener*).
// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded
// audio and state changes (we call decoder_->set_listener(this) in setup()).
// The two set_listener() methods live on different base classes and serve opposite directions.
class AudioHTTPMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener {
public:
void setup() override;
void loop() override;
void dump_config() override;
void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
// 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;
// DecoderListener interface implementation
size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override;
void on_stream_info(const micro_decoder::AudioStreamInfo &info) override;
void on_state_change(micro_decoder::DecoderState state) override;
protected:
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
audio::AudioStreamInfo stream_info_;
size_t buffer_size_{50000};
// Written from the main loop in handle_command(), read from the decoder task in
// on_audio_write(). Must be atomic to avoid a data race.
std::atomic<bool> pause_{false};
bool decoder_task_stack_in_psram_{false};
};
} // namespace esphome::audio_http
#endif // USE_ESP32
@@ -0,0 +1,59 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import audio, esp32, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
CODEOWNERS = ["@kahrendt"]
AUTO_LOAD = ["audio"]
audio_http_ns = cg.esphome_ns.namespace("audio_http")
AudioHTTPMediaSource = audio_http_ns.class_(
"AudioHTTPMediaSource", cg.Component, media_source.MediaSource
)
def _request_micro_decoder(config: ConfigType) -> ConfigType:
audio.request_micro_decoder_support()
return config
def _validate_task_stack_in_psram(value: Any) -> bool:
# Only require the psram component when actually enabling PSRAM stacks; validating
# the boolean first means `false` doesn't trigger the requires_component check.
if value := cv.boolean(value):
return cv.requires_component(psram.DOMAIN)(value)
return value
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioHTTPMediaSource,
)
.extend(
{
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
min=5000, max=1000000
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
_request_micro_decoder,
)
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 config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
+1 -1
View File
@@ -154,7 +154,7 @@ void BH1750Sensor::loop() {
break;
}
ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx);
ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx);
this->status_clear_warning();
this->publish_state(lx);
this->state_ = IDLE;
+2 -1
View File
@@ -62,6 +62,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -624,7 +625,7 @@ async def setup_binary_sensor_core_(var, config):
async def register_binary_sensor(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_binary_sensor(var))
queue_entity_register("binary_sensor", config)
CORE.register_platform_component("binary_sensor", var)
await setup_binary_sensor_core_(var, config)
+498 -2
View File
@@ -16,6 +16,7 @@ from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
)
@@ -24,16 +25,32 @@ BK72XX_BOARDS = {
"name": "WB2L_M1 Wi-Fi Module",
"family": FAMILY_BK7231N,
},
"xh-wb3s": {
"name": "NiceMCU XH-WB3S",
"family": FAMILY_BK7238,
},
"cbu": {
"name": "CBU Wi-Fi Module",
"family": FAMILY_BK7231N,
},
"t1-u": {
"name": "T1-U Wi-Fi Module",
"family": FAMILY_BK7238,
},
"generic-bk7238-tuya": {
"name": "Generic - BK7238 (Tuya T1)",
"family": FAMILY_BK7238,
},
"t1-m": {
"name": "T1-M Wi-Fi Module",
"family": FAMILY_BK7238,
},
"generic-bk7231t-qfn32-tuya": {
"name": "Generic - BK7231T (Tuya QFN32)",
"name": "Generic - BK7231T (Tuya)",
"family": FAMILY_BK7231T,
},
"generic-bk7231n-qfn32-tuya": {
"name": "Generic - BK7231N (Tuya QFN32)",
"name": "Generic - BK7231N (Tuya)",
"family": FAMILY_BK7231N,
},
"cb1s": {
@@ -64,6 +81,10 @@ BK72XX_BOARDS = {
"name": "Generic - BK7252",
"family": FAMILY_BK7251,
},
"t1-3s": {
"name": "T1-3S Wi-Fi Module",
"family": FAMILY_BK7238,
},
"wb2l": {
"name": "WB2L Wi-Fi Module",
"family": FAMILY_BK7231T,
@@ -80,6 +101,10 @@ BK72XX_BOARDS = {
"name": "CB2S Wi-Fi Module",
"family": FAMILY_BK7231N,
},
"generic-bk7238": {
"name": "Generic - BK7238",
"family": FAMILY_BK7238,
},
"wa2": {
"name": "WA2 Wi-Fi Module",
"family": FAMILY_BK7231Q,
@@ -100,6 +125,10 @@ BK72XX_BOARDS = {
"name": "WB3L Wi-Fi Module",
"family": FAMILY_BK7231T,
},
"t1-2s": {
"name": "T1-2S Wi-Fi Module",
"family": FAMILY_BK7238,
},
"wb2s": {
"name": "WB2S Wi-Fi Module",
"family": FAMILY_BK7231T,
@@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = {
"D12": 22,
"A0": 23,
},
"xh-wb3s": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
"SPI0_MOSI": 16,
"SPI0_SCK": 14,
"WIRE2_SCL_0": 15,
"WIRE2_SCL_1": 24,
"WIRE2_SDA_0": 17,
"WIRE2_SDA_1": 26,
"SERIAL1_RX": 10,
"SERIAL1_TX": 11,
"SERIAL2_RX": 1,
"SERIAL2_TX": 0,
"ADC1": 26,
"ADC2": 24,
"ADC3": 20,
"ADC4": 28,
"ADC5": 1,
"ADC6": 10,
"CS": 15,
"MISO": 17,
"MOSI": 16,
"P0": 0,
"P1": 1,
"P6": 6,
"P7": 7,
"P8": 8,
"P9": 9,
"P10": 10,
"P11": 11,
"P14": 14,
"P15": 15,
"P16": 16,
"P17": 17,
"P20": 20,
"P21": 21,
"P22": 22,
"P23": 23,
"P24": 24,
"P26": 26,
"P28": 28,
"PWM0": 6,
"PWM1": 7,
"PWM2": 8,
"PWM3": 9,
"PWM4": 24,
"PWM5": 26,
"RX1": 10,
"RX2": 1,
"SCK": 14,
"TX1": 11,
"TX2": 0,
"D0": 7,
"D1": 23,
"D2": 14,
"D3": 26,
"D4": 24,
"D5": 6,
"D6": 9,
"D7": 0,
"D8": 1,
"D9": 8,
"D10": 10,
"D11": 11,
"D12": 16,
"D13": 20,
"D14": 21,
"D15": 22,
"D16": 15,
"D17": 17,
"A0": 28,
"A1": 26,
"A2": 24,
"A3": 1,
"A4": 10,
"A5": 20,
},
"cbu": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
@@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = {
"D18": 21,
"A0": 23,
},
"t1-u": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
"SPI0_MOSI": 16,
"SPI0_SCK": 14,
"WIRE2_SCL_0": 15,
"WIRE2_SCL_1": 24,
"WIRE2_SDA_0": 17,
"WIRE2_SDA_1": 26,
"SERIAL1_RX": 10,
"SERIAL1_TX": 11,
"SERIAL2_RX": 1,
"SERIAL2_TX": 0,
"ADC1": 26,
"ADC2": 24,
"ADC3": 20,
"ADC4": 28,
"ADC5": 1,
"ADC6": 10,
"CS": 15,
"MISO": 17,
"MOSI": 16,
"P0": 0,
"P1": 1,
"P6": 6,
"P8": 8,
"P9": 9,
"P10": 10,
"P11": 11,
"P14": 14,
"P15": 15,
"P16": 16,
"P17": 17,
"P20": 20,
"P21": 21,
"P22": 22,
"P23": 23,
"P24": 24,
"P26": 26,
"P28": 28,
"PWM0": 6,
"PWM2": 8,
"PWM3": 9,
"PWM4": 24,
"PWM5": 26,
"RX1": 10,
"RX2": 1,
"SCK": 14,
"TX1": 11,
"TX2": 0,
"D0": 14,
"D1": 16,
"D2": 23,
"D3": 22,
"D4": 20,
"D5": 1,
"D6": 0,
"D7": 24,
"D8": 9,
"D9": 26,
"D10": 6,
"D11": 8,
"D12": 11,
"D13": 10,
"D14": 28,
"D15": 21,
"D16": 17,
"D17": 15,
"A0": 20,
"A1": 1,
"A2": 24,
"A3": 26,
"A4": 10,
"A5": 28,
},
"generic-bk7238-tuya": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
"SPI0_MOSI": 16,
"SPI0_SCK": 14,
"WIRE2_SCL_0": 15,
"WIRE2_SCL_1": 24,
"WIRE2_SDA_0": 17,
"WIRE2_SDA_1": 26,
"SERIAL1_RX": 10,
"SERIAL1_TX": 11,
"SERIAL2_RX": 1,
"SERIAL2_TX": 0,
"ADC1": 26,
"ADC2": 24,
"ADC3": 20,
"ADC4": 28,
"ADC5": 1,
"ADC6": 10,
"CS": 15,
"MISO": 17,
"MOSI": 16,
"P0": 0,
"P1": 1,
"P6": 6,
"P7": 7,
"P8": 8,
"P9": 9,
"P10": 10,
"P11": 11,
"P14": 14,
"P15": 15,
"P16": 16,
"P17": 17,
"P20": 20,
"P21": 21,
"P22": 22,
"P23": 23,
"P24": 24,
"P26": 26,
"P28": 28,
"PWM0": 6,
"PWM1": 7,
"PWM2": 8,
"PWM3": 9,
"PWM4": 24,
"PWM5": 26,
"RX1": 10,
"RX2": 1,
"SCK": 14,
"TX1": 11,
"TX2": 0,
"D0": 0,
"D1": 1,
"D2": 6,
"D3": 7,
"D4": 8,
"D5": 9,
"D6": 10,
"D7": 11,
"D8": 14,
"D9": 15,
"D10": 16,
"D11": 17,
"D12": 20,
"D13": 21,
"D14": 22,
"D15": 23,
"D16": 24,
"D17": 26,
"D18": 28,
"A0": 1,
"A1": 10,
"A2": 20,
"A3": 24,
"A4": 26,
"A5": 28,
},
"t1-m": {
"WIRE2_SCL": 24,
"WIRE2_SDA": 26,
"SERIAL1_RX": 10,
"SERIAL1_TX": 11,
"SERIAL2_RX": 1,
"SERIAL2_TX": 0,
"ADC1": 26,
"ADC2": 24,
"ADC5": 1,
"ADC6": 10,
"P0": 0,
"P1": 1,
"P6": 6,
"P8": 8,
"P9": 9,
"P10": 10,
"P11": 11,
"P24": 24,
"P26": 26,
"PWM0": 6,
"PWM2": 8,
"PWM3": 9,
"PWM4": 24,
"PWM5": 26,
"RX1": 10,
"RX2": 1,
"SCL2": 24,
"SDA2": 26,
"TX1": 11,
"TX2": 0,
"D0": 26,
"D1": 6,
"D2": 8,
"D3": 1,
"D4": 10,
"D5": 11,
"D6": 9,
"D7": 24,
"D11": 0,
"A0": 26,
"A1": 10,
"A2": 1,
"A3": 24,
},
"generic-bk7231t-qfn32-tuya": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
@@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = {
"A6": 12,
"A7": 13,
},
"t1-3s": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
"SPI0_MOSI": 16,
"SPI0_SCK": 14,
"WIRE2_SCL_0": 15,
"WIRE2_SCL_1": 24,
"WIRE2_SDA_0": 17,
"WIRE2_SDA_1": 26,
"SERIAL1_RX": 10,
"SERIAL1_TX": 11,
"SERIAL2_RX": 1,
"SERIAL2_TX": 0,
"ADC1": 26,
"ADC2": 24,
"ADC3": 20,
"ADC5": 1,
"ADC6": 10,
"CS": 15,
"MISO": 17,
"MOSI": 16,
"P0": 0,
"P1": 1,
"P6": 6,
"P8": 8,
"P9": 9,
"P10": 10,
"P11": 11,
"P14": 14,
"P15": 15,
"P16": 16,
"P17": 17,
"P20": 20,
"P22": 22,
"P23": 23,
"P24": 24,
"P26": 26,
"PWM0": 6,
"PWM2": 8,
"PWM3": 9,
"PWM4": 24,
"PWM5": 26,
"RX1": 10,
"RX2": 1,
"SCK": 14,
"TX1": 11,
"TX2": 0,
"D0": 20,
"D1": 22,
"D2": 6,
"D3": 8,
"D4": 9,
"D5": 23,
"D6": 0,
"D7": 1,
"D8": 24,
"D9": 26,
"D10": 10,
"D11": 11,
"D12": 17,
"D13": 16,
"D14": 15,
"D15": 14,
"A0": 20,
"A1": 1,
"A2": 24,
"A3": 26,
"A4": 10,
},
"wb2l": {
"WIRE1_SCL": 20,
"WIRE1_SDA": 21,
@@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = {
"D10": 21,
"A0": 23,
},
"generic-bk7238": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
"SPI0_MOSI": 16,
"SPI0_SCK": 14,
"WIRE2_SCL_0": 15,
"WIRE2_SCL_1": 24,
"WIRE2_SDA_0": 17,
"WIRE2_SDA_1": 26,
"SERIAL1_RX": 10,
"SERIAL1_TX": 11,
"SERIAL2_RX": 1,
"SERIAL2_TX": 0,
"ADC1": 26,
"ADC2": 24,
"ADC3": 20,
"ADC4": 28,
"ADC5": 1,
"ADC6": 10,
"CS": 15,
"MISO": 17,
"MOSI": 16,
"P0": 0,
"P1": 1,
"P6": 6,
"P7": 7,
"P8": 8,
"P9": 9,
"P10": 10,
"P11": 11,
"P14": 14,
"P15": 15,
"P16": 16,
"P17": 17,
"P20": 20,
"P21": 21,
"P22": 22,
"P23": 23,
"P24": 24,
"P26": 26,
"P28": 28,
"PWM0": 6,
"PWM1": 7,
"PWM2": 8,
"PWM3": 9,
"PWM4": 24,
"PWM5": 26,
"RX1": 10,
"RX2": 1,
"SCK": 14,
"TX1": 11,
"TX2": 0,
"D0": 0,
"D1": 1,
"D2": 6,
"D3": 7,
"D4": 8,
"D5": 9,
"D6": 10,
"D7": 11,
"D8": 14,
"D9": 15,
"D10": 16,
"D11": 17,
"D12": 20,
"D13": 21,
"D14": 22,
"D15": 23,
"D16": 24,
"D17": 26,
"D18": 28,
"A0": 1,
"A1": 10,
"A2": 20,
"A3": 24,
"A4": 26,
"A5": 28,
},
"wa2": {
"WIRE1_SCL": 20,
"WIRE1_SDA": 21,
@@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = {
"D15": 1,
"A0": 23,
},
"t1-2s": {
"WIRE2_SCL": 24,
"WIRE2_SDA": 26,
"SERIAL1_RX": 10,
"SERIAL1_TX": 11,
"SERIAL2_RX": 1,
"SERIAL2_TX": 0,
"ADC1": 26,
"ADC2": 24,
"ADC5": 1,
"ADC6": 10,
"P0": 0,
"P1": 1,
"P6": 6,
"P8": 8,
"P9": 9,
"P10": 10,
"P11": 11,
"P24": 24,
"P26": 26,
"PWM0": 6,
"PWM2": 8,
"PWM3": 9,
"PWM4": 24,
"PWM5": 26,
"RX1": 10,
"RX2": 1,
"SCL2": 24,
"SDA2": 26,
"TX1": 11,
"TX2": 0,
"D0": 26,
"D1": 6,
"D2": 8,
"D3": 1,
"D4": 10,
"D5": 11,
"D6": 9,
"D7": 24,
"D11": 0,
"A0": 26,
"A1": 10,
"A2": 1,
"A3": 24,
},
"wb2s": {
"WIRE1_SCL": 20,
"WIRE1_SDA": 21,
@@ -30,19 +30,6 @@ void BluetoothProxy::setup() {
this->configured_scan_active_ = this->parent_->get_scan_active();
this->parent_->add_scanner_state_listener(this);
this->set_interval(100, [this]() {
if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
this->flush_pending_advertisements_();
return;
}
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->get_address() != 0 && !connection->disconnect_pending()) {
connection->disconnect();
}
}
});
}
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
@@ -133,6 +120,25 @@ void BluetoothProxy::dump_config() {
YESNO(this->active_), this->connection_count_);
}
void BluetoothProxy::loop() {
// Run advertisement flush / connection cleanup every 100ms
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_advertisement_flush_time_ < 100)
return;
this->last_advertisement_flush_time_ = now;
if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
this->flush_pending_advertisements_();
return;
}
for (uint8_t i = 0; i < this->connection_count_; i++) {
auto *connection = this->connections_[i];
if (connection->get_address() != 0 && !connection->disconnect_pending()) {
connection->disconnect();
}
}
}
esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() {
return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS;
}
@@ -201,7 +207,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE);
this->log_connection_info_(connection, "v3 without cache");
}
uint64_to_bd_addr(msg.address, connection->remote_bda_);
connection->set_remote_addr_type(static_cast<esp_ble_addr_type_t>(msg.address_type));
connection->set_state(espbt::ClientState::DISCOVERED);
this->send_connections_free();
@@ -65,6 +65,7 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
void dump_config() override;
void setup() override;
void loop() override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
void register_connection(BluetoothConnection *connection) {
@@ -176,6 +177,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
// Group 3: 4-byte types
uint32_t last_advertisement_flush_time_{0};
// Pre-allocated response message - always ready to send
api::BluetoothConnectionsFreeResponse connections_free_response_;
+2 -1
View File
@@ -19,6 +19,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -101,7 +102,7 @@ async def setup_button_core_(var, config):
async def register_button(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_button(var))
queue_entity_register("button", config)
CORE.register_platform_component("button", var)
await setup_button_core_(var, config)
+6 -2
View File
@@ -49,7 +49,11 @@ from esphome.const import (
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
IS_PLATFORM_COMPONENT = True
@@ -442,7 +446,7 @@ async def setup_climate_core_(var, config):
async def register_climate(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_climate(var))
queue_entity_register("climate", config)
CORE.register_platform_component("climate", var)
await setup_climate_core_(var, config)
+2 -1
View File
@@ -39,6 +39,7 @@ from esphome.const import (
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -232,7 +233,7 @@ async def setup_cover_core_(var, config):
async def register_cover(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_cover(var))
queue_entity_register("cover", config)
CORE.register_platform_component("cover", var)
await setup_cover_core_(var, config)
+6 -2
View File
@@ -22,7 +22,11 @@ from esphome.const import (
CONF_YEAR,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@rfdarter", "@jesserockz"]
@@ -160,7 +164,7 @@ async def register_datetime(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
entity_type = config[CONF_TYPE].lower()
cg.add(getattr(cg.App, f"register_{entity_type}")(var))
queue_entity_register(entity_type, config)
CORE.register_platform_component(entity_type, var)
await setup_datetime_core_(var, config)
+6 -2
View File
@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32S3,
get_esp32_variant,
)
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -33,6 +34,7 @@ from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_NRF52,
PlatformFramework,
)
from esphome.core import CORE
@@ -304,7 +306,7 @@ CONFIG_SCHEMA = cv.All(
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_NRF52]),
validate_config,
)
@@ -369,6 +371,8 @@ async def to_code(config):
if CONF_TOUCH_WAKEUP in config:
cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP]))
if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations:
zephyr_add_prj_conf("POWEROFF", True)
cg.add_define("USE_DEEP_SLEEP")
@@ -413,7 +417,7 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if CONF_SLEEP_DURATION in config:
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.int32)
template_ = await cg.templatable(config[CONF_SLEEP_DURATION], args, cg.uint32)
cg.add(var.set_sleep_duration(template_))
if CONF_UNTIL in config:
@@ -59,6 +59,8 @@ void DeepSleepComponent::deep_sleep_() {
lt_deep_sleep_enter();
}
bool DeepSleepComponent::should_teardown_() { return true; }
} // namespace esphome::deep_sleep
#endif // USE_BK72XX
@@ -9,11 +9,22 @@ static const char *const TAG = "deep_sleep";
// 5 seconds for deep sleep to ensure clean disconnect from Home Assistant
static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000;
bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
std::atomic<DeepSleepComponent *> global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void DeepSleepComponent::setup() {
#ifdef USE_ZEPHYR
k_sem_init(&this->wakeup_sem_, 0, 1);
#endif
global_has_deep_sleep = true;
this->schedule_sleep_();
// It can be used from another thread for waking up the device.
// It should be called as last item in setup.
global_deep_sleep.store(this);
}
void DeepSleepComponent::schedule_sleep_() {
this->next_enter_deep_sleep_ = false;
const optional<uint32_t> run_duration = get_run_duration_();
if (run_duration.has_value()) {
ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration);
@@ -58,13 +69,17 @@ void DeepSleepComponent::begin_sleep(bool manual) {
if (this->sleep_duration_.has_value()) {
ESP_LOGI(TAG, "Sleeping for %" PRId64 "us", *this->sleep_duration_);
}
App.run_safe_shutdown_hooks();
// It's critical to teardown components cleanly for deep sleep to ensure
// Home Assistant sees a clean disconnect instead of marking the device unavailable
App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS);
App.run_powerdown_hooks();
if (this->should_teardown_()) {
App.run_safe_shutdown_hooks();
// It's critical to teardown components cleanly for deep sleep to ensure
// Home Assistant sees a clean disconnect instead of marking the device unavailable
App.teardown_components(TEARDOWN_TIMEOUT_DEEP_SLEEP_MS);
App.run_powerdown_hooks();
}
this->deep_sleep_();
this->schedule_sleep_();
}
float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; }
@@ -4,6 +4,7 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <atomic>
#ifdef USE_ESP32
#include <esp_sleep.h>
@@ -14,6 +15,10 @@
#include "esphome/core/time.h"
#endif
#ifdef USE_ZEPHYR
#include <zephyr/kernel.h>
#endif
#include <cinttypes>
namespace esphome {
@@ -120,6 +125,9 @@ class DeepSleepComponent : public Component {
void prevent_deep_sleep();
void allow_deep_sleep();
#ifdef USE_ZEPHYR
void wakeup();
#endif
protected:
// Returns nullopt if no run duration is set. Otherwise, returns the run
@@ -129,6 +137,8 @@ class DeepSleepComponent : public Component {
void dump_config_platform_();
bool prepare_to_sleep_();
void deep_sleep_();
void schedule_sleep_();
bool should_teardown_();
#ifdef USE_BK72XX
bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const;
@@ -157,6 +167,9 @@ class DeepSleepComponent : public Component {
optional<uint32_t> run_duration_;
bool next_enter_deep_sleep_{false};
bool prevent_{false};
#ifdef USE_ZEPHYR
k_sem wakeup_sem_;
#endif
};
extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -243,5 +256,8 @@ template<typename... Ts> class AllowDeepSleepAction : public Action<Ts...>, publ
void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); }
};
extern std::atomic<DeepSleepComponent *>
global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace deep_sleep
} // namespace esphome
@@ -165,6 +165,8 @@ void DeepSleepComponent::deep_sleep_() {
esp_deep_sleep_start();
}
bool DeepSleepComponent::should_teardown_() { return true; }
} // namespace deep_sleep
} // namespace esphome
#endif // USE_ESP32
@@ -18,6 +18,8 @@ void DeepSleepComponent::deep_sleep_() {
ESP.deepSleep(this->sleep_duration_.value_or(0)); // NOLINT(readability-static-accessed-through-instance)
}
bool DeepSleepComponent::should_teardown_() { return true; }
} // namespace deep_sleep
} // namespace esphome
#endif
@@ -0,0 +1,60 @@
#include "deep_sleep_component.h"
#ifdef USE_ZEPHYR
#include "esphome/core/log.h"
#include <zephyr/sys/poweroff.h>
#include <zephyr/kernel.h>
#include <zephyr/stats/stats.h>
#include <zephyr/pm/pm.h>
namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep";
void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); }
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
bool DeepSleepComponent::prepare_to_sleep_() { return true; }
void DeepSleepComponent::deep_sleep_() {
k_timeout_t sleep_duration = K_FOREVER;
if (this->sleep_duration_.has_value()) {
sleep_duration = K_USEC(*this->sleep_duration_);
} else {
#ifndef USE_ZIGBEE
// the device can be woken up through one of the following signals:
// - The DETECT signal, optionally generated by the GPIO peripheral.
// - The ANADETECT signal, optionally generated by the LPCOMP module.
// - The SENSE signal, optionally generated by the NFC module to wake-on-field.
// - Detecting a valid USB voltage on the VBUS pin (VBUS,DETECT).
// - A reset.
//
// The system is reset when it wakes up from System OFF mode.
sys_poweroff();
#endif
}
// It might wake up immediately if k_sem_give was called again after wake up
int ret = k_sem_take(&this->wakeup_sem_, sleep_duration);
if (ret == 0) {
ESP_LOGD(TAG, "Woken up by another thread");
} else {
ESP_LOGD(TAG, "Timeout expired (normal sleep)");
}
}
bool DeepSleepComponent::should_teardown_() {
if (this->sleep_duration_.has_value()) {
return false;
}
#ifdef USE_ZIGBEE
return false;
#else
return true;
#endif
}
} // namespace esphome::deep_sleep
#endif
+5
View File
@@ -1724,6 +1724,11 @@ async def to_code(config):
CORE.relative_internal_path(".espressif")
)
# Both ESP-IDF and ESP32 Arduino builds generate IDF app metadata. Keep
# volatile build path/time data out of the binary so equivalent projects can
# produce reproducible outputs and downstream tooling can reuse artifacts.
add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True)
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-DUSE_ESP_IDF")
cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF")
+17 -1
View File
@@ -18,6 +18,12 @@ struct NVSData {
static std::vector<NVSData> s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// open() runs from app_main() before the logger is initialized, so any failure
// must be deferred until after global_logger is set. This is emitted from the
// first make_preference() call, which runs from the generated setup() after
// log->pre_setup() has run at EARLY_INIT priority.
static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) {
// try find in pending saves and update that
for (auto &obj : s_pending_save) {
@@ -70,12 +76,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) {
}
void ESP32Preferences::open() {
// Runs from app_main() before the logger is initialized; any logging here
// must be deferred. See s_open_err and make_preference() below.
nvs_flash_init();
esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle);
if (err == 0)
return;
ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err));
s_open_err = err;
nvs_flash_deinit();
nvs_flash_erase();
nvs_flash_init();
@@ -87,6 +95,14 @@ void ESP32Preferences::open() {
}
ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) {
if (s_open_err != ESP_OK) {
if (this->nvs_handle == 0) {
ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err));
} else {
ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err));
}
s_open_err = ESP_OK;
}
auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory)
pref->nvs_handle = this->nvs_handle;
pref->key = type;
@@ -166,8 +166,9 @@ void ESP32BLETracker::loop() {
ClientStateCounts counts = this->count_client_states_();
if (counts != this->client_state_counts_) {
this->client_state_counts_ = counts;
ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting,
this->client_state_counts_.discovered, this->client_state_counts_.disconnecting);
ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d, active: %d",
this->client_state_counts_.connecting, this->client_state_counts_.discovered,
this->client_state_counts_.disconnecting, this->client_state_counts_.active);
}
// Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set
@@ -190,10 +191,18 @@ void ESP32BLETracker::loop() {
*/
// Start scan: reached when scanner_state_ becomes IDLE (via set_scanner_state_()) and
// all clients are idle (their state changes increment version when they finish)
// no clients are in the transient CONNECTING / DISCOVERED / DISCONNECTING states
// (their state changes increment version when they finish). CONNECTED / ESTABLISHED
// clients do NOT block this branch — the coex revert below has its own active-count gate.
if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) {
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
this->update_coex_preference_(false);
// Only revert to BALANCE when no connections are active. Established connections
// continue to need PREFER_BT so peer GATT responses can reach us while WiFi traffic
// (advertisement upload, log streaming) competes for the shared radio. Reverting too
// early causes Bluedroid to time out at ~20s and synthesize status=133.
if (!counts.active) {
this->update_coex_preference_(false);
}
#endif
if (this->scan_continuous_) {
this->start_scan_(false); // first = false
@@ -701,9 +710,10 @@ void ESP32BLETracker::dump_config() {
this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
ESP_LOGCONFIG(TAG,
" Scanner State: %s\n"
" Connecting: %d, discovered: %d, disconnecting: %d",
" Connecting: %d, discovered: %d, disconnecting: %d, active: %d",
this->scanner_state_to_string_(this->scanner_state_), this->client_state_counts_.connecting,
this->client_state_counts_.discovered, this->client_state_counts_.disconnecting);
this->client_state_counts_.discovered, this->client_state_counts_.disconnecting,
this->client_state_counts_.active);
if (this->scan_start_fail_count_) {
ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_);
}
@@ -160,9 +160,13 @@ struct ClientStateCounts {
uint8_t connecting = 0;
uint8_t discovered = 0;
uint8_t disconnecting = 0;
// CONNECTED + ESTABLISHED clients. Tracked so coex stays at PREFER_BT
// while active connections may still need to send/receive GATT traffic.
uint8_t active = 0;
bool operator==(const ClientStateCounts &other) const {
return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting;
return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting &&
active == other.active;
}
bool operator!=(const ClientStateCounts &other) const { return !(*this == other); }
@@ -381,6 +385,10 @@ class ESP32BLETracker : public Component,
case ClientState::CONNECTING:
counts.connecting++;
break;
case ClientState::CONNECTED:
case ClientState::ESTABLISHED:
counts.active++;
break;
default:
break;
}
@@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() {
// Do initial oneshot scans to populate baseline values
for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) {
err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS);
App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe
if (err != ESP_OK) {
ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err));
}
+13 -1
View File
@@ -292,6 +292,7 @@ void ESPHomeOTAComponent::handle_data_() {
bool update_started = false;
size_t total = 0;
uint32_t last_progress = 0;
uint32_t last_data_ms = 0;
uint8_t buf[OTA_BUFFER_SIZE];
char *sbuf = reinterpret_cast<char *>(buf);
size_t ota_size;
@@ -350,8 +351,18 @@ void ESPHomeOTAComponent::handle_data_() {
// Acknowledge MD5 OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
// Track when we last received data so a silently-vanished peer (no FIN/RST
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
// can't wedge the device indefinitely. Without this, the loop only exits
// on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP
// TCP keepalive isn't enabled here.
last_data_ms = millis();
while (total < ota_size) {
// TODO: timeout check
if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) {
ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA);
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
size_t remaining = ota_size - total;
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
ssize_t read = this->client_->read(buf, requested);
@@ -369,6 +380,7 @@ void ESPHomeOTAComponent::handle_data_() {
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
last_data_ms = millis();
error_code = this->backend_->write(buf, read);
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGW(TAG, "Flash write err %d", error_code);
+2 -1
View File
@@ -19,6 +19,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -108,7 +109,7 @@ async def setup_event_core_(var, config, *, event_types: list[str]):
async def register_event(var, config, *, event_types: list[str]):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_event(var))
queue_entity_register("event", config)
CORE.register_platform_component("event", var)
await setup_event_core_(var, config, event_types=event_types)
@@ -1,5 +1,6 @@
import logging
from pathlib import Path
from typing import Any
from esphome import git, loader
import esphome.config_validation as cv
@@ -17,7 +18,7 @@ from esphome.const import (
TYPE_GIT,
TYPE_LOCAL,
)
from esphome.core import CORE
from esphome.core import CORE, TimePeriodSeconds
_LOGGER = logging.getLogger(__name__)
@@ -35,17 +36,15 @@ CONFIG_SCHEMA = cv.ensure_list(
)
async def to_code(config):
async def to_code(config: dict[str, Any]) -> None:
pass
def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str:
# When skip_update is True, use NEVER_REFRESH to prevent updates
actual_refresh = git.NEVER_REFRESH if skip_update else refresh
def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path:
repo_dir, _ = git.clone_or_update(
url=config[CONF_URL],
ref=config.get(CONF_REF),
refresh=actual_refresh,
refresh=refresh,
domain=DOMAIN,
username=config.get(CONF_USERNAME),
password=config.get(CONF_PASSWORD),
@@ -72,12 +71,12 @@ def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str
return components_dir
def _process_single_config(config: dict, skip_update: bool = False):
def _process_single_config(config: dict[str, Any]) -> None:
conf = config[CONF_SOURCE]
if conf[CONF_TYPE] == TYPE_GIT:
with cv.prepend_path([CONF_SOURCE]):
components_dir = _process_git_config(
config[CONF_SOURCE], config[CONF_REFRESH], skip_update
config[CONF_SOURCE], config[CONF_REFRESH]
)
elif conf[CONF_TYPE] == TYPE_LOCAL:
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
@@ -107,7 +106,7 @@ def _process_single_config(config: dict, skip_update: bool = False):
loader.install_meta_finder(components_dir, allowed_components=allowed_components)
def do_external_components_pass(config: dict, skip_update: bool = False) -> None:
def do_external_components_pass(config: dict[str, Any]) -> None:
conf = config.get(DOMAIN)
if conf is None:
return
@@ -115,4 +114,4 @@ def do_external_components_pass(config: dict, skip_update: bool = False) -> None
conf = CONFIG_SCHEMA(conf)
for i, c in enumerate(conf):
with cv.prepend_path(i):
_process_single_config(c, skip_update)
_process_single_config(c)
+6 -2
View File
@@ -32,7 +32,11 @@ from esphome.const import (
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
IS_PLATFORM_COMPONENT = True
@@ -292,7 +296,7 @@ async def setup_fan_core_(var, config):
async def register_fan(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_fan(var))
queue_entity_register("fan", config)
CORE.register_platform_component("fan", var)
await setup_fan_core_(var, config)
+10 -10
View File
@@ -3,11 +3,12 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome {
namespace feedback {
namespace esphome::feedback {
static const char *const TAG = "feedback.cover";
static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1;
using namespace esphome::cover;
void FeedbackCover::setup() {
@@ -37,7 +38,7 @@ void FeedbackCover::setup() {
}
#endif
this->last_recompute_time_ = this->start_dir_time_ = millis();
this->last_recompute_time_ = this->start_dir_time_ = App.get_loop_component_start_time();
}
CoverTraits FeedbackCover::get_traits() {
@@ -135,7 +136,7 @@ void FeedbackCover::set_close_endstop(binary_sensor::BinarySensor *close_endstop
#endif
void FeedbackCover::endstop_reached_(bool open_endstop) {
const uint32_t now = millis();
const uint32_t now = App.get_loop_component_start_time();
this->position = open_endstop ? COVER_OPEN : COVER_CLOSED;
@@ -174,7 +175,7 @@ void FeedbackCover::set_current_operation_(cover::CoverOperation operation, bool
if (!is_triggered || (this->open_feedback_ == nullptr || this->close_feedback_ == nullptr))
#endif
{
auto now = millis();
const uint32_t now = App.get_loop_component_start_time();
this->current_operation = operation;
this->start_dir_time_ = this->last_recompute_time_ = now;
this->publish_state();
@@ -306,7 +307,7 @@ void FeedbackCover::control(const CoverCall &call) {
void FeedbackCover::stop_prev_trigger_() {
if (this->direction_change_waittime_.has_value()) {
this->cancel_timeout("direction_change");
this->cancel_timeout(DIRECTION_CHANGE_TIMEOUT_ID);
}
if (this->prev_command_trigger_ != nullptr) {
this->prev_command_trigger_->stop_action();
@@ -377,7 +378,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) {
ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str());
this->start_direction_(COVER_OPERATION_IDLE);
this->set_timeout("direction_change", *this->direction_change_waittime_,
this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, *this->direction_change_waittime_,
[this, dir]() { this->start_direction_(dir); });
} else {
@@ -395,7 +396,7 @@ void FeedbackCover::recompute_position_() {
if (this->current_operation == COVER_OPERATION_IDLE)
return;
const uint32_t now = millis();
const uint32_t now = App.get_loop_component_start_time();
float dir;
float action_dur;
float min_pos;
@@ -451,5 +452,4 @@ void FeedbackCover::recompute_position_() {
this->last_recompute_time_ = now;
}
} // namespace feedback
} // namespace esphome
} // namespace esphome::feedback
+2 -4
View File
@@ -8,8 +8,7 @@
#endif
#include "esphome/components/cover/cover.h"
namespace esphome {
namespace feedback {
namespace esphome::feedback {
class FeedbackCover : public cover::Cover, public Component {
public:
@@ -85,5 +84,4 @@ class FeedbackCover : public cover::Cover, public Component {
uint32_t update_interval_{1000};
};
} // namespace feedback
} // namespace esphome
} // namespace esphome::feedback
+2 -7
View File
@@ -8,7 +8,6 @@
#include <csignal>
#include <sched.h>
#include <time.h>
#include <cmath>
#include <cstdlib>
namespace {
@@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); }
uint32_t IRAM_ATTR HOT millis() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
time_t seconds = spec.tv_sec;
uint32_t ms = round(spec.tv_nsec / 1e6);
return ((uint32_t) seconds) * 1000U + ms;
return static_cast<uint32_t>(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000);
}
uint64_t millis_64() {
struct timespec spec;
@@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) {
uint32_t IRAM_ATTR HOT micros() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
time_t seconds = spec.tv_sec;
uint32_t us = round(spec.tv_nsec / 1e3);
return ((uint32_t) seconds) * 1000000U + us;
return static_cast<uint32_t>(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000);
}
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) {
struct timespec ts;
+14 -7
View File
@@ -744,21 +744,28 @@ async def write_image(config, all_frames=False):
if frame_count <= 1:
_LOGGER.warning("Image file %s has no animation frames", path)
total_rows = height * frame_count
encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha)
if byte_order := config.get(CONF_BYTE_ORDER):
# Check for valid type has already been done in validate_settings
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
# Encode each frame with its own encoder and concatenate. This keeps every
# frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane]
# per frame) so animation frame stepping in image.cpp / animation.cpp stays
# correct without needing to know the total frame count.
byte_order = config.get(CONF_BYTE_ORDER)
combined_data: list[int] = []
encoder: ImageEncoder | None = None
for frame_index in range(frame_count):
image.seek(frame_index)
encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha)
if byte_order is not None:
# Check for valid type has already been done in validate_settings
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
pixels = encoder.convert(image.resize((width, height)), path).getdata()
for row in range(height):
for col in range(width):
encoder.encode(pixels[row * width + col])
encoder.end_row()
encoder.end_image()
encoder.end_image()
combined_data.extend(encoder.data)
rhs = [HexInt(x) for x in encoder.data]
rhs = [HexInt(x) for x in combined_data]
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
image_type = get_image_type_enum(type)
trans_value = get_transparency_enum(encoder.transparency)
+2 -2
View File
@@ -12,7 +12,7 @@ import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.core.entity_helpers import setup_entity
from esphome.core.entity_helpers import queue_entity_register, setup_entity
from esphome.coroutine import CoroPriority
from esphome.types import ConfigType
@@ -54,8 +54,8 @@ async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None:
"""Register an infrared device with the core."""
cg.add_define("USE_IR_RF")
await cg.register_component(var, config)
queue_entity_register("infrared", config)
await setup_infrared_core_(var, config)
cg.add(cg.App.register_infrared(var))
CORE.register_platform_component("infrared", var)
+111 -1
View File
@@ -1,13 +1,73 @@
#include "ir_rf_proxy.h"
#include <cinttypes>
#include "esphome/core/log.h"
namespace esphome::ir_rf_proxy {
static const char *const TAG = "ir_rf_proxy";
// ========== Shared transmit helper ==========
// Static template: all instantiations occur in this translation unit.
template<typename CallT>
static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency,
const CallT &call) {
if (transmitter == nullptr) {
ESP_LOGW(TAG, "No transmitter configured");
return;
}
if (!call.has_raw_timings()) {
ESP_LOGE(TAG, "No raw timings provided");
return;
}
auto transmit_call = transmitter->transmit();
auto *transmit_data = transmit_call.get_data();
transmit_data->set_carrier_frequency(carrier_frequency);
if (call.is_packed()) {
transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(),
call.get_packed_count());
ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(),
call.get_repeat_count());
} else if (call.is_base64url()) {
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
ESP_LOGE(TAG, "Invalid base64url data");
return;
}
constexpr int32_t max_timing_us = 500000;
for (int32_t timing : transmit_data->get_data()) {
int32_t abs_timing = timing < 0 ? -timing : timing;
if (abs_timing > max_timing_us) {
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
return;
}
}
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
call.get_repeat_count());
} else {
transmit_data->set_data(call.get_raw_timings());
ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(),
call.get_repeat_count());
}
if (call.get_repeat_count() > 0) {
transmit_call.set_send_times(call.get_repeat_count());
}
transmit_call.perform();
}
// ========== IrRfProxy (Infrared platform) ==========
#ifdef USE_IR_RF
void IrRfProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"IR/RF Proxy '%s'\n"
"IR Proxy '%s'\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
@@ -20,4 +80,54 @@ void IrRfProxy::dump_config() {
}
}
void IrRfProxy::control(const infrared::InfraredCall &call) {
uint32_t carrier = call.get_carrier_frequency().value_or(0);
transmit_raw_timings(this->transmitter_, carrier, call);
}
#endif // USE_IR_RF
// ========== RfProxy (Radio Frequency platform) ==========
#ifdef USE_RADIO_FREQUENCY
void RfProxy::setup() {
this->traits_.set_supports_transmitter(this->transmitter_ != nullptr);
this->traits_.set_supports_receiver(this->receiver_ != nullptr);
// remote_transmitter/receiver always uses OOK (on-off keying)
this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK);
if (this->receiver_ != nullptr) {
this->receiver_->register_listener(this);
}
}
void RfProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"RF Proxy '%s'\n"
" Backend: remote_transmitter/receiver\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
YESNO(this->traits_.get_supports_receiver()));
const auto &traits = this->traits_;
if (traits.get_frequency_min_hz() > 0) {
if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) {
ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f);
} else {
ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f,
traits.get_frequency_max_hz() / 1e6f);
}
}
}
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
// RF: no IR carrier modulation
transmit_raw_timings(this->transmitter_, 0, call);
}
#endif // USE_RADIO_FREQUENCY
} // namespace esphome::ir_rf_proxy
@@ -4,10 +4,19 @@
// without following the normal breaking changes policy. Use at your own risk.
// Once the API is considered stable, this warning will be removed.
#include "esphome/components/remote_base/remote_base.h"
#ifdef USE_IR_RF
#include "esphome/components/infrared/infrared.h"
#endif
#ifdef USE_RADIO_FREQUENCY
#include "esphome/components/radio_frequency/radio_frequency.h"
#endif
namespace esphome::ir_rf_proxy {
#ifdef USE_IR_RF
/// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend
class IrRfProxy : public infrared::Infrared {
public:
@@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared {
void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); }
protected:
void control(const infrared::InfraredCall &call) override;
// RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF
uint32_t frequency_khz_{0};
};
#endif // USE_IR_RF
#ifdef USE_RADIO_FREQUENCY
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend
class RfProxy : public radio_frequency::RadioFrequency {
public:
RfProxy() = default;
void setup() override;
void dump_config() override;
/// Set the remote transmitter component
void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; }
/// Set the remote receiver component
void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; }
/// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware)
void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); }
protected:
void control(const radio_frequency::RadioFrequencyCall &call) override;
remote_base::RemoteTransmitterBase *transmitter_{nullptr};
remote_base::RemoteReceiverBase *receiver_{nullptr};
};
#endif // USE_RADIO_FREQUENCY
} // namespace esphome::ir_rf_proxy
@@ -0,0 +1,68 @@
"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver)."""
import esphome.codegen as cg
from esphome.components import radio_frequency, remote_receiver, remote_transmitter
import esphome.config_validation as cv
from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["radio_frequency"]
RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency)
CONFIG_SCHEMA = cv.All(
radio_frequency.radio_frequency_schema(RfProxy).extend(
{
cv.Optional(CONF_FREQUENCY): cv.frequency,
cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id(
remote_receiver.RemoteReceiverComponent
),
cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id(
remote_transmitter.RemoteTransmitterComponent
),
}
),
cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID),
)
def _final_validate(config: ConfigType) -> None:
"""Validate that RF transmitters have carrier duty set to 100%."""
if CONF_REMOTE_TRANSMITTER_ID not in config:
return
transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID]
full_config = fv.full_config.get()
transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1]
transmitter_config = full_config.get_config_for_path(transmitter_path)
duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT)
if duty_percent is not None and duty_percent != 100:
raise cv.Invalid(
f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' "
"set to 100% for RF transmission. Dedicated RF hardware handles modulation; "
"applying a carrier duty cycle would corrupt the signal"
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
"""Code generation for remote_base radio frequency platform."""
var = await radio_frequency.new_radio_frequency(config)
if CONF_FREQUENCY in config:
cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY])))
if CONF_REMOTE_TRANSMITTER_ID in config:
transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID])
cg.add(var.set_transmitter(transmitter))
if CONF_REMOTE_RECEIVER_ID in config:
receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID])
cg.add(var.set_receiver(receiver))
+4
View File
@@ -58,6 +58,7 @@ COMPONENT_RTL87XX = "rtl87xx"
FAMILY_BK7231N = "BK7231N"
FAMILY_BK7231Q = "BK7231Q"
FAMILY_BK7231T = "BK7231T"
FAMILY_BK7238 = "BK7238"
FAMILY_BK7251 = "BK7251"
FAMILY_LN882H = "LN882H"
FAMILY_RTL8710B = "RTL8710B"
@@ -66,6 +67,7 @@ FAMILIES = [
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_BK7251,
FAMILY_LN882H,
FAMILY_RTL8710B,
@@ -75,6 +77,7 @@ FAMILY_FRIENDLY = {
FAMILY_BK7231N: "BK7231N",
FAMILY_BK7231Q: "BK7231Q",
FAMILY_BK7231T: "BK7231T",
FAMILY_BK7238: "BK7238",
FAMILY_BK7251: "BK7251",
FAMILY_LN882H: "LN882H",
FAMILY_RTL8710B: "RTL8710B",
@@ -84,6 +87,7 @@ FAMILY_COMPONENT = {
FAMILY_BK7231N: COMPONENT_BK72XX,
FAMILY_BK7231Q: COMPONENT_BK72XX,
FAMILY_BK7231T: COMPONENT_BK72XX,
FAMILY_BK7238: COMPONENT_BK72XX,
FAMILY_BK7251: COMPONENT_BK72XX,
FAMILY_LN882H: COMPONENT_LN882X,
FAMILY_RTL8710B: COMPONENT_RTL87XX,
+6 -2
View File
@@ -40,7 +40,11 @@ from esphome.const import (
CONF_WHITE,
)
from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -405,7 +409,7 @@ async def setup_light_core_(light_var, config, output_var):
async def register_light(output_var, config):
light_var = cg.new_Pvariable(config[CONF_ID], output_var)
cg.add(cg.App.register_light(light_var))
queue_entity_register("light", config)
CORE.register_platform_component("light", light_var)
await cg.register_component(light_var, config)
await setup_light_core_(light_var, config, output_var)
+6 -2
View File
@@ -13,7 +13,11 @@ from esphome.const import (
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@esphome/core"]
@@ -112,7 +116,7 @@ async def _setup_lock_core(var, config):
async def register_lock(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_lock(var))
queue_entity_register("lock", config)
CORE.register_platform_component("lock", var)
await _setup_lock_core(var, config)
+9 -8
View File
@@ -472,14 +472,15 @@ async def _late_logger_init(config: ConfigType) -> None:
# esphome implement own fatal error handler which save PC/LR before reset
zephyr_add_prj_conf("RESET_ON_FATAL_ERROR", False)
zephyr_add_prj_conf("THREAD_LOCAL_STORAGE", True)
if config[CONF_HARDWARE_UART] == UART0:
zephyr_add_overlay("""&uart0 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == UART1:
zephyr_add_overlay("""&uart1 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == USB_CDC:
cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC")
zephyr_add_prj_conf("UART_LINE_CTRL", True)
zephyr_add_cdc_acm(config, 0)
if has_serial_logging:
if config[CONF_HARDWARE_UART] == UART0:
zephyr_add_overlay("""&uart0 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == UART1:
zephyr_add_overlay("""&uart1 { status = "okay";};""")
if config[CONF_HARDWARE_UART] == USB_CDC:
cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC")
zephyr_add_prj_conf("UART_LINE_CTRL", True)
zephyr_add_cdc_acm(config, 0)
# Register at end for safe mode
await cg.register_component(log, config)
@@ -65,10 +65,12 @@ void Logger::pre_setup() {
break;
#ifdef USE_LOGGER_USB_CDC
case UART_SELECTION_USB_CDC:
#ifdef CONFIG_USB_DEVICE_STACK
uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(cdc_acm_uart0));
if (device_is_ready(uart_dev)) {
usb_enable(nullptr);
}
#endif
break;
#endif
}
@@ -4,15 +4,25 @@ from esphome.components.binary_sensor import (
new_binary_sensor,
)
import esphome.config_validation as cv
from esphome.const import CONF_STATE
from ..defines import CONF_WIDGET
from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lvgl_static
from ..types import LV_EVENT, lv_pseudo_button_t
from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant
from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static
from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t
from ..widgets import Widget, get_widgets, wait_for_widgets
STATE_PRESSED = "PRESSED"
STATE_CHECKED = "CHECKED"
BS_STATE = LvConstant(
"LV_STATE_",
STATE_PRESSED,
STATE_CHECKED,
)
CONFIG_SCHEMA = binary_sensor_schema(BinarySensor).extend(
{
cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t),
cv.Optional(CONF_STATE, default=STATE_PRESSED): BS_STATE.one_of,
}
)
@@ -22,16 +32,23 @@ async def to_code(config):
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
assert isinstance(widget, Widget)
state = await BS_STATE.process(config[CONF_STATE])
await wait_for_widgets()
async with LambdaContext(EVENT_ARG) as pressed_ctx:
pressed_ctx.add(sensor.publish_state(widget.is_pressed()))
is_pressed = str(state) == str(LV_STATE.PRESSED)
test_expr = widget.is_pressed() if is_pressed else widget.is_checked()
async with LambdaContext(EVENT_ARG) as test_ctx:
test_ctx.add(sensor.publish_state(test_expr))
async with LvContext() as ctx:
ctx.add(sensor.publish_initial_state(widget.is_pressed()))
ctx.add(sensor.publish_initial_state(test_expr))
if is_pressed:
events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED]
widget.add_flag(LV_OBJ_FLAG.CLICKABLE)
else:
events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT]
ctx.add(
lvgl_static.add_event_cb(
widget.obj,
await pressed_ctx.get_lambda(),
LV_EVENT.PRESSED,
LV_EVENT.RELEASED,
await test_ctx.get_lambda(),
*events,
)
)
+111 -3
View File
@@ -1,20 +1,32 @@
from collections.abc import Callable
from esphome import automation
import esphome.codegen as cg
from esphome.components import audio
import esphome.config_validation as cv
from esphome.const import (
CONF_ENTITY_CATEGORY,
CONF_FORMAT,
CONF_ICON,
CONF_ID,
CONF_NUM_CHANNELS,
CONF_ON_IDLE,
CONF_ON_STATE,
CONF_ON_TURN_OFF,
CONF_ON_TURN_ON,
CONF_SAMPLE_RATE,
CONF_VOLUME,
)
from esphome.core import CORE
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
inherit_property_from,
queue_entity_register,
setup_entity,
)
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObjClass
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
@@ -34,6 +46,102 @@ MEDIA_PLAYER_FORMAT_PURPOSE_ENUM = {
"announcement": MediaPlayerFormatPurpose.PURPOSE_ANNOUNCEMENT,
}
# Public API for external components. Do not remove.
FORMAT_MAPPING = {
"FLAC": "flac",
"MP3": "mp3",
"OPUS": "opus",
"WAV": "wav",
}
def build_supported_format_struct(
format_config: ConfigType, purpose: MockObj
) -> cg.StructInitializer:
"""Build a MediaPlayerSupportedFormat struct from a format config and purpose.
Public API for external components. Do not remove.
"""
args = [
MediaPlayerSupportedFormat,
("format", FORMAT_MAPPING[format_config[CONF_FORMAT]]),
("sample_rate", format_config[CONF_SAMPLE_RATE]),
("num_channels", format_config[CONF_NUM_CHANNELS]),
("purpose", purpose),
]
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
# if the number of bytes per sample is specified for MP3.
if format_config[CONF_FORMAT] != "MP3":
args.append(("sample_bytes", 2))
return cg.StructInitializer(*args)
def validate_preferred_format(
component_name: str, audio_device_key: str
) -> Callable[[ConfigType], ConfigType]:
"""Return a validator that inherits audio device settings and validates format constraints.
Public API for external components. Do not remove.
"""
def validator(config: ConfigType) -> ConfigType:
# Inherit settings from audio device if not manually set
inherit_property_from(CONF_NUM_CHANNELS, audio_device_key)(config)
inherit_property_from(CONF_SAMPLE_RATE, audio_device_key)(config)
# Opus only supports 48 kHz
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
# Validate the settings are compatible with the audio device
audio.final_validate_audio_schema(
component_name,
audio_device=audio_device_key,
bits_per_sample=16,
channels=config.get(CONF_NUM_CHANNELS),
sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
return config
return validator
def request_codecs_for_format_configs(
config: ConfigType, format_config_keys: list[str]
) -> None:
"""Scan format configs for configured formats and request the needed codec support.
If any config uses "NONE" (accepts any format), all codecs are requested.
Public API for external components. Do not remove.
"""
needed_formats: set[str] = set()
need_all = False
for key in format_config_keys:
if format_config := config.get(key):
fmt = format_config[CONF_FORMAT]
if fmt == "NONE":
need_all = True
else:
needed_formats.add(fmt)
if need_all:
audio.request_flac_support()
audio.request_mp3_support()
audio.request_opus_support()
else:
if "FLAC" in needed_formats:
audio.request_flac_support()
if "MP3" in needed_formats:
audio.request_mp3_support()
if "OPUS" in needed_formats:
audio.request_opus_support()
# Local config key constants
CONF_ANNOUNCEMENT = "announcement"
CONF_ON_PLAY = "on_play"
@@ -155,7 +263,7 @@ async def setup_media_player_core_(var, config):
async def register_media_player(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_media_player(var))
queue_entity_register("media_player", config)
CORE.register_platform_component("media_player", var)
await setup_media_player_core_(var, config)
@@ -352,7 +352,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
ESP_LOGD(TAG, "Setting temperature out-of-range: %.1f", target_temperature);
return;
}
this->status_.target_temperature = std::round(target_temperature);
this->status_.target_temperature = target_temperature;
this->pending_updates_.set(UpdateFlag::TEMPERATURE);
}
@@ -387,9 +387,9 @@ void MitsubishiCN105::apply_settings_() {
if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(this->status_.target_temperature * 2.0f + 128.0f);
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - this->status_.target_temperature);
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
@@ -16,6 +16,13 @@ namespace esphome::nextion {
static const char *const TAG = "nextion.upload.arduino";
static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16;
// Timeout for display acknowledgment during TFT upload (ms).
// A single value is used for all chunks; the happy path returns as soon as
// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field
// reports showed the previous 500ms steady-state value was too tight for
// some firmware variants.
static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000;
// Followed guide
// https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2
@@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) {
recv_string.clear();
this->write_array(buffer, buffer_size);
App.feed_wdt();
this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true);
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
this->content_length_ -= read_len;
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_,
EspClass::getFreeHeap());
this->upload_first_chunk_sent_ = true;
if (recv_string.empty()) {
ESP_LOGW(TAG, "No response from display during upload");
ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS);
allocator.deallocate(buffer, 4096);
buffer = nullptr;
return -1;
@@ -19,6 +19,13 @@ namespace esphome::nextion {
static const char *const TAG = "nextion.upload.esp32";
static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16;
// Timeout for display acknowledgment during TFT upload (ms).
// A single value is used for all chunks; the happy path returns as soon as
// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field
// reports showed the previous 500ms steady-state value was too tight for
// some firmware variants.
static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000;
// Followed guide
// https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2
@@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r
recv_string.clear();
this->write_array(buffer, buffer_size);
App.feed_wdt();
this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true);
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
this->content_length_ -= read_len;
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
#ifdef USE_PSRAM
@@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r
#endif
upload_first_chunk_sent_ = true;
if (recv_string.empty()) {
ESP_LOGW(TAG, "No response from display during upload");
ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS);
allocator.deallocate(buffer, 4096);
buffer = nullptr;
return -1;
+2 -1
View File
@@ -82,6 +82,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
setup_unit_of_measurement,
@@ -301,7 +302,7 @@ async def register_number(
):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_number(var))
queue_entity_register("number", config)
CORE.register_platform_component("number", var)
await setup_number_core_(
var, config, min_value=min_value, max_value=max_value, step=step
+24 -15
View File
@@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either:
)
def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
"""Clone/update a git repo and load the YAML files listed in the package definition.
Returns ``{"packages": {<filename>: <loaded_yaml>, ...}}`` so the caller
@@ -215,11 +215,10 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
If loading fails after cloning, attempts a revert and retry in case
a prior cached checkout is stale.
"""
actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH]
repo_dir, revert = git.clone_or_update(
url=config[CONF_URL],
ref=config.get(CONF_REF),
refresh=actual_refresh,
refresh=config[CONF_REFRESH],
domain=DOMAIN,
username=config.get(CONF_USERNAME),
password=config.get(CONF_PASSWORD),
@@ -378,9 +377,8 @@ def _substitute_package_definition(
Local package contents are left untouched they will be substituted
later during the main substitution pass.
"""
if isinstance(package_config, str) or (
isinstance(package_config, dict) and is_remote_package(package_config)
):
def do_substitute(package_config: dict | str) -> dict | str:
# Collect undefined-variable errors (rather than raising strict) so the
# path walked through a remote-package dict is preserved and the user
# sees which field (url / path / ref / ...) referenced the undefined
@@ -394,6 +392,22 @@ def _substitute_package_definition(
errors=errors,
)
raise_first_undefined(errors, "package definition")
return package_config
if isinstance(package_config, str):
return do_substitute(package_config)
if isinstance(package_config, dict) and is_remote_package(package_config):
# Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be
# passed as-is to the package YAML and may contain their own substitution expressions that should not
# be prematurely evaluated here.
if CONF_FILES in package_config:
for file_def in package_config[CONF_FILES]:
if isinstance(file_def, dict) and CONF_VARS in file_def:
file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS])
package_config = do_substitute(package_config)
return package_config
@@ -441,11 +455,9 @@ class _PackageProcessor:
self,
substitutions: UserDict,
command_line_substitutions: dict[str, Any] | None,
skip_update: bool,
) -> None:
self.substitutions = substitutions
self.parent_context = UserDict(command_line_substitutions or {})
self.skip_update = skip_update
def resolve_package(
self,
@@ -493,7 +505,7 @@ class _PackageProcessor:
)
if is_remote_package(package_config):
package_config = _process_remote_package(package_config, self.skip_update)
package_config = _process_remote_package(package_config)
return package_config
def collect_substitutions(self, package_config: dict) -> None:
@@ -537,11 +549,10 @@ class _PackageProcessor:
def do_packages_pass(
config: dict,
config: dict[str, Any],
*,
command_line_substitutions: dict[str, Any] | None = None,
skip_update: bool = False,
) -> dict:
) -> dict[str, Any]:
"""Load, validate, and flatten all packages in the config.
Returns the config with all packages loaded in-place (but not yet merged)
@@ -556,9 +567,7 @@ def do_packages_pass(
config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions
)
)
processor = _PackageProcessor(
substitutions, command_line_substitutions, skip_update
)
processor = _PackageProcessor(substitutions, command_line_substitutions)
_update_substitutions_context(processor.parent_context, substitutions)
context_vars = push_context(
@@ -12,7 +12,7 @@ import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.core.entity_helpers import setup_entity
from esphome.core.entity_helpers import queue_entity_register, setup_entity
from esphome.coroutine import CoroPriority
from esphome.types import ConfigType
@@ -55,8 +55,8 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non
"""Register a radio frequency device with the core."""
cg.add_define("USE_RADIO_FREQUENCY")
await cg.register_component(var, config)
queue_entity_register("radio_frequency", config)
await setup_radio_frequency_core_(var, config)
cg.add(cg.App.register_radio_frequency(var))
CORE.register_platform_component("radio_frequency", var)
+1 -1
View File
@@ -129,6 +129,6 @@ async def to_code(config):
async def sensor_template_publish_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
template_ = await cg.templatable(config[CONF_VALUE], args, cg.int32)
template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_)
cg.add(var.set_value(template_))
return var
+3 -1
View File
@@ -93,7 +93,9 @@ async def to_code(config):
cg.add(var.set_gain(config[CONF_GAIN]))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config.get(CONF_ON_FINISHED_PLAYBACK):
cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK")
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@automation.register_action(
+2
View File
@@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) {
// Clear loop_done when transitioning from `State::STOPPED` to any other state
if (state == State::STOPPED) {
this->disable_loop();
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
this->on_finished_playback_callback_.call();
#endif
ESP_LOGD(TAG, "Playback finished");
} else if (old_state == State::STOPPED) {
this->enable_loop();
+6
View File
@@ -2,6 +2,8 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#ifdef USE_OUTPUT
#include "esphome/components/output/float_output.h"
@@ -45,9 +47,11 @@ class Rtttl : public Component {
bool is_playing() { return this->state_ != State::STOPPED; }
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
template<typename F> void add_on_finished_playback_callback(F &&callback) {
this->on_finished_playback_callback_.add(std::forward<F>(callback));
}
#endif
protected:
inline uint16_t get_integer_() {
@@ -106,8 +110,10 @@ class Rtttl : public Component {
uint32_t samples_gap_{0};
#endif // USE_SPEAKER
#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK
/// The callback to call when playback is finished.
CallbackManager<void()> on_finished_playback_callback_;
#endif
};
template<typename... Ts> class PlayAction : public Action<Ts...> {
+2 -1
View File
@@ -76,8 +76,9 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if config.get(CONF_ON_SAFE_MODE):
if on_safe_mode := config.get(CONF_ON_SAFE_MODE):
cg.add_define("USE_SAFE_MODE_CALLBACK")
cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode))
await automation.build_callback_automations(
var, config, _CALLBACK_AUTOMATIONS
)
+1 -1
View File
@@ -57,7 +57,7 @@ class SafeModeComponent final : public Component {
// Larger objects at the end
ESPPreferenceObject rtc_;
#ifdef USE_SAFE_MODE_CALLBACK
CallbackManager<void()> safe_mode_callback_{};
StaticCallbackManager<ESPHOME_SAFE_MODE_CALLBACK_COUNT, void()> safe_mode_callback_{};
#endif
static const uint32_t ENTER_SAFE_MODE_MAGIC =
+6 -2
View File
@@ -19,7 +19,11 @@ from esphome.const import (
CONF_WEB_SERVER,
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass, TemplateArguments
from esphome.cpp_types import global_ns
@@ -113,7 +117,7 @@ async def setup_select_core_(var, config, *, options: list[str]):
async def register_select(var, config, *, options: list[str]):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_select(var))
queue_entity_register("select", config)
CORE.register_platform_component("select", var)
await setup_select_core_(var, config, options=options)
+268
View File
@@ -0,0 +1,268 @@
from dataclasses import dataclass
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, network, psram, socket, wifi
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_ID,
CONF_SAMPLE_RATE,
CONF_TASK_STACK_IN_PSRAM,
)
from esphome.core import CORE, ID
from esphome.cpp_generator import TemplateArgsType
from esphome.types import ConfigType
# mdns for autodiscovery
AUTO_LOAD = ["mdns"]
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["network"]
DOMAIN = "sendspin"
CONF_SENDSPIN_ID = "sendspin_id"
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay"
# sendspin-cpp library lives in the global `sendspin` namespace.
sendspin_library_ns = cg.global_ns.namespace("sendspin")
# Library Enums
SendspinCodecFormat = sendspin_library_ns.enum("SendspinCodecFormat", is_class=True)
CODEC_FORMAT_FLAC = SendspinCodecFormat.enum("FLAC")
CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
# Library Structs
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
# Analysis tools strip the trailing underscore (same pattern as `template_`).
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
SendspinHub = sendspin_ns.class_(
"SendspinHub",
cg.Component,
)
SendspinSwitchCommandAction = sendspin_ns.class_(
"SendspinSwitchCommandAction",
automation.Action,
cg.Parented.template(SendspinHub),
)
@dataclass
class SendspinConfiguration:
artwork_support: bool = False
controller_support: bool = False
metadata_support: bool = False
player_support: bool = False
visualizer_support: bool = False
player_config: ConfigType | None = None
def _get_data() -> SendspinConfiguration:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = SendspinConfiguration()
return CORE.data[DOMAIN]
def request_artwork_support() -> None:
"""Request artwork role support for Sendspin."""
_get_data().artwork_support = True
def request_controller_support() -> None:
"""Request controller role support for Sendspin."""
_get_data().controller_support = True
def request_metadata_support() -> None:
"""Request metadata role support for Sendspin."""
_get_data().metadata_support = True
def request_player_support() -> None:
"""Request player role support for Sendspin."""
_get_data().player_support = True
def request_visualizer_support() -> None:
"""Request visualizer role support for Sendspin."""
_get_data().visualizer_support = True
def register_player_config(config: ConfigType) -> None:
"""Register the player role config from the media source subcomponent."""
data = _get_data()
request_player_support()
if data.player_config is not None:
raise cv.Invalid(
"Only one sendspin media_source player configuration is supported"
)
data.player_config = config
def _validate_task_stack_in_psram(value):
value = cv.boolean(value)
if value:
return cv.requires_component(psram.DOMAIN)(value)
return value
def _request_high_performance_networking(config: ConfigType) -> ConfigType:
"""Request high performance networking for Sendspin streaming.
Also enables wake_loop_threadsafe support for fast defer() callbacks
from background threads (WebSocket handler, image decoder).
"""
network.require_high_performance_networking()
# Socket consumption varies by mode:
# - Server mode: 1 listening socket + 2 client connections (for handoff)
# - Client mode: 1 outbound connection
socket.consume_sockets(
1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN
)(config)
socket.consume_sockets(2, "sendspin_websocket_server")(config)
socket.consume_sockets(1, "sendspin_websocket_client")(config)
wifi.enable_runtime_power_save_control()
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(SendspinHub),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
}
),
cv.only_on_esp32,
_request_high_performance_networking,
)
def _request_controller_role(config: ConfigType) -> ConfigType:
"""Request the controller role for the sendspin.switch action."""
request_controller_support()
return config
SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All(
automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinHub),
}
)
),
_request_controller_role,
)
@automation.register_action(
"sendspin.switch",
SendspinSwitchCommandAction,
SENDSPIN_SIMPLE_ACTION_SCHEMA,
synchronous=True,
)
async def sendspin_switch_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1")
cg.add_define("USE_SENDSPIN", True) # for MDNS
data = _get_data()
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
if data.artwork_support:
cg.add_define("USE_SENDSPIN_ARTWORK", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
if data.controller_support:
cg.add_define("USE_SENDSPIN_CONTROLLER", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_CONTROLLER", False)
if data.metadata_support:
cg.add_define("USE_SENDSPIN_METADATA", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_METADATA", False)
if data.player_support:
cg.add_define("USE_SENDSPIN_PLAYER", True)
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
player_cfg = data.player_config
sample_rate = player_cfg[CONF_SAMPLE_RATE]
# OPUS only supports 48 kHz audio
codecs = [CODEC_FORMAT_FLAC]
if sample_rate == 48000:
codecs.append(CODEC_FORMAT_OPUS)
codecs.append(CODEC_FORMAT_PCM)
def _audio_format(codec, channels):
return cg.StructInitializer(
AudioSupportedFormatObject,
("codec", codec),
("channels", channels),
("sample_rate", sample_rate),
("bit_depth", 16),
)
audio_format_structs = [
_audio_format(codec, channels) for codec in codecs for channels in (2, 1)
]
psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False)
if psram_stack:
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
player_config_struct = cg.StructInitializer(
PlayerRoleConfig,
("audio_formats", audio_format_structs),
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]),
("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
("psram_stack", psram_stack),
("priority", 2),
)
cg.add(var.set_player_config(player_config_struct))
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if data.visualizer_support:
cg.add_define("USE_SENDSPIN_VISUALIZER", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_VISUALIZER", False)
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/core/automation.h"
#include "sendspin_hub.h"
namespace esphome::sendspin_ {
#ifdef USE_SENDSPIN_CONTROLLER
template<typename... Ts> class SendspinSwitchCommandAction : public Action<Ts...>, public Parented<SendspinHub> {
public:
void play(const Ts &...x) override {
// Clear any EXTERNAL_SOURCE state so the switch command is followed
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH);
}
};
#endif // USE_SENDSPIN_CONTROLLER
} // namespace esphome::sendspin_
#endif // USE_ESP32
@@ -0,0 +1,45 @@
import esphome.codegen as cg
from esphome.components import media_player
from esphome.components.const import CONF_VOLUME_INCREMENT
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, request_controller_support, sendspin_ns
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
SendspinMediaPlayer = sendspin_ns.class_(
"SendspinMediaPlayer",
media_player.MediaPlayer,
cg.Component,
)
def _request_roles(config: ConfigType) -> ConfigType:
"""Request the necessary Sendspin roles for the media player."""
request_controller_support()
return config
CONFIG_SCHEMA = cv.All(
media_player.media_player_schema(SendspinMediaPlayer).extend(
{
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage,
}
),
cv.only_on_esp32,
_request_roles,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
await media_player.register_media_player(var, config)
cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT]))
@@ -0,0 +1,165 @@
#include "sendspin_media_player.h"
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include <sendspin/types.h>
#include <algorithm>
#include <cmath>
#include <memory>
#include <optional>
#include <esp_timer.h>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.media_player";
// THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop,
// since SendspinHub dispatches group updates and controller state from client_->loop().
void SendspinMediaPlayer::setup() {
// Register for group updates to sync playback state
this->parent_->add_group_update_callback([this](const sendspin::GroupUpdateObject &group_obj) {
if (group_obj.playback_state.has_value()) {
media_player::MediaPlayerState new_state;
switch (group_obj.playback_state.value()) {
case sendspin::SendspinPlaybackState::PLAYING:
new_state = media_player::MEDIA_PLAYER_STATE_PLAYING;
break;
case sendspin::SendspinPlaybackState::STOPPED:
default:
new_state = media_player::MEDIA_PLAYER_STATE_IDLE;
break;
}
if (this->state != new_state) {
this->state = new_state;
this->publish_state();
ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
}
}
});
this->parent_->add_controller_state_callback([this](const sendspin::ServerStateControllerObject &state) {
float new_volume = static_cast<float>(state.volume) / 100.0f;
bool new_muted = state.muted;
if ((new_volume != this->volume) || (new_muted != this->muted_)) {
this->volume = new_volume;
this->muted_ = new_muted;
this->publish_state();
}
});
// Publish an initial state
this->state = media_player::MEDIA_PLAYER_STATE_IDLE;
this->publish_state();
}
// THREAD CONTEXT: Main loop (invoked by the media_player framework)
media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() {
auto traits = media_player::MediaPlayerTraits();
// By default, the base media player always enables these traits, but they are not actually supported by this media
// player
traits.clear_feature_flags(media_player::MediaPlayerEntityFeature::PLAY_MEDIA |
media_player::MediaPlayerEntityFeature::BROWSE_MEDIA |
media_player::MediaPlayerEntityFeature::MEDIA_ANNOUNCE);
traits.add_feature_flags(
media_player::MediaPlayerEntityFeature::PLAY | media_player::MediaPlayerEntityFeature::PAUSE |
media_player::MediaPlayerEntityFeature::STOP | media_player::MediaPlayerEntityFeature::VOLUME_STEP |
media_player::MediaPlayerEntityFeature::VOLUME_SET | media_player::MediaPlayerEntityFeature::VOLUME_MUTE);
// NEXT_TRACK, PREVIOUS_TRACK, SHUFFLE_SET, and REPEAT_SET are intentionally not advertised: the ESPHome native API
// does not implement the corresponding media player commands, so Home Assistant cannot actually send them even if
// we expose the capability. They remain accessible via ESPHome YAML automations.
return traits;
}
// THREAD CONTEXT: Main loop (invoked by the media_player framework)
void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) {
if (!this->is_ready()) {
// Ignore any commands sent before the media player is setup
return;
}
auto volume = call.get_volume();
if (volume.has_value()) {
uint8_t new_volume = static_cast<uint8_t>(std::roundf(volume.value() * 100.0f));
this->parent_->send_client_command(sendspin::SendspinControllerCommand::VOLUME, new_volume, std::nullopt);
}
auto command = call.get_command();
if (!command.has_value()) {
return;
}
switch (command.value()) {
case media_player::MEDIA_PLAYER_COMMAND_TOGGLE:
if (this->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE);
} else {
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY);
}
break;
case media_player::MEDIA_PLAYER_COMMAND_PLAY:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY);
break;
case media_player::MEDIA_PLAYER_COMMAND_PAUSE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE);
break;
case media_player::MEDIA_PLAYER_COMMAND_STOP:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::STOP);
break;
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF);
break;
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE);
break;
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL);
break;
case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE);
break;
case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE);
break;
case media_player::MEDIA_PLAYER_COMMAND_NEXT:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT);
break;
case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS);
break;
case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP:
this->parent_->send_client_command(
sendspin::SendspinControllerCommand::VOLUME,
static_cast<uint8_t>(std::roundf(std::min(1.0f, this->volume + this->volume_increment_) * 100.0f)),
std::nullopt);
break;
case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN:
this->parent_->send_client_command(
sendspin::SendspinControllerCommand::VOLUME,
static_cast<uint8_t>(std::roundf(std::max(0.0f, this->volume - this->volume_increment_) * 100.0f)),
std::nullopt);
break;
case media_player::MEDIA_PLAYER_COMMAND_MUTE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, true);
break;
case media_player::MEDIA_PLAYER_COMMAND_UNMUTE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, false);
break;
default:
break;
}
}
void SendspinMediaPlayer::dump_config() {
ESP_LOGCONFIG(TAG, "Sendspin Media Player: volume_increment=%.2f", this->volume_increment_);
}
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,33 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/components/media_player/media_player.h"
#include "esphome/components/sendspin/sendspin_hub.h"
namespace esphome::sendspin_ {
class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer {
public:
void setup() override;
void dump_config() override;
// MediaPlayer implementations
media_player::MediaPlayerTraits get_traits() override;
void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; }
bool is_muted() const override { return this->muted_; }
protected:
// Receives commands from HA
void control(const media_player::MediaPlayerCall &call) override;
float volume_increment_{0.05f};
bool muted_{false};
};
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,134 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import media_source
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_ID,
CONF_SAMPLE_RATE,
CONF_TASK_STACK_IN_PSRAM,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
from .. import (
CONF_FIXED_DELAY,
CONF_INITIAL_STATIC_DELAY,
CONF_SENDSPIN_ID,
SendspinHub,
_validate_task_stack_in_psram,
register_player_config,
request_controller_support,
sendspin_ns,
)
AUTO_LOAD = ["audio"]
CODEOWNERS = ["@kahrendt"]
CONF_STATIC_DELAY_ADJUSTABLE = "static_delay_adjustable"
SendspinMediaSource = sendspin_ns.class_(
"SendspinMediaSource",
cg.Component,
media_source.MediaSource,
)
EnableStaticDelayAdjustmentAction = sendspin_ns.class_(
"EnableStaticDelayAdjustmentAction",
automation.Action,
cg.Parented.template(SendspinMediaSource),
)
DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
"DisableStaticDelayAdjustmentAction",
automation.Action,
cg.Parented.template(SendspinMediaSource),
)
def _register(config: ConfigType) -> ConfigType:
request_controller_support()
register_player_config(
{
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
CONF_FIXED_DELAY: config[CONF_FIXED_DELAY],
CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False),
}
)
return config
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
SendspinMediaSource,
).extend(
{
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000),
cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=5000)),
),
cv.Optional(CONF_STATIC_DELAY_ADJUSTABLE, default=False): cv.boolean,
cv.Optional(CONF_FIXED_DELAY, default="0us"): cv.All(
cv.positive_time_period_microseconds,
cv.Range(max=cv.TimePeriod(microseconds=10000)),
),
cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range(
min=16000, max=96000
),
}
),
cv.only_on_esp32,
_register,
)
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)
sendspin_hub = await cg.get_variable(config[CONF_SENDSPIN_ID])
await cg.register_parented(var, sendspin_hub)
cg.add(sendspin_hub.set_listener(var))
cg.add(var.set_static_delay_adjustable(config[CONF_STATIC_DELAY_ADJUSTABLE]))
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinMediaSource),
}
)
)
@automation.register_action(
"sendspin.media_source.enable_static_delay_adjustment",
EnableStaticDelayAdjustmentAction,
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA,
synchronous=True,
)
@automation.register_action(
"sendspin.media_source.disable_static_delay_adjustment",
DisableStaticDelayAdjustmentAction,
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA,
synchronous=True,
)
async def sendspin_static_delay_adjustment_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/core/automation.h"
#include "sendspin_media_source.h"
namespace esphome::sendspin_ {
template<typename... Ts>
class EnableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
public:
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); }
};
template<typename... Ts>
class DisableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
public:
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); }
};
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,207 @@
#include "sendspin_media_source.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/audio/audio.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.media_source";
static constexpr char URI_PREFIX[] = "sendspin://";
void SendspinMediaSource::setup() {
this->player_role_ = this->parent_->get_player_role();
if (!this->player_role_) {
ESP_LOGE(TAG, "Failed to get player role from hub");
this->mark_failed();
return;
}
// Push cached states to player role. They may have been set before setup() ran.
this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f));
this->player_role_->update_muted(this->cached_muted_);
this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_);
}
void SendspinMediaSource::dump_config() {
ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_));
}
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / config)
void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) {
this->static_delay_adjustable_ = adjustable;
if (this->player_role_) {
this->player_role_->set_static_delay_adjustable(adjustable);
}
}
// --- MediaSource interface ---
bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only)
bool SendspinMediaSource::play_uri(const std::string &uri) {
if (!this->is_ready() || this->is_failed() || !this->has_listener()) {
return false;
}
if (this->get_state() != media_source::MediaSourceState::IDLE) {
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
return false;
}
if (!uri.starts_with(URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1);
if (sendspin_id.empty()) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str());
if (sendspin_id != "current") {
// Connect to a new server as a websocket client
this->parent_->connect_to_server("ws://" + sendspin_id);
}
// Tell the orchestrator we're now playing so it routes audio output from us
this->pending_start_ = false;
this->set_state_(media_source::MediaSourceState::PLAYING);
return true;
}
// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only)
void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) {
switch (command) {
case media_source::MediaSourceCommand::STOP: {
if (!this->pending_start_) {
// Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before
// play_uri
ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE");
this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE);
}
break;
}
case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone)
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::PAUSE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::NEXT:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::PREVIOUS:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_ALL:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_ONE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_OFF:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::SHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::UNSHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt);
break;
default:
break;
}
}
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
void SendspinMediaSource::notify_volume_changed(float volume) {
this->cached_volume_ = volume;
if (this->player_role_) {
this->player_role_->update_volume(std::roundf(volume * 100.0f));
}
}
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
void SendspinMediaSource::notify_mute_changed(bool is_muted) {
this->cached_muted_ = is_muted;
if (this->player_role_) {
this->player_role_->update_muted(is_muted);
}
}
// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker).
// PlayerRole::notify_audio_played() is documented as thread-safe for this use.
void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) {
if (this->player_role_) {
this->player_role_->notify_audio_played(frames, timestamp);
}
}
// --- Sendspin PlayerRoleListener overrides ---
// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms.
size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) {
if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) {
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
return 0;
}
// PlayerRole::get_current_stream_params() is safe to call from the sync task.
auto &params = this->player_role_->get_current_stream_params();
if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) {
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
return 0;
}
audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate);
return this->write_output(data, length, timeout_ms, stream_info);
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_start() {
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
if (!this->pending_start_) {
// Dedup rapid on_stream_start() calls
this->pending_start_ = true;
// Request the orchestrator to start this source
this->request_play_uri_("sendspin://current");
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_end() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_clear() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); }
} // namespace esphome::sendspin_
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
@@ -0,0 +1,72 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/media_source/media_source.h"
#include <sendspin/player_role.h>
namespace esphome::sendspin_ {
/// @brief Thin adapter media source for Sendspin.
///
/// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's
/// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards
/// transport commands to the hub's controller role.
class SendspinMediaSource : public SendspinChild,
public media_source::MediaSource,
public sendspin::PlayerRoleListener {
public:
void setup() override;
void dump_config() override;
void set_static_delay_adjustable(bool adjustable);
// 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;
bool has_internal_playlist() const override { return true; }
void notify_volume_changed(float volume) override;
void notify_mute_changed(bool is_muted) override;
void notify_audio_played(uint32_t frames, int64_t timestamp) override;
protected:
// --- Sendspin PlayerRoleListener overrides ---
/// @brief Writes decoded PCM audio to ESPHome's media source output pipeline.
/// Called from the sync task's background thread.
size_t on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) override;
/// @brief Called when a new audio stream starts (main loop thread).
void on_stream_start() override;
/// @brief Called when the audio stream ends (main loop thread).
void on_stream_end() override;
/// @brief Called when the audio stream is cleared (main loop thread).
void on_stream_clear() override;
/// @brief Called when volume changes (main loop thread).
void on_volume_changed(uint8_t volume) override;
/// @brief Called when mute state changes (main loop thread).
void on_mute_changed(bool muted) override;
sendspin::PlayerRole *player_role_{nullptr};
float cached_volume_{0.0f};
bool cached_muted_{false};
bool pending_start_{false};
bool static_delay_adjustable_{false};
};
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,225 @@
#include "sendspin_hub.h"
#ifdef USE_ESP32
#include "esphome/components/network/util.h"
#ifdef USE_WIFI
#include "esphome/components/wifi/wifi_component.h"
#endif
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#include <esp_log.h>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.hub";
void SendspinHub::setup() {
auto config = this->build_client_config_();
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
// Set up persistence (preferences must be initialized before providers are added to the client)
this->last_played_server_pref_ =
global_preferences->make_preference<LastPlayedServerPref>(fnv1a_hash("sendspin_last_played"));
#ifdef USE_SENDSPIN_PLAYER
this->static_delay_pref_ = global_preferences->make_preference<StaticDelayPref>(fnv1a_hash("sendspin_static_delay"));
#endif
// Wire providers and client listener
this->client_->set_listener(this);
this->client_->set_network_provider(this);
this->client_->set_persistence_provider(this);
#ifdef USE_SENDSPIN_CONTROLLER
this->controller_role_ = &this->client_->add_controller();
this->controller_role_->set_listener(this);
#endif
#ifdef USE_SENDSPIN_METADATA
this->metadata_role_ = &this->client_->add_metadata();
this->metadata_role_->set_listener(this);
#endif
#ifdef USE_SENDSPIN_PLAYER
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
if (!this->client_->start_server()) {
ESP_LOGE(TAG, "Failed to start Sendspin server");
this->mark_failed();
return;
}
}
void SendspinHub::loop() { this->client_->loop(); }
void SendspinHub::dump_config() {
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Sendspin Hub:\n"
" Client ID: %s\n"
" Task stack in PSRAM: %s",
get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
}
// --- Delegating methods ---
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::connect_to_server(const std::string &url) {
if (this->is_ready()) {
this->client_->connect_to(url);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) {
if (this->is_ready()) {
this->client_->disconnect(reason);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::update_state(sendspin::SendspinClientState state) {
if (this->is_ready()) {
this->client_->update_state(state);
}
}
sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
sendspin::SendspinClientConfig config;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
config.client_id = get_mac_address_pretty_into_buffer(mac_buf);
config.name = App.get_friendly_name();
config.product_name = App.get_name();
config.manufacturer = "ESPHome";
config.software_version = ESPHOME_VERSION;
config.httpd_psram_stack = this->task_stack_in_psram_;
return config;
}
// --- SendspinClientListener overrides ---
// THREAD CONTEXT: Main loop (fired from client_->loop())
void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) {
this->group_update_callbacks_.call(group);
}
void SendspinHub::on_request_high_performance() {
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->request_high_performance();
}
#endif
}
void SendspinHub::on_release_high_performance() {
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->release_high_performance();
}
#endif
}
// --- SendspinNetworkProvider override ---
// THREAD CONTEXT: Main loop (polled by client_->loop())
bool SendspinHub::is_network_ready() { return network::is_connected(); }
// --- SendspinPersistenceProvider overrides ---
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
bool SendspinHub::save_last_server_hash(uint32_t hash) {
LastPlayedServerPref pref{.server_id_hash = hash};
bool ok = this->last_played_server_pref_.save(&pref);
if (ok) {
ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash);
} else {
ESP_LOGW(TAG, "Failed to persist last played server hash");
}
return ok;
}
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
std::optional<uint32_t> SendspinHub::load_last_server_hash() {
LastPlayedServerPref pref{};
if (this->last_played_server_pref_.load(&pref)) {
ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash);
return pref.server_id_hash;
}
return std::nullopt;
}
// --- Sendspin role specific methods/overrides ---
#ifdef USE_SENDSPIN_CONTROLLER
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
std::optional<bool> mute) {
if (this->is_ready()) {
this->controller_role_->send_command(command, volume, mute);
}
}
// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop())
void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) {
this->controller_state_callbacks_.call(state);
}
#endif
#ifdef USE_SENDSPIN_METADATA
// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop())
void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) {
this->metadata_update_callbacks_.call(metadata);
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
uint32_t SendspinHub::get_track_progress_ms() const {
if (this->is_ready()) {
return this->metadata_role_->get_track_progress_ms();
}
return 0;
}
#endif
#ifdef USE_SENDSPIN_PLAYER
// THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured
sendspin::PlayerRole *SendspinHub::get_player_role() {
if (this->is_ready()) {
return this->client_->player();
}
return nullptr;
}
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
bool SendspinHub::save_static_delay(uint16_t delay_ms) {
StaticDelayPref pref{.delay_ms = delay_ms};
bool ok = this->static_delay_pref_.save(&pref);
if (ok) {
ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms);
} else {
ESP_LOGW(TAG, "Failed to persist static delay");
}
return ok;
}
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
std::optional<uint16_t> SendspinHub::load_static_delay() {
StaticDelayPref pref{};
if (this->static_delay_pref_.load(&pref)) {
ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms);
return pref.delay_ms;
}
return std::nullopt;
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP32
+231
View File
@@ -0,0 +1,231 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include <sendspin/client.h>
#include <sendspin/config.h>
#include <sendspin/types.h>
#ifdef USE_SENDSPIN_CONTROLLER
#include <sendspin/controller_role.h>
#endif
#ifdef USE_SENDSPIN_METADATA
#include <sendspin/metadata_role.h>
#endif
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#endif
#include <functional>
#include <memory>
#include <optional>
namespace esphome::sendspin_ {
/// @brief Setup priorities for the sendspin hub and its child components.
///
/// Centralized here so every sendspin component orders itself relative to the hub
/// without each subcomponent having to pick a priority independently. Children run
/// one step later than hub so they can assume hub's setup() has already completed.
namespace sendspin_priority {
inline constexpr float HUB = esphome::setup_priority::PROCESSOR;
inline constexpr float CHILD = HUB - 1.0f;
} // namespace sendspin_priority
/// @brief Persistent storage structure for last played server hash.
struct LastPlayedServerPref {
uint32_t server_id_hash;
};
#ifdef USE_SENDSPIN_PLAYER
/// @brief Persistent storage structure for player static delay.
struct StaticDelayPref {
uint16_t delay_ms;
};
#endif
/// @brief Thin adapter over sendspin::SendspinClient.
///
/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for
/// fan-out to child components.
/// - Provides persistence via ESPPreferenceObject and WiFi power management integration.
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
/// child thereafter, since no fan-out is needed.
///
/// The sendspin-cpp library follows this design:
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
/// - Library -> user code communication happens via two interface types the user implements and registers in our
/// `setup()`: listener interfaces (for events the library pushes; e.g., group updates) and provider interfaces
/// (for services the library pulls; e.g., persistence, network readiness).
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
class SendspinHub final : public Component,
#ifdef USE_SENDSPIN_CONTROLLER
public sendspin::ControllerRoleListener,
#endif
#ifdef USE_SENDSPIN_METADATA
public sendspin::MetadataRoleListener,
#endif
public sendspin::SendspinClientListener,
public sendspin::SendspinNetworkProvider,
public sendspin::SendspinPersistenceProvider {
public:
float get_setup_priority() const override { return sendspin_priority::HUB; }
void setup() override;
void loop() override;
void dump_config() override;
/// @brief Connects the underlying client to the given Sendspin server.
///
/// No-op if the hub's client is not ready (e.g. setup() has not completed).
/// Must be called from the main loop thread.
/// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`).
void connect_to_server(const std::string &url);
/// @brief Disconnects the underlying client from the current server.
///
/// Sends a `client/goodbye` message with the given reason before closing the connection.
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// @param reason Reason reported to the server:
/// - `ANOTHER_SERVER`: client is switching to another server.
/// - `SHUTDOWN`: client is shutting down.
/// - `RESTART`: client is restarting.
/// - `USER_REQUEST`: user explicitly requested disconnect.
void disconnect_from_server(sendspin::SendspinGoodbyeReason reason);
/// @brief Updates the client's reported playback state on the server.
///
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// @param state New client state:
/// - `SYNCHRONIZED`: client is synchronized and playing from the server.
/// - `ERROR`: client encountered a playback error.
/// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source.
void update_state(sendspin::SendspinClientState state);
// --- Configuration setters (called from codegen) ---
template<typename F> void add_group_update_callback(F &&callback) {
this->group_update_callbacks_.add(std::forward<F>(callback));
}
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
// --- Sendspin role specific methods ---
#ifdef USE_SENDSPIN_CONTROLLER
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
std::optional<bool> mute = std::nullopt);
template<typename F> void add_controller_state_callback(F &&callback) {
this->controller_state_callbacks_.add(std::forward<F>(callback));
}
#endif
#ifdef USE_SENDSPIN_METADATA
template<typename F> void add_metadata_update_callback(F &&callback) {
this->metadata_update_callbacks_.add(std::forward<F>(callback));
}
/// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready.
uint32_t get_track_progress_ms() const;
#endif
#ifdef USE_SENDSPIN_PLAYER
void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; }
void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; }
/// @brief Child components call this to get the PlayerRole instance after setup, so they can push updates to it.
sendspin::PlayerRole *get_player_role();
#endif
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
// --- SendspinClientListener overrides ---
void on_group_update(const sendspin::GroupUpdateObject &group) override;
void on_request_high_performance() override;
void on_release_high_performance() override;
// --- SendspinNetworkProvider override ---
bool is_network_ready() override;
// --- SendspinPersistenceProvider overrides ---
bool save_last_server_hash(uint32_t hash) override;
std::optional<uint32_t> load_last_server_hash() override;
// --- Sendspin role specific methods/overrides/member variables ---
#ifdef USE_SENDSPIN_CONTROLLER
sendspin::ControllerRole *controller_role_{nullptr};
void on_controller_state(const sendspin::ServerStateControllerObject &state) override;
// Callback fan-out to child components; they filter as needed
CallbackManager<void(const sendspin::ServerStateControllerObject &)> controller_state_callbacks_{};
#endif
#ifdef USE_SENDSPIN_METADATA
sendspin::MetadataRole *metadata_role_{nullptr};
void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override;
// Callback fan-out to child components; they filter as needed
CallbackManager<void(const sendspin::ServerMetadataStateObject &)> metadata_update_callbacks_{};
#endif
#ifdef USE_SENDSPIN_PLAYER
sendspin::PlayerRoleListener *player_listener_{nullptr};
sendspin::PlayerRoleConfig player_config_{};
// Part of SendspinPersistenceProvider overrides
ESPPreferenceObject static_delay_pref_;
std::optional<uint16_t> load_static_delay() override;
bool save_static_delay(uint16_t delay_ms) override;
#endif
// --- Core member variables ---
ESPPreferenceObject last_played_server_pref_;
std::unique_ptr<sendspin::SendspinClient> client_;
// Callback fan-out to child components
CallbackManager<void(const sendspin::GroupUpdateObject &)> group_update_callbacks_{};
bool task_stack_in_psram_{false};
};
/// @brief Base class for all sendspin subcomponents.
///
/// Consolidates the Component + Parented<SendspinHub> inheritance and pins the setup
/// priority so the hub's setup() always runs before any child. Subcomponents should
/// inherit from this instead of listing Component/Parented individually and must not
/// override get_setup_priority().
class SendspinChild : public Component, public Parented<SendspinHub> {
public:
float get_setup_priority() const override { return sendspin_priority::CHILD; }
};
/// @brief Base class for sendspin subcomponents that need polling behavior.
///
/// Same purpose as SendspinChild but inherits from PollingComponent for subcomponents
/// that poll on a fixed interval. Subcomponents should inherit from this instead of
/// listing PollingComponent/Parented individually and must not override get_setup_priority().
class SendspinPollingChild : public PollingComponent, public Parented<SendspinHub> {
public:
float get_setup_priority() const override { return sendspin_priority::CHILD; }
};
} // namespace esphome::sendspin_
#endif // USE_ESP32
@@ -0,0 +1,98 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
CONF_TYPE,
CONF_YEAR,
STATE_CLASS_MEASUREMENT,
UNIT_MILLISECOND,
)
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
CONF_TRACK = "track"
CONF_TRACK_PROGRESS = "track_progress"
CONF_TRACK_DURATION = "track_duration"
SendspinTrackProgressSensor = sendspin_ns.class_(
"SendspinTrackProgressSensor",
sensor.Sensor,
cg.PollingComponent,
)
SendspinMetadataSensor = sendspin_ns.class_(
"SendspinMetadataSensor",
sensor.Sensor,
cg.Component,
)
SendspinNumericMetadataTypes = sendspin_ns.enum(
"SendspinNumericMetadataTypes", is_class=True
)
_METADATA_TYPE_ENUM = {
CONF_TRACK_DURATION: SendspinNumericMetadataTypes.TRACK_DURATION,
CONF_YEAR: SendspinNumericMetadataTypes.YEAR,
CONF_TRACK: SendspinNumericMetadataTypes.TRACK,
}
def _request_roles(config: ConfigType) -> ConfigType:
"""Request the necessary Sendspin roles for the sensor."""
request_metadata_support()
return config
_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
def _metadata_schema(**sensor_kwargs):
"""Schema for event-driven numeric metadata sensors (duration/year/track)."""
return (
sensor.sensor_schema(
SendspinMetadataSensor,
accuracy_decimals=0,
**sensor_kwargs,
)
.extend(_HUB_ID_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
{
CONF_TRACK_PROGRESS: sensor.sensor_schema(
SendspinTrackProgressSensor,
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_MILLISECOND,
)
.extend(_HUB_ID_SCHEMA)
.extend(cv.polling_component_schema("1s")),
CONF_TRACK_DURATION: _metadata_schema(
state_class=STATE_CLASS_MEASUREMENT,
unit_of_measurement=UNIT_MILLISECOND,
),
CONF_YEAR: _metadata_schema(),
CONF_TRACK: _metadata_schema(),
},
key=CONF_TYPE,
),
cv.only_on_esp32,
_request_roles,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
await sensor.register_sensor(var, config)
if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None:
cg.add(var.set_metadata_type(metadata_type))
@@ -0,0 +1,98 @@
#include "sendspin_sensor.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#include <sendspin/metadata_role.h>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.sensor";
// --- SendspinTrackProgressSensor ---
void SendspinTrackProgressSensor::dump_config() {
LOG_SENSOR("", "Track Progress", this);
LOG_UPDATE_INTERVAL(this);
}
// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop
// (SendspinHub dispatches metadata from client_->loop()).
void SendspinTrackProgressSensor::setup() {
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
if (!metadata.progress.has_value()) {
return;
}
const auto &progress = metadata.progress.value();
if (progress.playback_speed == 0) {
// Paused: freeze progress at the reported position and stop polling to save cycles.
this->stop_poller();
this->publish_state(progress.track_progress);
} else {
// Resumed: publish the fresh interpolated position immediately so the frontend doesn't show a stale
// paused value until the next poll tick.
this->publish_state(this->parent_->get_track_progress_ms());
this->start_poller();
}
});
}
// THREAD CONTEXT: Main loop.
// Sendspin only pushes progress on state changes (play/pause/seek/speed change), not continuously during
// playback. The hub helper interpolates the current position from the last server update and the playback
// speed, giving us a fresh value on every poll.
void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); }
// --- SendspinMetadataSensor ---
void SendspinMetadataSensor::dump_config() {
switch (this->metadata_type_) {
case SendspinNumericMetadataTypes::TRACK_DURATION:
LOG_SENSOR("", "Track Duration", this);
break;
case SendspinNumericMetadataTypes::YEAR:
LOG_SENSOR("", "Year", this);
break;
case SendspinNumericMetadataTypes::TRACK:
LOG_SENSOR("", "Track", this);
break;
}
}
std::optional<float> SendspinMetadataSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const {
switch (this->metadata_type_) {
case SendspinNumericMetadataTypes::TRACK_DURATION:
if (metadata.progress.has_value())
return metadata.progress.value().track_duration;
return std::nullopt;
case SendspinNumericMetadataTypes::YEAR:
if (metadata.year.has_value())
return metadata.year.value();
return std::nullopt;
case SendspinNumericMetadataTypes::TRACK:
if (metadata.track.has_value())
return metadata.track.value();
return std::nullopt;
}
return std::nullopt;
}
// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop
// (SendspinHub dispatches metadata from client_->loop()).
void SendspinMetadataSensor::setup() {
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
if (auto value = this->extract_value_(metadata)) {
this->publish_if_changed_(*value);
}
});
}
// Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes.
void SendspinMetadataSensor::publish_if_changed_(float value) {
if (this->get_raw_state() != value) {
this->publish_state(value);
}
}
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,42 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/sensor/sensor.h"
#include <optional>
namespace esphome::sendspin_ {
class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild {
public:
void dump_config() override;
void setup() override;
void update() override;
};
enum class SendspinNumericMetadataTypes {
TRACK_DURATION,
YEAR,
TRACK,
};
class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild {
public:
void dump_config() override;
void setup() override;
void set_metadata_type(SendspinNumericMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; }
protected:
std::optional<float> extract_value_(const sendspin::ServerMetadataStateObject &metadata) const;
void publish_if_changed_(float value);
SendspinNumericMetadataTypes metadata_type_;
};
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,53 @@
import esphome.codegen as cg
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TYPE
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
SendspinTextSensor = sendspin_ns.class_(
"SendspinTextSensor",
text_sensor.TextSensor,
cg.Component,
)
SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True)
SENDSPIN_TEXT_METADATA_TYPES = {
"title": SendspinTextMetadataTypes.TITLE,
"artist": SendspinTextMetadataTypes.ARTIST,
"album": SendspinTextMetadataTypes.ALBUM,
"album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST,
}
def _request_roles(config: ConfigType) -> ConfigType:
"""Request the necessary Sendspin roles for the text sensor."""
request_metadata_support()
return config
CONFIG_SCHEMA = cv.All(
text_sensor.text_sensor_schema().extend(
{
cv.GenerateID(): cv.declare_id(SendspinTextSensor),
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES),
}
),
cv.only_on_esp32,
_request_roles,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
await text_sensor.register_text_sensor(var, config)
cg.add(var.set_metadata_type(config[CONF_TYPE]))
@@ -0,0 +1,56 @@
#include "sendspin_text_sensor.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#include <sendspin/metadata_role.h>
#include <string>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.text_sensor";
void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); }
const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const {
switch (this->metadata_type_) {
case SendspinTextMetadataTypes::TITLE:
if (metadata.title.has_value())
return metadata.title.value().c_str();
return nullptr;
case SendspinTextMetadataTypes::ARTIST:
if (metadata.artist.has_value())
return metadata.artist.value().c_str();
return nullptr;
case SendspinTextMetadataTypes::ALBUM:
if (metadata.album.has_value())
return metadata.album.value().c_str();
return nullptr;
case SendspinTextMetadataTypes::ALBUM_ARTIST:
if (metadata.album_artist.has_value())
return metadata.album_artist.value().c_str();
return nullptr;
}
return nullptr;
}
// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop
// (SendspinHub dispatches metadata from client_->loop()).
void SendspinTextSensor::setup() {
this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) {
if (const char *value = this->extract_value_(metadata)) {
this->publish_if_changed_(value);
}
});
}
// Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies.
void SendspinTextSensor::publish_if_changed_(const char *value) {
if (this->get_raw_state() != value) {
this->publish_state(value);
}
}
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,36 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include <sendspin/metadata_role.h>
namespace esphome::sendspin_ {
enum class SendspinTextMetadataTypes {
TITLE,
ARTIST,
ALBUM,
ALBUM_ARTIST,
};
class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor {
public:
void dump_config() override;
void setup() override;
void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; }
protected:
const char *extract_value_(const sendspin::ServerMetadataStateObject &metadata) const;
void publish_if_changed_(const char *value);
SendspinTextMetadataTypes metadata_type_;
};
} // namespace esphome::sendspin_
#endif
+2 -1
View File
@@ -109,6 +109,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
setup_unit_of_measurement,
@@ -982,7 +983,7 @@ async def setup_sensor_core_(var, config):
async def register_sensor(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_sensor(var))
queue_entity_register("sensor", config)
CORE.register_platform_component("sensor", var)
await setup_sensor_core_(var, config)
@@ -32,7 +32,6 @@ from esphome.const import (
CONF_URL,
)
from esphome.core import CORE, HexInt
from esphome.core.entity_helpers import inherit_property_from
from esphome.external_files import download_content
_LOGGER = logging.getLogger(__name__)
@@ -44,16 +43,12 @@ DEPENDENCIES = ["network"]
CODEOWNERS = ["@kahrendt", "@synesthesiam"]
DOMAIN = "media_player"
CODEC_SUPPORT_ALL = "all"
CODEC_SUPPORT_NEEDED = "needed"
CODEC_SUPPORT_NONE = "none"
TYPE_LOCAL = "local"
TYPE_WEB = "web"
CONF_ANNOUNCEMENT = "announcement"
CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline"
CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled"
CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0
CONF_ENQUEUE = "enqueue"
CONF_MEDIA_FILE = "media_file"
CONF_MEDIA_PIPELINE = "media_pipeline"
@@ -106,43 +101,10 @@ def _download_web_file(value):
return value
# Returns a media_player.MediaPlayerSupportedFormat struct with the configured
# format, sample rate, number of channels, purpose, and bytes per sample
def _get_supported_format_struct(pipeline, type):
args = [
media_player.MediaPlayerSupportedFormat,
]
if pipeline[CONF_FORMAT] == "FLAC":
args.append(("format", "flac"))
elif pipeline[CONF_FORMAT] == "MP3":
args.append(("format", "mp3"))
elif pipeline[CONF_FORMAT] == "OPUS":
args.append(("format", "opus"))
elif pipeline[CONF_FORMAT] == "WAV":
args.append(("format", "wav"))
args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE]))
args.append(("num_channels", pipeline[CONF_NUM_CHANNELS]))
if type == "MEDIA":
args.append(
(
"purpose",
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"],
)
)
elif type == "ANNOUNCEMENT":
args.append(
(
"purpose",
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"],
)
)
if pipeline[CONF_FORMAT] != "MP3":
args.append(("sample_bytes", 2))
return cg.StructInitializer(*args)
_PURPOSE_MAP = {
"MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"],
"ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"],
}
def _file_schema(value):
@@ -210,25 +172,9 @@ def _validate_file_shorthand(value):
)
def _validate_pipeline(config):
# Inherit transcoder settings from speaker if not manually set
inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config)
inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config)
# Opus only supports 48 kHz
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
# Validate the transcoder settings is compatible with the speaker
audio.final_validate_audio_schema(
"speaker media_player",
audio_device=CONF_SPEAKER,
bits_per_sample=16,
channels=config.get(CONF_NUM_CHANNELS),
sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
return config
_validate_pipeline = media_player.validate_preferred_format(
"speaker media_player", CONF_SPEAKER
)
def _validate_repeated_speaker(config):
@@ -245,59 +191,34 @@ def _validate_repeated_speaker(config):
def _final_validate(config):
# Normalize boolean values to string equivalents
codec_mode = config[CONF_CODEC_SUPPORT_ENABLED]
if codec_mode is True:
codec_mode = CODEC_SUPPORT_ALL
elif codec_mode is False:
codec_mode = CODEC_SUPPORT_NONE
# Remove before 2026.10.0
if CONF_CODEC_SUPPORT_ENABLED in config:
_LOGGER.warning(
"'%s' is deprecated and will be removed in 2026.10.0. "
"Codec support is now automatically determined from the pipeline "
"'format' setting. Set format to 'NONE' to enable all codecs.",
CONF_CODEC_SUPPORT_ENABLED,
)
use_codec = codec_mode != CODEC_SUPPORT_NONE
# In "needed" mode, collect formats from pipelines and files
needed_formats = set()
need_all = False
if codec_mode == CODEC_SUPPORT_NEEDED:
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
if pipeline := config.get(pipeline_key):
fmt = pipeline[CONF_FORMAT]
if fmt == "NONE":
# No preferred format means any format could arrive
need_all = True
else:
needed_formats.add(fmt)
# Request codecs based on pipeline formats
media_player.request_codecs_for_format_configs(
config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE]
)
# Validate local files and request any additional codecs they need
for file_config in config.get(CONF_FILES, []):
_, media_file_type = _read_audio_file_and_type(file_config)
if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]):
raise cv.Invalid("Unsupported local media file")
if not use_codec and str(media_file_type) != str(
audio.AUDIO_FILE_TYPE_ENUM["WAV"]
):
# Only wav files are supported
raise cv.Invalid(
f"Unsupported local media file type, set {CONF_CODEC_SUPPORT_ENABLED} to true or convert the media file to wav"
)
# In "needed" mode, add file format to needed codecs
if codec_mode == CODEC_SUPPORT_NEEDED:
for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items():
if str(media_file_type) == str(fmt_enum):
if fmt_name not in ("WAV", "NONE"):
needed_formats.add(fmt_name)
break
# Request codec support
if codec_mode == CODEC_SUPPORT_ALL or need_all:
audio.request_flac_support()
audio.request_mp3_support()
audio.request_opus_support()
elif codec_mode == CODEC_SUPPORT_NEEDED:
if "FLAC" in needed_formats:
audio.request_flac_support()
if "MP3" in needed_formats:
audio.request_mp3_support()
if "OPUS" in needed_formats:
audio.request_opus_support()
for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items():
if str(media_file_type) == str(fmt_enum):
if fmt_name == "FLAC":
audio.request_flac_support()
elif fmt_name == "MP3":
audio.request_mp3_support()
elif fmt_name == "OPUS":
audio.request_opus_support()
break
return config
@@ -362,17 +283,8 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(
min=4000, max=4000000
),
cv.Optional(
CONF_CODEC_SUPPORT_ENABLED, default=CODEC_SUPPORT_NEEDED
): cv.Any(
cv.boolean,
cv.one_of(
CODEC_SUPPORT_ALL,
CODEC_SUPPORT_NEEDED,
CODEC_SUPPORT_NONE,
lower=True,
),
),
# Remove before 2026.10.0
cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string),
cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
cv.boolean, cv.requires_component(psram.DOMAIN)
@@ -432,8 +344,8 @@ async def to_code(config):
if announcement_pipeline_config[CONF_FORMAT] != "NONE":
cg.add(
var.set_announcement_format(
_get_supported_format_struct(
announcement_pipeline_config, "ANNOUNCEMENT"
media_player.build_supported_format_struct(
announcement_pipeline_config, _PURPOSE_MAP["ANNOUNCEMENT"]
)
)
)
@@ -444,7 +356,9 @@ async def to_code(config):
if media_pipeline_config[CONF_FORMAT] != "NONE":
cg.add(
var.set_media_format(
_get_supported_format_struct(media_pipeline_config, "MEDIA")
media_player.build_supported_format_struct(
media_pipeline_config, _PURPOSE_MAP["MEDIA"]
)
)
)
@@ -17,7 +17,6 @@ from esphome.const import (
CONF_SPEAKER,
)
from esphome.core import ID
from esphome.core.entity_helpers import inherit_property_from
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
@@ -65,53 +64,9 @@ SetPlaylistDelayAction = speaker_source_ns.class_(
)
FORMAT_MAPPING = {
"FLAC": "flac",
"MP3": "mp3",
"OPUS": "opus",
"WAV": "wav",
}
# Returns a media_player.MediaPlayerSupportedFormat struct with the configured
# format, sample rate, number of channels, purpose, and bytes per sample
def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj):
args = [
media_player.MediaPlayerSupportedFormat,
]
args.append(("format", FORMAT_MAPPING[pipeline[CONF_FORMAT]]))
args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE]))
args.append(("num_channels", pipeline[CONF_NUM_CHANNELS]))
args.append(("purpose", purpose))
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
# if the number of bytes per sample is specified for MP3.
if pipeline[CONF_FORMAT] != "MP3":
args.append(("sample_bytes", 2))
return cg.StructInitializer(*args)
def _validate_pipeline(config: ConfigType) -> ConfigType:
# Inherit settings from speaker if not manually set
inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config)
inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config)
# Opus only supports 48 kHz
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
audio.final_validate_audio_schema(
"speaker_source media_player",
audio_device=CONF_SPEAKER,
bits_per_sample=16,
channels=config.get(CONF_NUM_CHANNELS),
sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
return config
_validate_pipeline = media_player.validate_preferred_format(
"speaker_source media_player", CONF_SPEAKER
)
PIPELINE_SCHEMA = cv.Schema(
@@ -198,31 +153,9 @@ CONFIG_SCHEMA = cv.All(
def _final_validate_codecs(config: ConfigType) -> ConfigType:
# "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available.
# When a specific format is set, only that codec is requested.
needed_formats: set[str] = set()
need_all = False
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
if pipeline := config.get(pipeline_key):
fmt = pipeline[CONF_FORMAT]
if fmt == "NONE":
need_all = True
else:
needed_formats.add(fmt)
if need_all:
audio.request_flac_support()
audio.request_mp3_support()
audio.request_opus_support()
else:
if "FLAC" in needed_formats:
audio.request_flac_support()
if "MP3" in needed_formats:
audio.request_mp3_support()
if "OPUS" in needed_formats:
audio.request_opus_support()
media_player.request_codecs_for_format_configs(
config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE]
)
return config
@@ -264,7 +197,9 @@ async def to_code(config: ConfigType) -> None:
cg.add(
var.set_format(
pipeline_enum,
_get_supported_format_struct(pipeline_config, purpose),
media_player.build_supported_format_struct(
pipeline_config, purpose
),
)
)
+2 -1
View File
@@ -23,6 +23,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -166,7 +167,7 @@ async def setup_switch_core_(var, config):
async def register_switch(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_switch(var))
queue_entity_register("switch", config)
CORE.register_platform_component("switch", var)
await setup_switch_core_(var, config)
+6 -2
View File
@@ -14,7 +14,11 @@ from esphome.const import (
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@mauritskorse"]
@@ -122,7 +126,7 @@ async def register_text(
):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_text(var))
queue_entity_register("text", config)
CORE.register_platform_component("text", var)
await setup_text_core_(
var, config, min_length=min_length, max_length=max_length, pattern=pattern
+2 -1
View File
@@ -22,6 +22,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -221,7 +222,7 @@ async def setup_text_sensor_core_(var, config):
async def register_text_sensor(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_text_sensor(var))
queue_entity_register("text_sensor", config)
CORE.register_platform_component("text_sensor", var)
await setup_text_sensor_core_(var, config)
+2 -1
View File
@@ -17,6 +17,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -113,7 +114,7 @@ async def setup_update_core_(var, config):
async def register_update(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_update(var))
queue_entity_register("update", config)
CORE.register_platform_component("update", var)
await setup_update_core_(var, config)
+2 -1
View File
@@ -24,6 +24,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -162,7 +163,7 @@ async def _setup_valve_core(var, config):
async def register_valve(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
cg.add(cg.App.register_valve(var))
queue_entity_register("valve", config)
CORE.register_platform_component("valve", var)
await _setup_valve_core(var, config)
+6 -2
View File
@@ -9,7 +9,11 @@ from esphome.const import (
CONF_VISUAL,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
from esphome.types import ConfigType
@@ -90,7 +94,7 @@ async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pva
cg.add_define("USE_WATER_HEATER")
cg.add(cg.App.register_water_heater(var))
queue_entity_register("water_heater", config)
CORE.register_platform_component("water_heater", var)
await setup_water_heater_core_(var, config)
@@ -472,24 +472,36 @@ void AsyncResponseStream::printf(const char *fmt, ...) {
#ifdef USE_WEBSERVER
AsyncEventSource::~AsyncEventSource() {
for (auto *ses : this->sessions_) {
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
LockGuard guard{this->pending_mutex_};
for (auto *vec : {&this->sessions_, &this->pending_sessions_}) {
for (auto *ses : *vec) {
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
}
}
}
void AsyncEventSource::handleRequest(AsyncWebServerRequest *request) {
// Httpd task: set up the live httpd_req_t and park the session; main loop does the rest.
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory,clang-analyzer-cplusplus.NewDeleteLeaks)
auto *rsp = new AsyncEventSourceResponse(request, this, this->web_server_);
if (this->on_connect_) {
this->on_connect_(rsp);
{
LockGuard guard{this->pending_mutex_};
this->pending_sessions_.push_back(rsp);
this->has_pending_sessions_.store(true, std::memory_order_release);
}
this->sessions_.push_back(rsp);
// Wake up WebServer::loop() to drain deferred event queues for this client.
// Safe from httpd task context via the pending_enable_loop_ flag.
this->web_server_->enable_loop_soon_any_context();
}
// clang-analyzer traces a false-positive leak path from loop() through
// adopt_pending_sessions_main_loop_() into start_session_main_loop_() and
// finally ArduinoJson. Suppress along the entire in-our-code call chain.
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks)
bool AsyncEventSource::loop() {
// Fast path: one atomic load per tick. Slow path is out-of-line on connect.
if (this->has_pending_sessions_.load(std::memory_order_acquire)) {
this->adopt_pending_sessions_main_loop_();
}
// Clean up dead sessions safely
// This follows the ESP-IDF pattern where free_ctx marks resources as dead
// and the main loop handles the actual cleanup to avoid race conditions
@@ -497,7 +509,7 @@ bool AsyncEventSource::loop() {
auto *ses = this->sessions_[i];
// If the session has a dead socket (marked by destroy callback)
if (ses->fd_.load() == 0) {
ESP_LOGD(TAG, "Removing dead event source session");
// destroy() already logged the close with the fd; don't double-log here.
delete ses; // NOLINT(cppcoreguidelines-owning-memory)
// Remove by swapping with last element (O(1) removal, order doesn't matter for sessions)
this->sessions_[i] = this->sessions_.back();
@@ -510,6 +522,30 @@ bool AsyncEventSource::loop() {
return !this->sessions_.empty();
}
void AsyncEventSource::adopt_pending_sessions_main_loop_() {
std::vector<AsyncEventSourceResponse *> incoming;
{
LockGuard guard{this->pending_mutex_};
incoming.swap(this->pending_sessions_);
this->has_pending_sessions_.store(false, std::memory_order_relaxed);
}
for (auto *rsp : incoming) {
// Already disconnected? Drop it; skip on_connect_/session start on a dead session.
if (rsp->fd_.load() == 0) {
delete rsp; // NOLINT(cppcoreguidelines-owning-memory)
continue;
}
this->sessions_.push_back(rsp);
// Prime first so on_connect_ observes a session that has already sent its
// initial ping/config/sorting_groups, matching the pre-refactor ordering.
rsp->start_session_main_loop_();
if (this->on_connect_) {
this->on_connect_(rsp);
}
}
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) {
for (auto *ses : this->sessions_) {
if (ses->fd_.load() != 0) { // Skip dead sessions
@@ -534,6 +570,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
esphome::web_server_idf::AsyncEventSource *server,
esphome::web_server::WebServer *ws)
: server_(server), web_server_(ws), entities_iterator_(ws, server) {
// Httpd task only. start_session_main_loop_() handles event_buffer_ / iterator setup.
httpd_req_t *req = *request;
httpd_resp_set_status(req, HTTPD_200);
@@ -555,21 +592,23 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
// Use non-blocking send to prevent watchdog timeouts when TCP buffers are full
httpd_sess_set_send_override(this->hd_, this->fd_.load(), nonblocking_send);
}
// Configure reconnect timeout and send config
// this should always go through since the tcp send buffer is empty on connect
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
void AsyncEventSourceResponse::start_session_main_loop_() {
auto *ws = this->web_server_;
// tcp send buffer is empty on connect, so these should always go through
auto message = ws->get_config_json();
this->try_send_nodefer(message.c_str(), "ping", millis(), 30000);
#ifdef USE_WEBSERVER_SORTING
for (auto &group : ws->sorting_groups_) {
// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson
json::JsonBuilder builder;
JsonObject root = builder.root();
root["name"] = group.second.name;
root["sorting_weight"] = group.second.weight;
message = builder.serialize();
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
// a (very) large number of these should be able to be queued initially without defer
// since the only thing in the send buffer at this point is the initial ping/config
@@ -578,13 +617,8 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest *
#endif
this->entities_iterator_.begin(ws->include_internal_);
// just dump them all up-front and take advantage of the deferred queue
// on second thought that takes too long, but leaving the commented code here for debug purposes
// while(!this->entities_iterator_.completed()) {
// this->entities_iterator_.advance();
//}
}
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks)
void AsyncEventSourceResponse::destroy(void *ptr) {
auto *rsp = static_cast<AsyncEventSourceResponse *>(ptr);
@@ -299,6 +299,9 @@ class AsyncEventSourceResponse {
AsyncEventSourceResponse(const AsyncWebServerRequest *request, esphome::web_server_idf::AsyncEventSource *server,
esphome::web_server::WebServer *ws);
// Main-loop only: sends initial ping/config/sorting_groups, starts entity iterator.
void start_session_main_loop_();
void deq_push_back_with_dedup_(void *source, message_generator_t *message_generator);
void process_deferred_queue_();
void process_buffer_();
@@ -335,6 +338,8 @@ class AsyncEventSource : public AsyncWebHandler {
}
// NOLINTNEXTLINE(readability-identifier-naming)
void handleRequest(AsyncWebServerRequest *request) override;
// Callback runs on the main loop (not the httpd task) after the session's
// initial ping/config/sorting_groups have been sent.
// NOLINTNEXTLINE(readability-identifier-naming)
void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); }
@@ -347,13 +352,18 @@ class AsyncEventSource : public AsyncWebHandler {
size_t count() const { return this->sessions_.size(); }
protected:
// Cold path: move sessions from pending_sessions_ into sessions_ and greet each one.
void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_();
std::string url_;
// Use vector instead of set: SSE sessions are typically 1-5 connections (browsers, dashboards).
// Linear search is faster than red-black tree overhead for this small dataset.
// Only operations needed: add session, remove session, iterate sessions - no need for sorted order.
// Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set.
std::vector<AsyncEventSourceResponse *> sessions_;
// Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.
std::vector<AsyncEventSourceResponse *> pending_sessions_;
Mutex pending_mutex_;
connect_handler_t on_connect_{};
esphome::web_server::WebServer *web_server_;
std::atomic<bool> has_pending_sessions_{false};
};
#endif // USE_WEBSERVER
@@ -1579,6 +1579,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) {
#endif
this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED;
// Refresh is_connected() cache; loop()'s refresh ran before this transition.
this->update_connected_state_();
this->num_retried_ = 0;
this->print_connect_params_();
@@ -951,6 +951,8 @@ void WiFiComponent::process_pending_callbacks_() {
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
if (this->pending_.disconnect) {
this->pending_.disconnect = false;
// Refresh is_connected() cache here, not in the SDK callback (sys context).
this->update_connected_state_();
this->notify_disconnect_state_listeners_();
}
#endif
@@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() {
#endif // USE_WIFI_AP
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
// cfg.nvs_enable = false;
if (global_preferences->nvs_handle == 0) {
ESP_LOGW(TAG, "starting wifi without nvs");
cfg.nvs_enable = false;
}
err = esp_wifi_init(&cfg);
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err));
@@ -804,6 +807,8 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
s_sta_connected = false;
s_sta_connecting = false;
error_from_callback_ = true;
// Refresh is_connected() cache; error_from_callback_ makes it false.
this->update_connected_state_();
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
this->notify_disconnect_state_listeners_();
#endif

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