Merge remote-tracking branch 'origin/cv-sensitive-redact-sentinel' into integration

This commit is contained in:
J. Nick Koston
2026-05-26 23:51:13 -05:00
211 changed files with 4484 additions and 1257 deletions
+1
View File
@@ -5,6 +5,7 @@ updates:
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
ignore:
# Hypotehsis is only used for testing and is updated quite often
- dependency-name: hypothesis
+1
View File
@@ -417,6 +417,7 @@ esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
esphome/components/rgbct/* @jesserockz
esphome/components/ring_buffer/* @kahrendt
esphome/components/router/speaker/* @kahrendt
esphome/components/rp2040/* @jesserockz
esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
+40 -10
View File
@@ -608,7 +608,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
process_stacktrace = getattr(module, "process_stacktrace")
process_stacktrace = module.process_stacktrace
except (AttributeError, ImportError):
_LOGGER.info(
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
@@ -639,7 +639,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
chunk = ser.read(ser.in_waiting or 1)
if not chunk:
continue
time_ = datetime.now()
time_ = datetime.now().astimezone()
milliseconds = time_.microsecond // 1000
time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]"
@@ -795,7 +795,7 @@ def _check_and_emit_build_info() -> None:
# Read build_info from JSON
try:
with open(build_info_json_path, encoding="utf-8") as f:
with build_info_json_path.open(encoding="utf-8") as f:
build_info = json.load(f)
except (OSError, json.JSONDecodeError) as e:
_LOGGER.debug("Failed to read build_info: %s", e)
@@ -1057,7 +1057,7 @@ def _wait_for_serial_port(
def _port_found() -> bool:
if port is not None:
if os.name == "posix":
return os.path.exists(port)
return Path(port).exists()
return any(p.path == port for p in get_serial_ports())
ports = get_serial_ports()
if known_ports is not None:
@@ -1102,7 +1102,7 @@ def upload_program(
host = devices[0]
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
if getattr(module, "upload_program")(config, args, host):
if module.upload_program(config, args, host):
return 0, host
except AttributeError:
pass
@@ -1354,7 +1354,7 @@ def _validate_bootloader_binary(binary: Path) -> None:
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
if getattr(module, "show_logs")(config, args, devices):
if module.show_logs(config, args, devices):
return 0
except AttributeError:
pass
@@ -1413,17 +1413,47 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
if not CORE.verbose:
config = strip_default_ids(config)
output = yaml_util.dump(config, args.show_secrets)
# add the console decoration so the front-end can hide the secrets
if not args.show_secrets:
output = re.sub(
r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
)
output = _redact_with_legacy_fallback(output)
if not CORE.quiet:
safe_print(output)
_LOGGER.info("Configuration is valid!")
return 0
# Legacy substring redaction fallback for unmigrated schemas; removed in
# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips
# values that already render themselves: ``\033[8m`` (SensitiveStr wrap),
# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line
# block; first line is structural). The fragment must either start the
# field name or follow ``_`` so the warning names a real field; this avoids
# false positives like ``monkey:`` matching the ``key`` fragment.
_LEGACY_REDACTION_RE = re.compile(
r"(?P<key>\b(?:\w+_)?(?:password|key|psk|ssid))\: "
r"(?!\\033\[8m|!secret\b|!lambda\b)(?P<val>.+)"
)
_LEGACY_REDACTION_REMOVAL = "2026.12.0"
def _redact_with_legacy_fallback(output: str) -> str:
unmarked: set[str] = set()
def _replace(m: re.Match[str]) -> str:
unmarked.add(m.group("key"))
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
output = _LEGACY_REDACTION_RE.sub(_replace, output)
for key in sorted(unmarked):
_LOGGER.warning(
"Field '%s' is being redacted by a legacy substring heuristic. "
"Mark this field's schema validator with cv.sensitive(...) for "
"deterministic redaction; the heuristic will be removed in %s.",
key,
_LEGACY_REDACTION_REMOVAL,
)
return output
def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
# generating code might modify config, so it must be done in order to generate
# a hash that will match what was generated when compiling and then running
+7 -6
View File
@@ -7,6 +7,7 @@ from collections.abc import Callable
import heapq
import json
from operator import itemgetter
from pathlib import Path
import sys
from typing import TYPE_CHECKING
@@ -510,7 +511,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):"
)
for i, (symbol, demangled, size) in enumerate(large_core_symbols):
for i, (_symbol, demangled, size) in enumerate(large_core_symbols):
# Core symbols only track (symbol, demangled, size) without section info,
# so we don't show section labels here
lines.append(
@@ -602,7 +603,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
)
for i, (symbol, demangled, size, section) in enumerate(large_symbols):
for i, (_symbol, demangled, size, section) in enumerate(large_symbols):
lines.append(
f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}"
)
@@ -641,7 +642,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):"
)
for symbol, demangled, size, section in large_ram_syms[:10]:
for _symbol, demangled, size, section in large_ram_syms[:10]:
# Format section label consistently by stripping leading dot
section_label = section.lstrip(".") if section else ""
display_name = _format_pstorage_name(demangled)
@@ -700,7 +701,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
content = "\n".join(lines)
if output_file:
with open(output_file, "w", encoding="utf-8") as f:
with Path(output_file).open("w", encoding="utf-8") as f:
f.write(content)
else:
print(content)
@@ -737,7 +738,7 @@ def main():
build_dir = sys.argv[1]
# Load build directory
from pathlib import Path
import json
from esphome.platformio.toolchain import IDEData
@@ -785,7 +786,7 @@ def main():
if not idedata_path.exists():
continue
try:
with open(idedata_path, encoding="utf-8") as f:
with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
+1 -1
View File
@@ -154,7 +154,7 @@ def batch_demangle(
failed_count = 0
for original, stripped, prefix, demangled in zip(
symbols, symbols_stripped, symbols_prefixes, demangled_lines
symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True
):
# Add back any prefix that was removed
demangled = _restore_symbol_prefix(prefix, stripped, demangled)
+1 -2
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
import os
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING
@@ -37,7 +36,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None:
Full path to the tool or None if not found
"""
# Get PlatformIO packages directory
platformio_home = Path(os.path.expanduser("~/.platformio/packages"))
platformio_home = Path("~/.platformio/packages").expanduser()
if not platformio_home.exists():
return None
+1 -1
View File
@@ -24,7 +24,7 @@ def get_available_components() -> list[str] | None:
return None
try:
with open(project_desc, encoding="utf-8") as f:
with project_desc.open(encoding="utf-8") as f:
data = json.load(f)
component_info = data.get("build_component_info", {})
+1 -1
View File
@@ -412,7 +412,7 @@ class ConfigBundleCreator:
@staticmethod
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
"""Add a BundleFile to the tar archive with deterministic metadata."""
with open(bf.source, "rb") as f:
with bf.source.open("rb") as f:
_add_bytes_to_tar(tar, bf.path, f.read())
+1 -1
View File
@@ -234,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation(
ENCRYPTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_KEY): validate_encryption_key,
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
}
)
+5 -7
View File
@@ -1169,13 +1169,11 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) {
void APIConnection::on_get_time_response(const GetTimeResponse &value) {
if (homeassistant::global_homeassistant_time != nullptr) {
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
#ifdef USE_TIME_TIMEZONE
// Only apply if the sender provided pre-parsed timezone data.
// Old clients (before 2026.3.0) only send the timezone string without the parsed struct,
// so all parsed_timezone fields default to zero — skip to keep the codegen-configured timezone.
// For actual UTC (all zeros), this also skips, which is harmless since UTC is the default.
// Eventually the timezone string will be removed and only the struct will be sent.
{
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
if (!value.timezone.empty()) {
// Check if the sender provided pre-parsed timezone data.
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
// For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent.
const auto &pt = value.parsed_timezone;
if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) {
time::ParsedTimezone tz{};
+4 -3
View File
@@ -1,6 +1,7 @@
#include "api_server.h"
#ifdef USE_API
#include <cerrno>
#include <cinttypes>
#include "api_connection.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
@@ -677,7 +678,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn
// Schedule automatic cleanup after timeout (client will have given up by then)
// Uses numeric ID overload to avoid heap allocation from str_sprintf
this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() {
ESP_LOGD(TAG, "Action call %u timed out", action_call_id);
ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id);
this->unregister_active_action_call(action_call_id);
});
@@ -721,7 +722,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message,
@@ -733,7 +734,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
}
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
+3 -2
View File
@@ -101,13 +101,14 @@ async def async_run_logs(
client_info=f"ESPHome Logs {__version__}",
noise_psk=noise_psk,
addresses=addresses, # Pass all addresses for automatic retry
provide_time=False,
)
# Try platform-specific stacktrace handler first, fall back to generic
platform_process_stacktrace = None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
platform_process_stacktrace = getattr(module, "process_stacktrace")
platform_process_stacktrace = module.process_stacktrace
except (AttributeError, ImportError):
_LOGGER.info(
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
@@ -118,7 +119,7 @@ async def async_run_logs(
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
time_ = datetime.now()
time_ = datetime.now().astimezone()
message: bytes = msg.message
text = message.decode("utf8", "backslashreplace")
nanoseconds = time_.microsecond // 1000
+1 -1
View File
@@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION):
if isinstance(value, str) and value.endswith("%"):
value = percent_to_position(value)
if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")):
if isinstance(value, str) and value.endswith(("°", "deg")):
return angle_to_position(
value,
min=round(min * POSITION_TO_ANGLE),
+41 -42
View File
@@ -9,9 +9,12 @@ namespace esphome::audio {
static const char *const TAG = "audio.decoder";
static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration
static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data
// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block,
// before yielding and returning.
static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32;
static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10;
AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
@@ -20,11 +23,13 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
}
esp_err_t AudioDecoder::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
// Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte
// aligned, so no frame alignment is required.
auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_);
if (source == nullptr) {
return ESP_ERR_NO_MEM;
// create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size)
return ESP_ERR_INVALID_ARG;
}
source->set_source(input_ring_buffer);
this->input_buffer_ = std::move(source);
return ESP_OK;
}
@@ -141,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
}
FileDecoderState state = FileDecoderState::MORE_TO_PROCESS;
uint32_t decoding_start = millis();
bool first_loop_iteration = true;
size_t bytes_processed = 0;
size_t bytes_available_before_processing = 0;
uint8_t no_output_iterations = 0;
while (state == FileDecoderState::MORE_TO_PROCESS) {
// Transfer decoded out
@@ -161,45 +160,39 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
this->playback_ms_ +=
this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_);
}
if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) {
// All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before
// decoding the next batch
return AudioDecoderState::DECODING;
}
} else {
// If paused, block to avoid wasting CPU resources
delay(READ_WRITE_TIMEOUT_MS);
}
// Verify there is enough space to store more decoded audio and that the function hasn't been running too long
if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) ||
(millis() - decoding_start > DECODING_TIMEOUT_MS)) {
if (this->output_transfer_buffer_->available() > 0) {
// Output transfer buffer indicates backpressure, return so caller can handle other events;
// e.g., stop/pause, before trying again
return AudioDecoderState::DECODING;
}
// Decode more audio
// Never shift the input buffer; every decoder buffers internally and consumes only what it processed.
size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) {
// Less data is available than what was processed in last iteration, so don't attempt to decode.
// This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer
// will shift the remaining data to the start and copy more from the source the next time the decode function is
// called
break;
// Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output
// stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this
// loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and
// the return keeps stop/pause responsive.
if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) {
delay(1);
return AudioDecoderState::DECODING;
}
bytes_available_before_processing = this->input_buffer_->available();
// Expose the next chunk of file data. Every decoder buffers internally and consumes only what it
// processed, so the source does not need to accumulate or stitch chunks across fill() calls.
this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) {
// Failed to decode in last attempt and there is no new data
const size_t available_before_decode = this->input_buffer_->available();
if ((this->input_buffer_->free() == 0) && first_loop_iteration) {
// The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact
// same data, we can never recover. For const sources this is correct: the entire file is already available, so
// a decode failure is genuine, not a transient out-of-data condition.
state = FileDecoderState::FAILED;
} else {
// Attempt to get more data next time
state = FileDecoderState::IDLE;
}
} else if (this->input_buffer_->available() == 0) {
if (available_before_decode == 0) {
// No data to decode, attempt to get more data next time
state = FileDecoderState::IDLE;
} else {
@@ -231,9 +224,6 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
}
}
first_loop_iteration = false;
bytes_processed = bytes_available_before_processing - this->input_buffer_->available();
if (state == FileDecoderState::POTENTIALLY_FAILED) {
++this->potentially_failed_count_;
} else if (state == FileDecoderState::END_OF_FILE) {
@@ -241,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
} else if (state == FileDecoderState::FAILED) {
return AudioDecoderState::FAILED;
} else if (state == FileDecoderState::MORE_TO_PROCESS) {
this->potentially_failed_count_ = 0;
// Reset the failsafe only when the iteration made forward progress: input was consumed or output was
// produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A
// MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it
// toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever.
if ((this->input_buffer_->available() < available_before_decode) ||
(this->output_transfer_buffer_->available() > 0)) {
this->potentially_failed_count_ = 0;
} else {
++this->potentially_failed_count_;
}
}
}
return AudioDecoderState::DECODING;
+5 -4
View File
@@ -61,15 +61,16 @@ class AudioDecoder {
*/
public:
/// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source()
/// @param input_buffer_size Size of the input transfer buffer in bytes.
/// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes.
/// @param output_buffer_size Size of the output transfer buffer in bytes.
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
~AudioDecoder() = default;
/// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr.
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
/// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr.
/// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource.
/// @param input_ring_buffer weak_ptr of the source ring buffer to read from
/// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
/// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr.
+45 -16
View File
@@ -12,16 +12,17 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20;
AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size)
: input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) {
this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size);
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
}
esp_err_t AudioResampler::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
if (this->input_transfer_buffer_ != nullptr) {
this->input_transfer_buffer_->set_source(input_ring_buffer);
return ESP_OK;
// The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring
// buffer (stored here) and the input stream info (set by start()) are available, in either order.
this->source_ring_buffer_ = input_ring_buffer.lock();
if (this->source_ring_buffer_ == nullptr) {
return ESP_ERR_INVALID_STATE;
}
return ESP_ERR_NO_MEM;
return ESP_OK;
}
esp_err_t AudioResampler::add_sink(std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer) {
@@ -47,7 +48,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
this->input_stream_info_ = input_stream_info;
this->output_stream_info_ = output_stream_info;
if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) {
if (this->output_transfer_buffer_ == nullptr) {
return ESP_ERR_NO_MEM;
}
@@ -56,6 +57,13 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
return ESP_ERR_NOT_SUPPORTED;
}
// Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks
// the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast.
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
return ESP_ERR_NOT_SUPPORTED;
}
if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) ||
(input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) {
this->resampler_ = make_unique<esp_audio_libs::resampler::Resampler>(
@@ -87,8 +95,27 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
}
AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) {
if (this->audio_source_ == nullptr) {
// Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are
// never split across the ring buffer's wrap boundary.
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
// Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would
// truncate it and could yield a source that tears frames.
return AudioResamplerState::FAILED;
}
// Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our
// reference only after the source has taken ownership.
this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_,
static_cast<uint8_t>(bytes_per_frame));
if (this->audio_source_ == nullptr) {
return AudioResamplerState::FAILED;
}
this->source_ring_buffer_.reset();
}
if (stop_gracefully) {
if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
return AudioResamplerState::FINISHED;
}
}
@@ -102,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
delay(READ_WRITE_TIMEOUT_MS);
}
this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS));
// Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource
// (there is no intermediate transfer buffer to compact).
this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
if (this->input_transfer_buffer_->available() == 0) {
if (this->audio_source_->available() == 0) {
// No samples available to process
return AudioResamplerState::RESAMPLING;
}
@@ -112,17 +141,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
const size_t bytes_free = this->output_transfer_buffer_->free();
const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free);
const size_t bytes_available = this->input_transfer_buffer_->available();
const size_t bytes_available = this->audio_source_->available();
const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available);
if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) ||
(this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) {
// Adjust gain by -3 dB to avoid clipping due to the resampling process
esp_audio_libs::resampler::ResamplerResults results =
this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(),
this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3);
this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(),
frames_available, frames_free, -3);
this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used));
this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used));
this->output_transfer_buffer_->increase_buffer_length(
this->output_stream_info_.frames_to_bytes(results.frames_generated));
@@ -146,10 +175,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free),
this->input_stream_info_.frames_to_bytes(frames_available));
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(),
(void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer);
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(),
bytes_to_transfer);
this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer);
this->audio_source_->consume(bytes_to_transfer);
this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer);
}
+10 -7
View File
@@ -22,7 +22,7 @@ namespace esphome::audio {
enum class AudioResamplerState : uint8_t {
RESAMPLING, // More data is available to resample
FINISHED, // All file data has been resampled and transferred
FAILED, // Unused state included for consistency among Audio classes
FAILED, // Failed to allocate the audio source
};
class AudioResampler {
@@ -32,14 +32,16 @@ class AudioResampler {
* component). Also supports converting bits per sample.
*/
public:
/// @brief Allocates the input and output transfer buffers
/// @param input_buffer_size Size of the input transfer buffer in bytes.
/// @brief Allocates the output transfer buffer. The input source is created later in resample().
/// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source.
/// @param output_buffer_size Size of the output transfer buffer in bytes.
AudioResampler(size_t input_buffer_size, size_t output_buffer_size);
/// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr.
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
/// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy
/// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first
/// resample() call, so add_source() and start() may be called in any order.
/// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership
/// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
/// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr.
@@ -78,7 +80,8 @@ class AudioResampler {
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
protected:
std::unique_ptr<AudioSourceTransferBuffer> input_transfer_buffer_;
std::shared_ptr<ring_buffer::RingBuffer> source_ring_buffer_;
std::unique_ptr<RingBufferAudioSource> audio_source_;
std::unique_ptr<AudioSinkTransferBuffer> output_transfer_buffer_;
size_t input_buffer_size_;
@@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) {
}
}
void RingBufferAudioSource::clear_buffered_data() {
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
this->current_data_ = nullptr;
this->current_available_ = 0;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
this->splice_length_ = 0;
this->ring_buffer_->reset();
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
@@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer {
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
void clear_buffered_data();
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
+2 -2
View File
@@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType:
def _validate_file_shorthand(value: str) -> ConfigType:
value = cv.string_strict(value)
if value.startswith("http://") or value.startswith("https://"):
if value.startswith(("http://", "https://")):
return _file_schema(
{
CONF_TYPE: TYPE_WEB,
@@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
else:
raise cv.Invalid("Unsupported file source")
with open(path, "rb") as f:
with path.open("rb") as f:
data = f.read()
try:
@@ -1,7 +1,5 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import audio, esp32, media_source, psram
from esphome.components import audio, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
@@ -21,19 +19,13 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
return config
def _validate_task_stack_in_psram(value: Any) -> bool:
if value := cv.boolean(value):
return cv.requires_component(psram.DOMAIN)(value)
return value
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioFileMediaSource,
)
.extend(
{
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -49,6 +41,4 @@ async def to_code(config: ConfigType) -> None:
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
)
psram.request_external_task_stack()
+3 -15
View File
@@ -1,7 +1,5 @@
from typing import Any
import esphome.codegen as cg
from esphome.components import audio, esp32, media_source, psram
from esphome.components import audio, 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
@@ -20,14 +18,6 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
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,
@@ -37,7 +27,7 @@ CONFIG_SCHEMA = cv.All(
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,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -53,7 +43,5 @@ async def to_code(config: ConfigType) -> None:
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
)
psram.request_external_task_stack()
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
@@ -144,7 +144,7 @@ void BluetoothConnection::loop() {
}
}
void BluetoothConnection::on_disconnect_complete_(esp_err_t reason) {
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
@@ -33,7 +33,7 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete_(esp_err_t reason) override;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
+1 -1
View File
@@ -169,7 +169,7 @@ async def to_code_base(config):
path = _compute_local_file_path(_compute_url(config))
try:
with open(path, encoding="utf-8") as f:
with path.open(encoding="utf-8") as f:
bsec2_iaq_config = f.read()
except Exception as e:
raise core.EsphomeError(
+7 -2
View File
@@ -16,9 +16,14 @@
#include <span>
#include <vector>
// On ESP8266 Arduino, BearSSL is the native crypto. The mbedtls headers can
// still be in scope when a sibling component (e.g. wireguard) pulls in
// esp_mbedtls_esp8266, but that build leaves MBEDTLS_GCM_C disabled so the
// gcm.h symbols are unresolved at link time. Force BearSSL on ESP8266 to
// avoid that linker error.
#if __has_include(<psa/crypto.h>)
#include <dsmr_parser/decryption/aes128gcm_tfpsa.h>
#elif __has_include(<mbedtls/gcm.h>)
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
#if __has_include(<mbedtls/esp_config.h>)
#include <mbedtls/esp_config.h>
#endif
@@ -33,7 +38,7 @@ namespace esphome::dsmr {
#if __has_include(<psa/crypto.h>)
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa;
#elif __has_include(<mbedtls/gcm.h>)
#elif !defined(USE_ESP8266) && __has_include(<mbedtls/gcm.h>)
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls;
#else
using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl;
+29 -13
View File
@@ -46,7 +46,7 @@ from esphome.const import (
Toolchain,
__version__,
)
from esphome.core import CORE, HexInt, Library
from esphome.core import CORE, EsphomeError, HexInt, Library
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_component
@@ -56,7 +56,7 @@ from esphome.types import ConfigType
from esphome.writer import clean_build, clean_cmake_cache
from .boards import BOARDS, STANDARD_BOARDS
from .const import ( # noqa
from .const import (
KEY_ARDUINO_LIBRARIES,
KEY_BOARD,
KEY_COMPONENTS,
@@ -86,7 +86,7 @@ from .const import ( # noqa
)
# force import gpio to register pin schema
from .gpio import esp32_pin_to_code # noqa
from .gpio import esp32_pin_to_code # noqa: F401
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["preferences"]
@@ -1816,12 +1816,12 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=maybe-uninitialized")
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_build_flag("-Wno-error=reorder")
cg.add_build_flag("-Wno-error=volatile")
cg.add_build_flag("-Wno-error=cpp")
# Demote IDF's blanket -Werror to warnings so third-party libs
# and user lambdas don't need a -Wno-error=<class> per warning.
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
# can't be globally undone); -Wno-error then handles the demotion.
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
cg.add_build_flag("-Wno-error")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
@@ -2658,13 +2658,29 @@ def copy_files():
def _decode_pc(config, addr):
from esphome.platformio import toolchain
# _decode_pc runs from the api log processor's asyncio callback, which
# only catches EsphomeError. Any other exception escaping here tears down
# the protocol and triggers an infinite reconnect/replay loop. Convert
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
# EsphomeError so the caller can disable decoding cleanly.
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain as idf_toolchain
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
try:
addr2line_path = idf_toolchain.get_addr2line_path()
firmware_elf_path = idf_toolchain.get_elf_path()
except RuntimeError as err:
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
addr2line_path = idedata.addr2line_path
firmware_elf_path = idedata.firmware_elf_path
if not addr2line_path or not firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # pylint: disable=broad-except
@@ -72,7 +72,7 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete_(ESP_GATT_CONN_TIMEOUT);
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -419,7 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete_(param->close.reason);
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {
@@ -145,7 +145,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete_(esp_err_t reason) {}
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
+9 -1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from esphome import pins
from esphome.components import esp32
from esphome.components.const import CONF_USE_PSRAM
import esphome.config_validation as cv
from esphome.const import (
CONF_CLK_PIN,
@@ -39,6 +40,7 @@ BASE_SCHEMA = cv.Schema(
cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True),
cv.Required(CONF_ACTIVE_HIGH): cv.boolean,
cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean,
}
)
@@ -242,6 +244,12 @@ async def to_code(config):
else:
_configure_spi(config)
# Place the transport mempool in PSRAM. Required on memory-tight host
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
# mempool allocation fails at boot with `sdio_mempool_create` assert.
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
# Library versions
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
@@ -249,7 +257,7 @@ async def to_code(config):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
@@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None:
return
path = CORE.relative_config_path(config[CONF_PATH])
with open(path, "rb") as f:
with path.open("rb") as f:
firmware_data = f.read()
calculated = hashlib.sha256(firmware_data).hexdigest()
expected = config[CONF_SHA256].lower()
@@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None:
if config[CONF_TYPE] == TYPE_EMBEDDED:
path = config[CONF_PATH]
with open(CORE.relative_config_path(path), "rb") as f:
with CORE.relative_config_path(path).open("rb") as f:
firmware_data = f.read()
rhs = [HexInt(x) for x in firmware_data]
arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8)
+1 -1
View File
@@ -133,7 +133,7 @@ CONFIG_SCHEMA = cv.All(
host=8082,
): cv.port,
cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean,
cv.Optional(CONF_PASSWORD): cv.string,
cv.Optional(CONF_PASSWORD): cv.sensitive(),
cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid(
f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode"
),
+1 -1
View File
@@ -17,7 +17,7 @@ from esphome.core import HexInt
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
AUTO_LOAD = ["network"]
byte_vector = cg.std_vector.template(cg.uint8)
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
@@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
#ifndef USE_WIFI
// Initialize LwIP stack for wake_loop_threadsafe() socket support
// When WiFi component is present, it handles esp_netif_init()
ESP_ERROR_CHECK(esp_netif_init());
#endif
if (this->enable_on_boot_) {
this->enable_();
} else {
@@ -174,8 +168,6 @@ void ESPNowComponent::enable() {
void ESPNowComponent::enable_() {
if (!this->is_wifi_enabled()) {
esp_event_loop_create_default();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
@@ -5,6 +5,7 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "w5500_custom_spi.h"
#include <lwip/dns.h>
#include <cinttypes>
@@ -163,11 +164,7 @@ void EthernetComponent::setup() {
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
#endif
err = esp_netif_init();
ESPHL_ERROR_CHECK(err, "ETH netif init error");
err = esp_event_loop_create_default();
ESPHL_ERROR_CHECK(err, "ETH event loop error");
// Network interface setup handled by network component
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
this->eth_netif_ = esp_netif_new(&cfg);
@@ -207,6 +204,10 @@ void EthernetComponent::setup() {
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
w5500_config.poll_period_ms = this->polling_interval_;
#endif
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
// runs the driver's init().
install_w5500_async_spi(w5500_config);
#elif defined(USE_ETHERNET_DM9051)
dm9051_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
@@ -0,0 +1,118 @@
#include "w5500_custom_spi.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <driver/spi_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring>
#include <new>
namespace esphome::ethernet {
namespace {
// Per-device context returned by init() and handed back to read/write/deinit.
struct W5500CustomSpiContext {
spi_device_handle_t handle;
SemaphoreHandle_t lock;
};
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
void *w5500_custom_spi_init(const void *spi_config) {
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
if (ctx == nullptr) {
return nullptr;
}
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
// byte in the address phase; mirror what the stock driver configures.
spi_device_interface_config_t devcfg = *config->spi_devcfg;
devcfg.command_bits = 16;
devcfg.address_bits = 8;
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
delete ctx;
return nullptr;
}
ctx->lock = xSemaphoreCreateMutex();
if (ctx->lock == nullptr) {
spi_bus_remove_device(ctx->handle);
delete ctx;
return nullptr;
}
return ctx;
}
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_bus_remove_device(ctx->handle);
vSemaphoreDelete(ctx->lock);
delete ctx;
return ESP_OK;
}
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
// accesses stay on the cheaper polling path. Used by both read and write.
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
esp_err_t ret;
if (len > W5500_SPI_BULK_THRESHOLD) {
ret = spi_device_transmit(ctx->handle, trans);
} else {
ret = spi_device_polling_transmit(ctx->handle, trans);
}
xSemaphoreGive(ctx->lock);
return ret;
}
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.tx_buffer = data;
return w5500_custom_spi_transfer(ctx, &trans, len);
}
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
// overwrites of adjacent registers (same guard the stock driver uses).
const bool use_rxdata = len <= 4;
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.rx_buffer = data;
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
if (use_rxdata && (ret == ESP_OK)) {
memcpy(data, trans.rx_data, len);
}
return ret;
}
} // namespace
void install_w5500_async_spi(eth_w5500_config_t &config) {
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
// spi_devcfg back out of it. The self-reference is valid because both the config and the
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
config.custom_spi_driver.config = &config;
config.custom_spi_driver.init = w5500_custom_spi_init;
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
config.custom_spi_driver.read = w5500_custom_spi_read;
config.custom_spi_driver.write = w5500_custom_spi_write;
}
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -0,0 +1,35 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <esp_idf_version.h>
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
// is no longer reachable through esp_eth.h and needs the explicit header.
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <esp_eth_mac_w5500.h>
#else
#include <esp_eth.h>
#endif
namespace esphome::ethernet {
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
//
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
// is cheaper than an interrupt round-trip.
//
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
// alive until esp_eth_mac_new_w5500() returns.
void install_w5500_async_spi(eth_w5500_config_t &config);
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -81,7 +81,7 @@ def _process_single_config(config: dict[str, Any]) -> None:
elif conf[CONF_TYPE] == TYPE_LOCAL:
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
else:
raise NotImplementedError()
raise NotImplementedError
if config[CONF_COMPONENTS] == "all":
num_components = len(list(components_dir.glob("*/__init__.py")))
+6 -4
View File
@@ -401,7 +401,7 @@ def validate_file_shorthand(value):
data[CONF_WEIGHT] = weight[1:]
return font_file_schema(data)
if value.startswith("http://") or value.startswith("https://"):
if value.startswith(("http://", "https://")):
return font_file_schema(
{
CONF_TYPE: TYPE_WEB,
@@ -563,13 +563,13 @@ async def to_code(config):
point_set.update(flatten(config[CONF_GLYPHS]))
# Create the codepoint to font file map
base_font = FONT_CACHE[config[CONF_FILE]]
point_font_map: dict[str, Face] = {c: base_font for c in point_set}
point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font)
# process extras, updating the map and extending the codepoint list
for extra in config[CONF_EXTRAS]:
extra_points = flatten(extra[CONF_GLYPHS])
point_set.update(extra_points)
extra_font = FONT_CACHE[extra[CONF_FILE]]
point_font_map.update({c: extra_font for c in extra_points})
point_font_map.update(dict.fromkeys(extra_points, extra_font))
codepoints = list(point_set)
codepoints.sort(key=functools.cmp_to_key(glyph_comparator))
@@ -594,7 +594,9 @@ async def to_code(config):
x.height,
]
for (x, y) in zip(
glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args]))
glyph_args,
list(accumulate([len(x.bitmap_data) for x in glyph_args])),
strict=True,
)
]
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.const import CONF_ID, CONF_TIMEZONE
from .. import homeassistant_ns
@@ -21,3 +21,5 @@ async def to_code(config):
await time_.register_time(var, config)
await cg.register_component(var, config)
cg.add_define("USE_HOMEASSISTANT_TIME")
if CONF_TIMEZONE not in config:
cg.add_define("USE_HOMEASSISTANT_TIMEZONE")
+1 -1
View File
@@ -14,7 +14,7 @@ from esphome.core import CORE
from .const import KEY_HOST
# force import gpio to register pin schema
from .gpio import host_pin_to_code # noqa
from .gpio import host_pin_to_code # noqa: F401
CODEOWNERS = ["@esphome/core", "@clydebarrow"]
AUTO_LOAD = ["network", "preferences"]
+4 -2
View File
@@ -1,3 +1,5 @@
from pathlib import Path
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32
@@ -63,7 +65,7 @@ CONF_JSON = "json"
def validate_url(value):
value = cv.url(value)
if value.startswith("http://") or value.startswith("https://"):
if value.startswith(("http://", "https://")):
return value
raise cv.Invalid("URL must start with 'http://' or 'https://'")
@@ -174,7 +176,7 @@ async def to_code(config):
if config.get(CONF_VERIFY_SSL):
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
with open(ca_cert_path, encoding="utf-8") as f:
with Path(ca_cert_path).open(encoding="utf-8") as f:
ca_cert_content = f.read()
cg.add(var.set_ca_certificate(ca_cert_content))
else:
@@ -57,7 +57,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All(
cv.Optional(CONF_MD5): cv.templatable(
cv.All(cv.string, cv.Length(min=32, max=32))
),
cv.Optional(CONF_PASSWORD): cv.templatable(cv.string),
cv.Optional(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string)),
cv.Optional(CONF_USERNAME): cv.templatable(cv.string),
cv.Required(CONF_URL): cv.templatable(cv.url),
}
+2 -2
View File
@@ -395,7 +395,7 @@ def download_image(value):
def is_svg_file(file):
if not file:
return False
with open(file, "rb") as f:
with Path(file).open("rb") as f:
return "<svg" in str(f.read(1024))
@@ -408,7 +408,7 @@ def validate_file_shorthand(value):
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
return download_gh_svg(parts[1], parts[0])
if value.startswith("http://") or value.startswith("https://"):
if value.startswith(("http://", "https://")):
return download_image(value)
value = cv.file_(value)
+8 -8
View File
@@ -28,7 +28,7 @@ from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed
from esphome.storage_json import StorageJSON
from . import gpio # noqa
from . import gpio # noqa: F401
from .const import (
COMPONENT_BK72XX,
CONF_GPIO_RECOVER,
@@ -513,13 +513,13 @@ async def component_to_code(config):
# apply LibreTiny options from framework: block
# setup LT logger to work nicely with ESPHome logger
lt_options = dict(
LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL],
LT_LOGGER_CALLER=0,
LT_LOGGER_TASK=0,
LT_LOGGER_COLOR=1,
LT_USE_TIME=1,
)
lt_options = {
"LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL],
"LT_LOGGER_CALLER": 0,
"LT_LOGGER_TASK": 0,
"LT_LOGGER_COLOR": 1,
"LT_USE_TIME": 1,
}
# enable/disable per-module debugging
for module in framework[CONF_DEBUG]:
if module == "NONE":
@@ -1,7 +1,7 @@
# Copyright (c) Kuba Szczodrzyński 2023-06-01.
# pylint: skip-file
# flake8: noqa
# ruff: noqa: C408, I001
import json
import re
@@ -313,8 +313,12 @@ def write_const(
# build component constants
comp_str = "\n".join(f'COMPONENT_{f} = "{f.lower()}"' for f in components)
# replace the 2nd regex group only
repl = lambda m: m.group(1) + comp_str + m.group(3)
code = re.sub(comp_regex, repl, code, flags=re.DOTALL | re.MULTILINE)
code = re.sub(
comp_regex,
lambda m: m.group(1) + comp_str + m.group(3),
code,
flags=re.DOTALL | re.MULTILINE,
)
# regex for finding the family list block
fam_regex = r"(# FAMILIES.+?\n)(.*?)(\n# FAMILIES)"
@@ -337,8 +341,12 @@ def write_const(
]
var_str = "\n".join(fam_lines)
# replace the 2nd regex group only
repl = lambda m: m.group(1) + var_str + m.group(3)
code = re.sub(fam_regex, repl, code, flags=re.DOTALL | re.MULTILINE)
code = re.sub(
fam_regex,
lambda m: m.group(1) + var_str + m.group(3),
code,
flags=re.DOTALL | re.MULTILINE,
)
# format with black
code = format_str(code, mode=FileMode())
+13 -11
View File
@@ -11,11 +11,19 @@
#include "esphome/core/time_64.h"
// IRAM_ATTR places a function in executable RAM so it is callable from an
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
// Each family uses a section its stock linker already routes to RAM:
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
// exception: its stock linker has no matching glob, so patch_linker.py
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
// LibreTiny families that need it share the same .sram.text input section
// name; how that section is routed into RAM differs per family:
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
// top of .ram_image2.data (which IS in ltchiptool's
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
// in .ram_image2.text but that output section is NOT in
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
// there is dropped from the flashed binary.
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
//
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
@@ -26,13 +34,7 @@
// layer.
#if defined(USE_BK72XX)
#define IRAM_ATTR
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
#else
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// LN882H: patch_linker.py.script injects *(.sram.text*) into
// .flash_copysection (> RAM0 AT> FLASH).
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
#endif
#define PROGMEM
@@ -6,12 +6,18 @@ import re
import subprocess
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
# section routed into RAM-executable memory (see esphome/core/hal.h).
# section routed into RAM-executable memory (see esphome/core/hal.h). The
# input section name is always .sram.text; only the output section it lands
# in differs per family.
#
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
# masks FIQ+IRQ around flash writes). On the remaining families:
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
# .ram_image2.text in sections_ram, so code there is silently dropped from
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
# .ram_image2.data (which IS extracted) instead.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
@@ -34,6 +40,20 @@ _KEEP_LINE = (
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
# Inject at the top of .ram_image2.data, before __data_start__ so our code
# does not fall inside the data range markers. .ram_image2.data is one of the
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
#
# The regex is intentionally strict (no attribute / ALIGN between the section
# name and the opening brace, brace on its own line). If a future AmbZ SDK
# linker template changes this format, _pre_link raises RuntimeError on the
# unpatched .ld file(s), and the RTL8710B CI compile job in
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
# rather than silently shipping a binary with IRAM_ATTR code dropped from
# one or both OTA slots.
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
def _detect(env):
@@ -71,12 +91,11 @@ def _inject_keep(host_section):
# Variants not listed here intentionally have no .ld patcher:
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
# already routes into .ram_image2.text (> BD_RAM).
# - RTL8720C: stock linker already consumes *(.sram.text*).
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
_PATCHERS_BY_VARIANT = {
"LN882H": (_inject_keep(_LN_COPY),),
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
}
@@ -87,13 +106,14 @@ def _patchers_for(variant):
def _pre_link(target, source, env):
build_dir = env.subst("$BUILD_DIR")
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
patched = 0
patched = []
unpatched = []
for name in ld_files:
path = os.path.join(build_dir, name)
with open(path, "r", encoding="utf-8") as fh:
original = fh.read()
if _MARKER in original:
patched += 1
patched.append(name)
continue
content = original
for fn in _patchers:
@@ -102,7 +122,9 @@ def _pre_link(target, source, env):
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
patched += 1
patched.append(name)
else:
unpatched.append(name)
if not patched:
raise RuntimeError(
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
@@ -110,6 +132,20 @@ def _pre_link(target, source, env):
build_dir
)
)
# Every .ld in the build must be patched. RTL8710B generates one .ld per
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
# boot after an OTA into that slot.
if unpatched:
raise RuntimeError(
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
"these. Update the regex to cover all linker scripts.".format(
len(unpatched), len(ld_files), build_dir,
", ".join(unpatched), _variant,
)
)
# Substrings matched against demangled names as a fallback on RTL8720C,
+1 -1
View File
@@ -58,7 +58,7 @@ from .effects import (
RGB_EFFECTS,
validate_effects,
)
from .types import ( # noqa
from .types import ( # noqa: F401
AddressableLight,
AddressableLightState,
ColorMode,
+1 -1
View File
@@ -514,7 +514,7 @@ def validate_printf(value):
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
)
""" # noqa
"""
matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE)
if len(matches) != len(value[CONF_ARGS]):
raise cv.Invalid(
+23 -6
View File
@@ -1,3 +1,4 @@
import functools
import importlib
from pathlib import Path
import pkgutil
@@ -79,7 +80,7 @@ from .schemas import (
WIDGET_TYPES,
any_widget_schema,
container_schema,
obj_schema,
obj_dict,
)
from .styles import styles_to_code, theme_to_code
from .touchscreens import touchscreen_schema, touchscreens_to_code
@@ -173,7 +174,7 @@ def generate_lv_conf_h():
if clashes:
LOGGER.warning(
"Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s",
sorted(list(clashes)),
sorted(clashes),
)
unused_defines = all_defines - lv_defines.keys() - defines_from_flags
@@ -518,16 +519,32 @@ def add_hello_world(config):
return config
def _theme_schema(value):
@functools.cache
def _build_theme_schema(
widget_types: tuple[tuple[str, widgets.WidgetType], ...],
) -> cv.Schema:
# The theme schema is value-independent: it depends only on the set of
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
# that an external component registering a new widget after the first
# validation (legal per any_widget_schema's lazy-evaluation contract)
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
# self-heals instead of stale-rejecting valid themes. See obj_dict() in
# schemas.py for why chained .extend() is avoided here.
return cv.Schema(
{
cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean,
**{
cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA)
for name, w in WIDGET_TYPES.items()
cv.Optional(name): cv.Schema(
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
)
for name, w in widget_types
},
}
)(value)
)
def _theme_schema(value: dict) -> dict:
return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
FINAL_VALIDATE_SCHEMA = final_validation
+1 -1
View File
@@ -335,7 +335,7 @@ TYPE_NONE = "none"
DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP")
LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [
LV_FONTS = [f"montserrat_{s}" for s in range(8, 50, 2)] + [
"dejavu_16_persian_hebrew",
"simsun_16_cjk",
"unscii_8",
-2
View File
@@ -6,7 +6,6 @@ from esphome.const import CONF_ARGS, CONF_FORMAT
CONF_IF_NAN = "if_nan"
# noqa
f_regex = re.compile(
r"""
( # start of capture group 1
@@ -20,7 +19,6 @@ f_regex = re.compile(
""",
flags=re.VERBOSE,
)
# noqa
c_regex = re.compile(
r"""
( # start of capture group 1
+1 -1
View File
@@ -239,7 +239,7 @@ def color_retmapper(value):
else:
r, g, b, _ = from_rgbw(cval)
return literal(f"lv_color_make({r}, {g}, {b})")
assert False
raise AssertionError(f"Unhandled lv_color value: {value!r}")
def option_string(value):
+104 -19
View File
@@ -22,6 +22,7 @@ from esphome.const import (
)
from esphome.core import TimePeriod
from esphome.core.config import StartupTrigger
from esphome.schema_extractors import EnableSchemaExtraction
from . import defines as df, lv_validation as lvalid
from .defines import (
@@ -378,18 +379,63 @@ TRIGGER_EVENT_MAP = {
}
def part_schema(parts):
def part_dict(parts: tuple[str, ...] | list[str]) -> dict[Any, Any]:
"""
Return the raw mapping used by part_schema, so callers can merge it into a
larger dict and avoid chained .extend() calls (each .extend() recompiles the
whole mapping, turning the build into O(N^2)).
Invariant: the source schemas spread here (STATE_SCHEMA, FLAG_SCHEMA, the
nested STATE_SCHEMA values) must use the default extra=PREVENT_EXTRA and
required=False and must not register any add_extra/prepend_extra
validators. Reaching into .schema and rebuilding via cv.Schema(...) keeps
only the mapping; non-default extra/required and any _extra_schemas would
be silently dropped.
"""
return {
**STATE_SCHEMA.schema,
**FLAG_SCHEMA.schema,
**{cv.Optional(part): STATE_SCHEMA for part in parts},
}
def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema:
"""
Generate a schema for the various parts (e.g. main:, indicator:) of a widget type
:param parts: The parts to include
:return: The schema
"""
return STATE_SCHEMA.extend(FLAG_SCHEMA).extend(
{cv.Optional(part): STATE_SCHEMA for part in parts}
)
return cv.Schema(part_dict(parts))
def automation_schema(typ: LvType):
def _lazy_validate_automation(extra_schema: dict) -> Callable[[Any], Any]:
"""Return a validator that defers building the validate_automation schema.
validate_automation() runs AUTOMATION_SCHEMA.extend(extra_schema), which
voluptuous compiles eagerly. automation_schema() builds ~60 of these per
widget type, and the vast majority of slots are never invoked by a given
user config. Deferring the build to first use removes that work from
schema-construction time.
When EnableSchemaExtraction is set (build_language_schema.py), fall back
to eager construction so the @schema_extractor("automation") decoration
inside validate_automation is registered.
"""
if EnableSchemaExtraction:
return validate_automation(extra_schema)
cached: Callable[[Any], Any] | None = None
def validator(value: Any) -> Any:
nonlocal cached
if cached is None:
cached = validate_automation(extra_schema)
return cached(value)
return validator
def automation_schema(typ: LvType) -> dict[Any, Any]:
events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS
if typ.has_on_value:
events = events + (CONF_ON_VALUE, CONF_ON_UPDATE)
@@ -404,7 +450,7 @@ def automation_schema(typ: LvType):
return {
**{
cv.Optional(event): validate_automation(
cv.Optional(event): _lazy_validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
Trigger.template(*get_trigger_args(event))
@@ -413,7 +459,7 @@ def automation_schema(typ: LvType):
)
for event in events
},
cv.Optional(CONF_ON_BOOT): validate_automation(
cv.Optional(CONF_ON_BOOT): _lazy_validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)}
),
}
@@ -462,23 +508,62 @@ def base_update_schema(widget_type: WidgetType | LvType, parts):
return schema
def obj_schema(widget_type: WidgetType):
# Memoize obj_dict() the same way _OBJ_SCHEMA_CACHE memoizes obj_schema().
# automation_schema(w.w_type) builds fresh Trigger.template(...) objects on
# every call, so without this cache _theme_schema pays that cost per widget
# per validation. Callers must treat the returned dict as immutable. The
# _theme_schema caller spreads it into a fresh dict, which is safe; the
# obj_schema caller passes it directly to cv.Schema(...) -- voluptuous stores
# the mapping by reference but never mutates it (.extend() copies first), so
# the alias is also safe today. Adding in-place mutation of obj_schema(w).schema
# would corrupt this cache.
_OBJ_DICT_CACHE: dict[int, tuple[WidgetType, dict[Any, Any]]] = {}
def obj_dict(widget_type: WidgetType) -> dict[Any, Any]:
"""
Return the raw mapping used by obj_schema, so callers can merge it into a
larger dict and avoid chained .extend() calls.
Inherits the same source-schema invariant documented on part_dict: any
schema spread into this mapping must use the default extra=PREVENT_EXTRA
and required=False and must carry no add_extra/prepend_extra validators.
The returned mapping is cached and must be treated as immutable by callers.
"""
cached = _OBJ_DICT_CACHE.get(id(widget_type))
if cached is not None and cached[0] is widget_type:
return cached[1]
built = {
**part_dict(widget_type.parts),
**ALIGN_TO_SCHEMA,
**automation_schema(widget_type.w_type),
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
cv.Optional(CONF_GROUP): cv.use_id(lv_group_t),
}
_OBJ_DICT_CACHE[id(widget_type)] = (widget_type, built)
return built
# Widget types are module-level singletons populated at import time, so we
# can cache compiled obj_schemas by widget_type identity for the lifetime of
# the process. The strong reference in the value keeps the key (an id()
# target) from being recycled.
_OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {}
def obj_schema(widget_type: WidgetType) -> cv.Schema:
"""
Create a schema for a widget type itself i.e. no allowance for children
:param widget_type:
:return:
"""
return (
part_schema(widget_type.parts)
.extend(ALIGN_TO_SCHEMA)
.extend(automation_schema(widget_type.w_type))
.extend(
{
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
cv.Optional(CONF_GROUP): cv.use_id(lv_group_t),
}
)
)
cached = _OBJ_SCHEMA_CACHE.get(id(widget_type))
if cached is not None and cached[0] is widget_type:
return cached[1]
schema = cv.Schema(obj_dict(widget_type))
_OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema)
return schema
ALIGN_TO_SCHEMA = {
+2 -1
View File
@@ -184,6 +184,7 @@ INDICATOR_ARC_SCHEMA = cv.Schema(
cv.Optional(CONF_START_VALUE): lv_float,
cv.Optional(CONF_END_VALUE): lv_float,
cv.Optional(CONF_OPA, default=1.0): opacity,
cv.Optional(CONF_ROUNDED, default=False): cv.boolean,
}
).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE))
@@ -417,7 +418,7 @@ class MeterType(WidgetType):
"arc_width": v[CONF_WIDTH],
"arc_color": v[CONF_COLOR],
"arc_opa": v[CONF_OPA],
"arc_rounded": v.get("arc_rounded", False),
"arc_rounded": v[CONF_ROUNDED],
}
if CONF_R_MOD in v:
get_warnings().add(
+1 -1
View File
@@ -97,7 +97,7 @@ class TabviewType(WidgetType):
tab_bar = Widget(bar_obj, obj_spec)
await set_obj_properties(tab_bar, tab_style)
if tab_items_style:
for index, tab_conf in enumerate(config[CONF_TABS]):
for index, _tab_conf in enumerate(config[CONF_TABS]):
await set_obj_properties(
Widget(lv_obj.get_child(bar_obj, index), button_spec),
tab_items_style,
@@ -7,7 +7,7 @@ from urllib.parse import urljoin
from esphome import automation, external_files, git
from esphome.automation import register_action, register_condition
import esphome.codegen as cg
from esphome.components import esp32, microphone, ota
from esphome.components import esp32, microphone, ota, psram
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
@@ -20,6 +20,7 @@ from esphome.const import (
CONF_RAW_DATA_ID,
CONF_REF,
CONF_REFRESH,
CONF_TASK_STACK_IN_PSRAM,
CONF_TYPE,
CONF_URL,
CONF_USERNAME,
@@ -358,6 +359,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_VAD): _maybe_empty_vad_schema,
cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_MODEL): cv.invalid(
f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter."
),
@@ -374,14 +376,14 @@ CONFIG_SCHEMA = cv.All(
def _load_model_data(manifest_path: Path):
with open(manifest_path, encoding="utf-8") as f:
with manifest_path.open(encoding="utf-8") as f:
manifest = json.load(f)
_validate_manifest_version(manifest)
model_path = manifest_path.parent / manifest[CONF_MODEL]
with open(model_path, "rb") as f:
with model_path.open("rb") as f:
model = f.read()
if manifest.get(KEY_VERSION) == 1:
@@ -451,6 +453,10 @@ async def to_code(config):
cg.add_define("USE_MICRO_WAKE_WORD")
ota.request_ota_state_listeners()
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1")
# Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn)
esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2")
@@ -33,7 +33,8 @@ static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072;
static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
enum EventGroupBits : uint32_t {
COMMAND_STOP = (1 << 0), // Signals the inference task should stop
COMMAND_STOP = (1 << 0), // Signals the inference task should stop
COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio
TASK_STARTING = (1 << 3),
TASK_RUNNING = (1 << 4),
@@ -114,13 +115,13 @@ void MicroWakeWord::setup() {
}
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (this->ring_buffer_.use_count() > 1) {
size_t bytes_free = temp_ring_buffer->free();
if (bytes_free < data.size()) {
xEventGroupSetBits(this->event_group_, EventGroupBits::WARNING_FULL_RING_BUFFER);
temp_ring_buffer->reset();
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
if (temp_ring_buffer->write_without_replacement(data.data(), data.size(), 0, false) == 0) {
xEventGroupSetBits(this->event_group_,
EventGroupBits::WARNING_FULL_RING_BUFFER | EventGroupBits::COMMAND_RESET_RING_BUFFER);
}
temp_ring_buffer->write((void *) data.data(), data.size());
}
});
@@ -146,56 +147,65 @@ void MicroWakeWord::inference_task(void *params) {
{ // Ensures any C++ objects fall out of scope to deallocate before deleting the task
const size_t new_bytes_to_process =
this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(this_mww->features_step_size_);
std::unique_ptr<audio::AudioSourceTransferBuffer> audio_buffer;
const auto &stream_info = this_mww->microphone_source_->get_audio_stream_info();
const size_t bytes_per_frame = stream_info.frames_to_bytes(1);
const size_t max_fill_bytes = stream_info.ms_to_bytes(this_mww->features_step_size_);
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE];
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
// Allocate audio transfer buffer
audio_buffer = audio::AudioSourceTransferBuffer::create(new_bytes_to_process);
if (audio_buffer == nullptr) {
// Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample.
const size_t ring_buffer_size =
(stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
if (temp_ring_buffer == nullptr) {
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
} else {
audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, max_fill_bytes,
static_cast<uint8_t>(bytes_per_frame));
if (audio_source == nullptr) {
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
} else {
this_mww->ring_buffer_ = temp_ring_buffer;
}
}
}
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
// Allocate ring buffer
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS));
if (temp_ring_buffer.use_count() == 0) {
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
}
audio_buffer->set_source(temp_ring_buffer);
this_mww->ring_buffer_ = temp_ring_buffer;
}
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
this_mww->microphone_source_->start();
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING);
while (!(xEventGroupGetBits(this_mww->event_group_) & COMMAND_STOP)) {
audio_buffer->transfer_data_from_source(pdMS_TO_TICKS(DATA_TIMEOUT_MS));
if (audio_buffer->available() < new_bytes_to_process) {
// Insufficient data to generate new spectrogram features, read more next iteration
continue;
while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) {
if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) {
// Producer asked us to drain; run the consumer-side reset from this thread.
audio_source->clear_buffered_data();
xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER);
}
// Generate new spectrogram features
uint32_t processed_samples = this_mww->generate_features_(
(int16_t *) audio_buffer->get_buffer_start(), audio_buffer->available() / sizeof(int16_t), features_buffer);
audio_buffer->decrease_buffer_length(processed_samples * sizeof(int16_t));
audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false);
// Run inference using the new spectorgram features
if (!this_mww->update_model_probabilities_(features_buffer)) {
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE);
break;
// The frontend buffers samples internally and only emits a feature once it has a full window, so we can
// hand it whatever the source exposes. The frontend consumes at least one sample per call, so available()
// strictly decreases and this loop always terminates.
while (audio_source->available() >= sizeof(int16_t)) {
const size_t samples_available = audio_source->available() / sizeof(int16_t);
const int16_t *audio_data = reinterpret_cast<const int16_t *>(audio_source->data());
size_t processed_samples = 0;
const bool feature_generated =
this_mww->generate_features_(audio_data, samples_available, features_buffer, &processed_samples);
audio_source->consume(processed_samples * sizeof(int16_t));
if (feature_generated) {
if (!this_mww->update_model_probabilities_(features_buffer)) {
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE);
break;
}
// Process each model's probabilities and possibly send a Detection Event to the queue
this_mww->process_probabilities_();
}
}
// Process each model's probabilities and possibly send a Detection Event to the queue
this_mww->process_probabilities_();
}
}
}
@@ -207,10 +217,7 @@ void MicroWakeWord::inference_task(void *params) {
FrontendFreeStateContents(&this_mww->frontend_state_);
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_STOPPED);
while (true) {
// Continuously delay until the main loop deletes the task
delay(10);
}
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
}
std::vector<WakeWordModel *> MicroWakeWord::get_wake_words() {
@@ -233,14 +240,14 @@ void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probabilit
#endif
void MicroWakeWord::suspend_task_() {
if (this->inference_task_handle_ != nullptr) {
vTaskSuspend(this->inference_task_handle_);
if (this->inference_task_.is_created()) {
vTaskSuspend(this->inference_task_.get_handle());
}
}
void MicroWakeWord::resume_task_() {
if (this->inference_task_handle_ != nullptr) {
vTaskResume(this->inference_task_handle_);
if (this->inference_task_.is_created()) {
vTaskResume(this->inference_task_.get_handle());
}
}
@@ -282,8 +289,7 @@ void MicroWakeWord::loop() {
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
vTaskDelete(this->inference_task_handle_);
this->inference_task_handle_ = nullptr;
this->inference_task_.deallocate();
xEventGroupClearBits(this->event_group_, ALL_BITS);
xQueueReset(this->detection_queue_);
this->set_state_(State::STOPPED);
@@ -301,7 +307,7 @@ void MicroWakeWord::loop() {
switch (this->state_) {
case State::STARTING:
if ((this->inference_task_handle_ == nullptr) && !this->status_has_error()) {
if (!this->inference_task_.is_created() && !this->status_has_error()) {
// Setup preprocesor feature generator. If done in the task, it would lock the task to its initial core, as it
// uses floating point operations.
if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_,
@@ -310,10 +316,8 @@ void MicroWakeWord::loop() {
return;
}
xTaskCreate(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this,
INFERENCE_TASK_PRIORITY, &this->inference_task_handle_);
if (this->inference_task_handle_ == nullptr) {
if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE,
(void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) {
FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state
this->status_momentary_error("task_start", 1000);
}
@@ -386,11 +390,15 @@ void MicroWakeWord::set_state_(State state) {
}
}
size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_available,
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]) {
size_t processed_samples = 0;
bool MicroWakeWord::generate_features_(const int16_t *audio_buffer, size_t samples_available,
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples) {
*processed_samples = 0;
struct FrontendOutput frontend_output =
FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, &processed_samples);
FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, processed_samples);
if (frontend_output.size == 0) {
return false;
}
for (size_t i = 0; i < frontend_output.size; ++i) {
// These scaling values are set to match the TFLite audio frontend int8 output.
@@ -415,7 +423,7 @@ size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_a
features_buffer[i] = static_cast<int8_t>(clamp<int32_t>(value, INT8_MIN, INT8_MAX));
}
return processed_samples;
return true;
}
void MicroWakeWord::process_probabilities_() {
@@ -11,6 +11,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/static_task.h"
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
@@ -59,6 +60,8 @@ class MicroWakeWord : public Component
void set_stop_after_detection(bool stop_after_detection) { this->stop_after_detection_ = stop_after_detection; }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
Trigger<std::string> *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; }
void add_wake_word_model(WakeWordModel *model);
@@ -93,6 +96,8 @@ class MicroWakeWord : public Component
bool stop_after_detection_;
bool task_stack_in_psram_{false};
uint8_t features_step_size_;
// Audio frontend handles generating spectrogram features
@@ -105,8 +110,9 @@ class MicroWakeWord : public Component
// Used to send messages about the models' states to the main loop
QueueHandle_t detection_queue_;
StaticTask inference_task_;
static void inference_task(void *params);
TaskHandle_t inference_task_handle_{nullptr};
/// @brief Suspends the inference task
void suspend_task_();
@@ -115,13 +121,16 @@ class MicroWakeWord : public Component
void set_state_(State state);
/// @brief Generates spectrogram features from an input buffer of audio samples
/// @param audio_buffer (int16_t *) Buffer containing input audio samples
/// @param samples_available (size_t) Number of samples avaiable in the input buffer
/// @param features_buffer (int8_t *) Buffer to store generated features
/// @return (size_t) Number of samples processed from the input buffer
size_t generate_features_(int16_t *audio_buffer, size_t samples_available,
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]);
/// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples
/// internally, so callers may stream arbitrary-sized chunks; a feature is only emitted once enough samples have
/// accumulated to fill a full analysis window.
/// @param audio_buffer (const int16_t *) Buffer containing input audio samples
/// @param samples_available (size_t) Number of samples available in the input buffer
/// @param features_buffer (int8_t *) Buffer to store the generated feature, valid only when the return value is true
/// @param processed_samples (size_t *) Set to the number of samples consumed from the input buffer
/// @return True if a new feature was generated; false if more samples are required
bool generate_features_(const int16_t *audio_buffer, size_t samples_available,
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples);
/// @brief Processes any new probabilities for each model. If any wake word is detected, it will send a DetectionEvent
/// to the detection_queue_.
+11 -1
View File
@@ -1,8 +1,14 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, uart
from esphome.components.climate import validate_climate_swing_mode
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
from esphome.const import (
CONF_ID,
CONF_SUPPORTED_SWING_MODES,
CONF_TEMPERATURE,
CONF_UPDATE_INTERVAL,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
@@ -43,6 +49,9 @@ CONFIG_SCHEMA = (
cv.Optional(
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s"
): cv.update_interval,
cv.Optional(
CONF_SUPPORTED_SWING_MODES, default="OFF"
): validate_climate_swing_mode,
}
)
)
@@ -63,6 +72,7 @@ async def to_code(config: ConfigType) -> None:
var = await climate.new_climate(config)
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES]))
cg.add(
var.set_current_temperature_min_interval(
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
@@ -1,5 +1,6 @@
#pragma once
#include <cmath>
#include <optional>
#include "esphome/components/uart/uart.h"
#include "esphome/core/finite_set_mask.h"
@@ -84,6 +84,8 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
traits.add_supported_fan_mode(p.second);
}
traits.set_supported_swing_modes(this->supported_swing_modes_);
traits.set_visual_min_temperature(16.0f);
traits.set_visual_max_temperature(31.0f);
traits.set_visual_temperature_step(1.0f);
@@ -114,6 +116,37 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
this->hp_.set_fan_mode(*fan_mode);
}
if (const auto swing_mode = call.get_swing_mode()) {
auto vane = this->last_non_swing_vane_mode_;
auto wide = this->last_non_swing_wide_vane_mode_;
switch (*swing_mode) {
case climate::CLIMATE_SWING_BOTH:
vane = MitsubishiCN105::VaneMode::SWING;
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_VERTICAL:
vane = MitsubishiCN105::VaneMode::SWING;
break;
case climate::CLIMATE_SWING_HORIZONTAL:
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
this->hp_.set_vane_mode(vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
this->hp_.set_wide_vane_mode(wide);
}
}
if (this->hp_.is_status_initialized()) {
this->apply_values_();
}
@@ -143,7 +176,64 @@ void MitsubishiCN105Climate::apply_values_() {
ESP_LOGD(TAG, "Unable to map fan mode");
}
if (!this->supported_swing_modes_.empty()) {
bool vertical_swinging = false;
bool horizontal_swinging = false;
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) {
vertical_swinging = true;
} else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
this->last_non_swing_vane_mode_ = status.vane_mode;
}
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
horizontal_swinging = true;
} else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode;
}
}
if (vertical_swinging && horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_BOTH;
} else if (vertical_swinging) {
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else if (horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
} else {
this->swing_mode = climate::CLIMATE_SWING_OFF;
}
}
this->publish_state();
}
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
this->supported_swing_modes_.clear();
switch (mode) {
case climate::CLIMATE_SWING_VERTICAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
break;
case climate::CLIMATE_SWING_HORIZONTAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
break;
case climate::CLIMATE_SWING_BOTH:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH);
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
}
}
} // namespace esphome::mitsubishi_cn105
@@ -25,10 +25,15 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
void set_supported_swing_mode(climate::ClimateSwingMode mode);
protected:
void apply_values_();
MitsubishiCN105 hp_;
climate::ClimateSwingModeMask supported_swing_modes_{};
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
};
template<typename... Ts>
+21 -21
View File
@@ -1,6 +1,6 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import audio, esp32, speaker
from esphome.components import audio, psram, speaker
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
@@ -44,20 +44,10 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend(
cv.positive_time_period_milliseconds,
cv.one_of(CONF_NEVER, lower=True),
),
cv.Optional(CONF_BITS_PER_SAMPLE, default=16): cv.int_range(16, 16),
}
)
def _set_stream_limits(config):
audio.set_stream_limits(
min_bits_per_sample=16,
max_bits_per_sample=16,
)(config)
return config
def _validate_source_speaker(config):
fconf = fv.full_config.get()
@@ -67,15 +57,25 @@ def _validate_source_speaker(config):
output_speaker_id = fconf.get_config_for_path(path)
config[CONF_OUTPUT_SPEAKER] = output_speaker_id
inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config)
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config)
inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config)
audio.final_validate_audio_schema(
"mixer",
audio_device=CONF_OUTPUT_SPEAKER,
sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
return config
def _validate_output_speaker(config):
audio.final_validate_audio_schema(
"mixer",
audio_device=CONF_OUTPUT_SPEAKER,
bits_per_sample=config.get(CONF_BITS_PER_SAMPLE),
channels=config.get(CONF_NUM_CHANNELS),
sample_rate=config.get(CONF_SAMPLE_RATE),
)(config)
return config
@@ -89,24 +89,26 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_SOURCE_SPEAKERS): cv.All(
cv.ensure_list(SOURCE_SPEAKER_SCHEMA),
cv.Length(min=2, max=8),
[_set_stream_limits],
),
cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True),
cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2),
cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean,
cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
}
),
cv.only_on([PLATFORM_ESP32]),
)
FINAL_VALIDATE_SCHEMA = cv.All(
inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER),
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER),
cv.Schema(
{
cv.Optional(CONF_SOURCE_SPEAKERS): [_validate_source_speaker],
},
extra=cv.ALLOW_EXTRA,
),
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER),
_validate_output_speaker,
)
@@ -116,16 +118,14 @@ async def to_code(config):
spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER])
cg.add(var.set_output_bits_per_sample(config[CONF_BITS_PER_SAMPLE]))
cg.add(var.set_output_channels(config[CONF_NUM_CHANNELS]))
cg.add(var.set_output_speaker(spkr))
cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE]))
if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(task_stack_in_psram))
if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]:
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
# Initialize FixedVector with exact count of source speakers
cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS])))
@@ -7,8 +7,10 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <mixer.h> // esp-audio-libs
#include <pcm_convert.h> // esp-audio-libs
#include <algorithm>
#include <array>
#include <cstring>
namespace esphome::mixer_speaker {
@@ -22,19 +24,8 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200;
static const size_t TASK_STACK_SIZE = 4096;
static const int16_t MAX_AUDIO_SAMPLE_VALUE = INT16_MAX;
static const int16_t MIN_AUDIO_SAMPLE_VALUE = INT16_MIN;
static const char *const TAG = "speaker_mixer";
// Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB
// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014)
// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15)
static const std::array<int16_t, 51> DECIBEL_REDUCTION_TABLE = {
32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183,
4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731,
651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103};
// Event bits for SourceSpeaker command processing
enum SourceSpeakerEventBits : uint32_t {
SOURCE_SPEAKER_COMMAND_START = (1 << 0),
@@ -315,97 +306,17 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBuffer
uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read);
if (samples_to_duck > 0) {
int16_t *current_buffer = reinterpret_cast<int16_t *>(audio_source->mutable_data());
duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_,
&this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_,
this->db_change_per_ducking_step_);
esp_audio_libs::ducking::apply(audio_source->mutable_data(),
static_cast<uint8_t>(this->audio_stream_info_.get_bits_per_sample() / 8),
samples_to_duck, this->ducking_state_);
}
return bytes_read;
}
void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) {
if (this->target_ducking_db_reduction_ != decibel_reduction) {
// Start transition from the previous target (which becomes the new current level)
this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_;
this->target_ducking_db_reduction_ = decibel_reduction;
// Calculate the number of intermediate dB steps for the transition timing.
// Subtract 1 because the first step is taken immediately after this calculation.
uint8_t total_ducking_steps = 0;
if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) {
// The dB reduction level is increasing (which results in quieter audio)
total_ducking_steps = this->target_ducking_db_reduction_ - this->current_ducking_db_reduction_ - 1;
this->db_change_per_ducking_step_ = 1;
} else {
// The dB reduction level is decreasing (which results in louder audio)
total_ducking_steps = this->current_ducking_db_reduction_ - this->target_ducking_db_reduction_ - 1;
this->db_change_per_ducking_step_ = -1;
}
if ((duration > 0) && (total_ducking_steps > 0)) {
this->ducking_transition_samples_remaining_ = this->audio_stream_info_.ms_to_samples(duration);
this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps;
this->ducking_transition_samples_remaining_ =
this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding
this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_;
} else {
this->ducking_transition_samples_remaining_ = 0;
this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_;
}
}
}
void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck,
int8_t *current_ducking_db_reduction, uint32_t *ducking_transition_samples_remaining,
uint32_t samples_per_ducking_step, int8_t db_change_per_ducking_step) {
if (*ducking_transition_samples_remaining > 0) {
// Ducking level is still transitioning
// Takes the ceiling of input_samples_to_duck/samples_per_ducking_step
uint32_t ducking_steps_in_batch =
input_samples_to_duck / samples_per_ducking_step + (input_samples_to_duck % samples_per_ducking_step != 0);
for (uint32_t i = 0; i < ducking_steps_in_batch; ++i) {
uint32_t samples_left_in_step = *ducking_transition_samples_remaining % samples_per_ducking_step;
if (samples_left_in_step == 0) {
samples_left_in_step = samples_per_ducking_step;
}
uint32_t samples_to_duck = std::min(input_samples_to_duck, samples_left_in_step);
samples_to_duck = std::min(samples_to_duck, *ducking_transition_samples_remaining);
// Ensure we only point to valid index in the Q15 scaling factor table
uint8_t safe_db_reduction_index =
clamp<uint8_t>(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1);
int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index];
audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, samples_to_duck);
if (samples_left_in_step - samples_to_duck == 0) {
// After scaling the current samples, we are ready to transition to the next step
*current_ducking_db_reduction += db_change_per_ducking_step;
}
input_buffer += samples_to_duck;
*ducking_transition_samples_remaining -= samples_to_duck;
input_samples_to_duck -= samples_to_duck;
}
}
if ((*current_ducking_db_reduction > 0) && (input_samples_to_duck > 0)) {
// Audio is ducked, but its not in the middle of a transition step
uint8_t safe_db_reduction_index =
clamp<uint8_t>(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1);
int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index];
audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, input_samples_to_duck);
}
const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0;
esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples);
}
void SourceSpeaker::enter_stopping_state_() {
@@ -417,8 +328,9 @@ void SourceSpeaker::enter_stopping_state_() {
void MixerSpeaker::dump_config() {
ESP_LOGCONFIG(TAG,
"Speaker Mixer:\n"
" Number of output channels: %u",
this->output_channels_);
" Number of output channels: %" PRIu8 "\n"
" Output bits per sample: %" PRIu8,
this->output_channels_, this->output_bits_per_sample_);
}
void MixerSpeaker::setup() {
@@ -512,13 +424,8 @@ void MixerSpeaker::loop() {
esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) {
if (!this->audio_stream_info_.has_value()) {
if (stream_info.get_bits_per_sample() != 16) {
// Audio streams that don't have 16 bits per sample are not supported
return ESP_ERR_NOT_SUPPORTED;
}
this->audio_stream_info_ = audio::AudioStreamInfo(stream_info.get_bits_per_sample(), this->output_channels_,
stream_info.get_sample_rate());
this->audio_stream_info_ =
audio::AudioStreamInfo(this->output_bits_per_sample_, this->output_channels_, stream_info.get_sample_rate());
this->output_speaker_->set_audio_stream_info(this->audio_stream_info_.value());
} else {
if (!this->queue_mode_ && (stream_info.get_sample_rate() != this->audio_stream_info_.value().get_sample_rate())) {
@@ -542,57 +449,6 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) {
return ESP_OK;
}
void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info,
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
uint32_t frames_to_transfer) {
uint8_t input_channels = input_stream_info.get_channels();
uint8_t output_channels = output_stream_info.get_channels();
const uint8_t max_input_channel_index = input_channels - 1;
if (input_channels == output_channels) {
size_t bytes_to_copy = input_stream_info.frames_to_bytes(frames_to_transfer);
memcpy(output_buffer, input_buffer, bytes_to_copy);
return;
}
for (uint32_t frame_index = 0; frame_index < frames_to_transfer; ++frame_index) {
for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) {
uint8_t input_channel_index = std::min(output_channel_index, max_input_channel_index);
output_buffer[output_channels * frame_index + output_channel_index] =
input_buffer[input_channels * frame_index + input_channel_index];
}
}
}
void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info,
const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info,
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
uint32_t frames_to_mix) {
const uint8_t primary_channels = primary_stream_info.get_channels();
const uint8_t secondary_channels = secondary_stream_info.get_channels();
const uint8_t output_channels = output_stream_info.get_channels();
const uint8_t max_primary_channel_index = primary_channels - 1;
const uint8_t max_secondary_channel_index = secondary_channels - 1;
for (uint32_t frames_index = 0; frames_index < frames_to_mix; ++frames_index) {
for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) {
const uint32_t secondary_channel_index = std::min(output_channel_index, max_secondary_channel_index);
const int32_t secondary_sample = secondary_buffer[frames_index * secondary_channels + secondary_channel_index];
const uint32_t primary_channel_index = std::min(output_channel_index, max_primary_channel_index);
const int32_t primary_sample =
static_cast<int32_t>(primary_buffer[frames_index * primary_channels + primary_channel_index]);
const int32_t added_sample = secondary_sample + primary_sample;
output_buffer[frames_index * output_channels + output_channel_index] =
static_cast<int16_t>(clamp<int32_t>(added_sample, MIN_AUDIO_SAMPLE_VALUE, MAX_AUDIO_SAMPLE_VALUE));
}
}
}
// NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created
void MixerSpeaker::audio_mixer_task(void *params) {
MixerSpeaker *this_mixer = static_cast<MixerSpeaker *>(params);
@@ -662,6 +518,10 @@ void MixerSpeaker::audio_mixer_task(void *params) {
uint32_t frames_to_mix = output_frames_free;
const audio::AudioStreamInfo &output_info = this_mixer->audio_stream_info_.value();
const uint8_t output_bps = output_info.get_bits_per_sample() / 8;
const uint8_t output_channels = output_info.get_channels();
if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) {
// Only one speaker has audio data, just copy samples over
@@ -669,14 +529,15 @@ void MixerSpeaker::audio_mixer_task(void *params) {
if (active_stream_info.get_sample_rate() ==
this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) {
// Speaker's sample rate matches the output speaker's, copy directly
// Speaker's sample rate matches the output speaker's, convert directly into the output buffer
const uint32_t frames_available_in_buffer =
active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
copy_frames(reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data()), active_stream_info,
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
esp_audio_libs::pcm_convert::copy_frames(
audio_sources_with_data[0]->data(), output_transfer_buffer->get_buffer_end(),
static_cast<uint8_t>(active_stream_info.get_bits_per_sample() / 8), active_stream_info.get_channels(),
output_bps, output_channels, frames_to_mix);
// Set playback delay for newly contributing source
if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) {
@@ -690,8 +551,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix));
// Update output transfer buffer length and pipeline frame count
output_transfer_buffer->increase_buffer_length(
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix));
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
} else {
// Speaker's stream info doesn't match the output speaker's, so it's a new source speaker
@@ -703,7 +563,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
} else {
// Speaker has finished writing the current audio, update the stream information and restart the speaker
this_mixer->audio_stream_info_ =
audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_,
audio::AudioStreamInfo(this_mixer->output_bits_per_sample_, this_mixer->output_channels_,
active_stream_info.get_sample_rate());
this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value());
this_mixer->output_speaker_->start();
@@ -719,21 +579,22 @@ void MixerSpeaker::audio_mixer_task(void *params) {
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
}
const int16_t *primary_buffer = reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data());
const uint8_t *primary_buffer = audio_sources_with_data[0]->data();
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
// Mix two streams together
// Mix two streams together at a time, accumulating into the output buffer.
for (size_t i = 1; i < audio_sources_with_data.size(); ++i) {
mix_audio_samples(primary_buffer, primary_stream_info,
reinterpret_cast<const int16_t *>(audio_sources_with_data[i]->data()),
speakers_with_data[i]->get_audio_stream_info(),
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
esp_audio_libs::mixer::mix_frames(
primary_buffer, static_cast<uint8_t>(primary_stream_info.get_bits_per_sample() / 8),
primary_stream_info.get_channels(), audio_sources_with_data[i]->data(),
static_cast<uint8_t>(speakers_with_data[i]->get_audio_stream_info().get_bits_per_sample() / 8),
speakers_with_data[i]->get_audio_stream_info().get_channels(), output_transfer_buffer->get_buffer_end(),
output_bps, output_channels, frames_to_mix);
if (i != audio_sources_with_data.size() - 1) {
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
primary_buffer = reinterpret_cast<const int16_t *>(output_transfer_buffer->get_buffer_end());
primary_stream_info = this_mixer->audio_stream_info_.value();
primary_buffer = output_transfer_buffer->get_buffer_end();
primary_stream_info = output_info;
}
}
@@ -754,8 +615,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
}
// Update output transfer buffer length and pipeline frame count (once, not per source)
output_transfer_buffer->increase_buffer_length(
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix));
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
}
}
@@ -11,6 +11,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/static_task.h"
#include <ducking.h> // esp-audio-libs
#include <freertos/event_groups.h>
#include <atomic>
@@ -22,7 +24,8 @@ namespace esphome::mixer_speaker {
* - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread
* safety
* - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker.
* - Audio sent to the SourceSpeaker must have 16 bits per sample.
* - Audio sent to the SourceSpeaker can have 8, 16, 24, or 32 bits per sample. Each source is converted to the output
* speaker's bit depth as it is mixed (or copied) into the output buffer.
* - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match
* the number of channels required for the output speaker.
* - In queue mode, the audio sent to the SourceSpeakers can have different sample rates.
@@ -93,19 +96,6 @@ class SourceSpeaker : public speaker::Speaker, public Component {
void enter_stopping_state_();
void send_command_(uint32_t command_bit, bool wake_loop = false);
/// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually
/// over a specified amount of samples.
/// @param input_buffer buffer with audio samples to be ducked in place
/// @param input_samples_to_duck number of samples to process in ``input_buffer``
/// @param current_ducking_db_reduction pointer to the current dB reduction
/// @param ducking_transition_samples_remaining pointer to the total number of samples left before the
/// transition is finished
/// @param samples_per_ducking_step total number of samples per ducking step for the transition
/// @param db_change_per_ducking_step the change in dB reduction per step
static void duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, int8_t *current_ducking_db_reduction,
uint32_t *ducking_transition_samples_remaining, uint32_t samples_per_ducking_step,
int8_t db_change_per_ducking_step);
MixerSpeaker *parent_;
std::shared_ptr<audio::RingBufferAudioSource> audio_source_;
@@ -118,11 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
bool pause_state_{false};
int8_t target_ducking_db_reduction_{0};
int8_t current_ducking_db_reduction_{0};
int8_t db_change_per_ducking_step_{1};
uint32_t ducking_transition_samples_remaining_{0};
uint32_t samples_per_ducking_step_{0};
esp_audio_libs::ducking::DuckingState ducking_state_{};
std::atomic<uint32_t> pending_playback_frames_{0};
std::atomic<uint32_t> playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing
@@ -143,12 +129,14 @@ class MixerSpeaker : public Component {
/// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information
/// @param stream_info The calling source speaker's audio stream information
/// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample
/// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream
/// @return ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream
/// ESP_OK if the incoming stream is compatible and the mixer task starts
esp_err_t start(audio::AudioStreamInfo &stream_info);
void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; }
void set_output_bits_per_sample(uint8_t output_bits_per_sample) {
this->output_bits_per_sample_ = output_bits_per_sample;
}
void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; }
void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; }
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
@@ -159,33 +147,6 @@ class MixerSpeaker : public Component {
uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); }
protected:
/// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels
/// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has
/// less channels, the extra channel input samples are dropped.
/// @param input_buffer
/// @param input_stream_info
/// @param output_buffer
/// @param output_stream_info
/// @param frames_to_transfer number of frames (consisting of a sample for each channel) to copy from the input buffer
static void copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer,
audio::AudioStreamInfo output_stream_info, uint32_t frames_to_transfer);
/// @brief Mixes the primary and secondary streams taking into account the number of channels in each stream. Primary
/// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number
/// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample
/// overflows.
/// @param primary_buffer samples buffer for the primary stream
/// @param primary_stream_info stream info for the primary stream
/// @param secondary_buffer samples buffer for secondary stream
/// @param secondary_stream_info stream info for the secondary stream
/// @param output_buffer buffer for the mixed samples
/// @param output_stream_info stream info for the output buffer
/// @param frames_to_mix number of frames in the primary and secondary buffers to mix together
static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info,
const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info,
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
uint32_t frames_to_mix);
static void audio_mixer_task(void *params);
EventGroupHandle_t event_group_{nullptr};
@@ -193,6 +154,7 @@ class MixerSpeaker : public Component {
FixedVector<SourceSpeaker *> source_speakers_;
speaker::Speaker *output_speaker_{nullptr};
uint8_t output_bits_per_sample_;
uint8_t output_channels_;
bool queue_mode_;
bool task_stack_in_psram_{false};
+1 -1
View File
@@ -232,7 +232,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_PORT, default=1883): cv.port,
cv.Optional(CONF_USERNAME, default=""): cv.string,
cv.Optional(CONF_PASSWORD, default=""): cv.string,
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
cv.Optional(CONF_CLIENT_ID): cv.string,
cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All(
+1 -1
View File
@@ -26,7 +26,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend(
),
key=CONF_NAME,
)
for event, icon in zip(EVENT_SENSORS, ICONS)
for event, icon in zip(EVENT_SENSORS, ICONS, strict=True)
}
)
+17
View File
@@ -1,3 +1,5 @@
import logging
from esphome import pins
import esphome.codegen as cg
from esphome.components import light
@@ -22,6 +24,7 @@ from esphome.const import (
Framework,
)
from esphome.core import CORE
from esphome.types import ConfigType
from ._methods import (
METHOD_BIT_BANG,
@@ -34,6 +37,8 @@ from ._methods import (
)
from .const import CHIP_TYPES, CONF_ASYNC, CONF_BUS, ONE_WIRE_CHIPS
_LOGGER = logging.getLogger(__name__)
neopixelbus_ns = cg.esphome_ns.namespace("neopixelbus")
NeoPixelBusLightOutputBase = neopixelbus_ns.class_(
"NeoPixelBusLightOutputBase", light.AddressableLight
@@ -134,6 +139,17 @@ def _validate(config):
return config
def _warn_esp32_deprecated(config: ConfigType) -> ConfigType:
if CORE.is_esp32:
_LOGGER.warning(
"'neopixelbus' on ESP32 is deprecated. The upstream library "
"(makuna/NeoPixelBus) is no longer actively maintained. Migrate "
"to 'esp32_rmt_led_strip'. Removal is targeted for 2027.1 but "
"may happen sooner once ESPHome moves to ESP-IDF 6."
)
return config
def _validate_method(value):
if value is None:
# default method is determined afterwards because it depends on the chip type chosen
@@ -195,6 +211,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
_choose_default_method,
_validate,
_warn_esp32_deprecated,
)
+16 -1
View File
@@ -5,8 +5,9 @@ import esphome.codegen as cg
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT
from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
AUTO_LOAD = ["mdns"]
@@ -19,6 +20,7 @@ KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
network_ns = cg.esphome_ns.namespace("network")
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
IPAddress = network_ns.class_("IPAddress")
@@ -107,6 +109,7 @@ def has_high_performance_networking() -> bool:
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
@@ -224,3 +227,15 @@ async def to_code(config):
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
if CORE.is_rp2040:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
# Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it
# emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config
# (above) separate from C++ object lifecycle and allows wiring in interface
# pointers via get_variable().
if CORE.is_esp32:
CORE.add_job(network_component_to_code, config)
@coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
async def network_component_to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -0,0 +1,33 @@
#include "network_component.h"
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/log.h"
#include "esp_err.h"
#include "esp_netif.h"
#include "esp_event.h"
namespace esphome::network {
static const char *const TAG = "network";
void NetworkComponent::setup() {
// Initialize ESP-IDF network interfaces and ensure the default event loop exists
esp_err_t err;
err = esp_netif_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
err = esp_event_loop_create_default();
// ESP_ERR_INVALID_STATE is returned if the default loop already exists,
// which is fine since we just want to make sure it exists
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
}
} // namespace esphome::network
#endif
@@ -0,0 +1,14 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/component.h"
namespace esphome::network {
class NetworkComponent : public Component {
public:
void setup() override;
// AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance.
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
};
} // namespace esphome::network
#endif
-11
View File
@@ -15,17 +15,6 @@ char *format_bytes_to(char *buffer, std::span<const uint8_t> bytes) {
return format_hex_pretty_to(buffer, FORMAT_BYTES_BUFFER_SIZE, bytes.data(), bytes.size(), ' ');
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
// Deprecated wrappers intentionally use heap-allocating version for backward compatibility
std::string format_uid(std::span<const uint8_t> uid) {
return format_hex_pretty(uid.data(), uid.size(), '-', false); // NOLINT
}
std::string format_bytes(std::span<const uint8_t> bytes) {
return format_hex_pretty(bytes.data(), bytes.size(), ' ', false); // NOLINT
}
#pragma GCC diagnostic pop
uint8_t guess_tag_type(uint8_t uid_length) {
if (uid_length == 4) {
return TAG_TYPE_MIFARE_CLASSIC;
-7
View File
@@ -63,13 +63,6 @@ static constexpr size_t FORMAT_BYTES_BUFFER_SIZE = 192;
/// Format bytes to buffer with ' ' separator (e.g., "04 11 22 33"). Returns buffer for inline use.
char *format_bytes_to(char *buffer, std::span<const uint8_t> bytes);
// Remove before 2026.6.0
ESPDEPRECATED("Use format_uid_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0")
std::string format_uid(std::span<const uint8_t> uid);
// Remove before 2026.6.0
ESPDEPRECATED("Use format_bytes_to() with stack buffer instead. Removed in 2026.6.0", "2025.12.0")
std::string format_bytes(std::span<const uint8_t> bytes);
uint8_t guess_tag_type(uint8_t uid_length);
int8_t get_mifare_classic_ndef_start_index(std::vector<uint8_t> &data);
bool decode_mifare_classic_tlv(std::vector<uint8_t> &data, uint32_t &message_length, uint8_t &message_start_index);
+1 -1
View File
@@ -65,7 +65,7 @@ from .const import (
)
# force import gpio to register pin schema
from .gpio import nrf52_pin_to_code # noqa
from .gpio import nrf52_pin_to_code # noqa: F401
CODEOWNERS = ["@tomaszduda23"]
AUTO_LOAD = ["zephyr", "preferences"]
+1 -1
View File
@@ -139,7 +139,7 @@ async def _smpmgr_upload_connected(
already_uploaded = True
if not already_uploaded:
with open(firmware, "rb") as file:
with firmware.open("rb") as file:
image = file.read()
upload_size = len(image)
progress = ProgressBar("Uploading")
+3 -7
View File
@@ -16,7 +16,7 @@ def define_has_component(component_type: str, keys: list[str]) -> None:
cg.add_define(
f"OPENTHERM_{component_type.upper()}_LIST(F, sep)",
cg.RawExpression(
" sep ".join(map(lambda key: f"F({key}_{component_type.lower()})", keys))
" sep ".join(f"F({key}_{component_type.lower()})" for key in keys)
),
)
for key in keys:
@@ -30,12 +30,8 @@ def define_has_settings(keys: list[str], schemas: dict[str, SettingSchema]) -> N
"OPENTHERM_SETTING_LIST(F, sep)",
cg.RawExpression(
" sep ".join(
map(
lambda key: (
f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})"
),
keys,
)
f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})"
for key in keys
)
),
)
@@ -21,7 +21,7 @@ async def new_openthermoutput(
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await output.register_output(var, config)
cg.add(getattr(var, "set_id")(cg.RawExpression(f'"{key}_{config[CONF_ID]}"')))
cg.add(var.set_id(cg.RawExpression(f'"{key}_{config[CONF_ID]}"')))
input.generate_setters(var, config)
return var
@@ -35,9 +35,8 @@ void OpenThreadComponent::setup() {
esp_vfs_eventfd_config_t eventfd_config = {
.max_fds = 3,
};
// Network interface setup handled by network component
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config));
xTaskCreate(
+36 -8
View File
@@ -112,7 +112,7 @@ def expand_file_to_files(config: dict):
def validate_yaml_filename(value):
value = cv.string(value)
if not (value.endswith(".yaml") or value.endswith(".yml")):
if not value.endswith((".yaml", ".yml")):
raise cv.Invalid("Only YAML (.yaml / .yml) files are supported.")
return value
@@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
If loading fails after cloning, attempts a revert and retry in case
a prior cached checkout is stale.
"""
repo_dir, revert = git.clone_or_update(
repo_root, revert = git.clone_or_update(
url=config[CONF_URL],
ref=config.get(CONF_REF),
refresh=config[CONF_REFRESH],
@@ -225,6 +225,10 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
)
files: list[dict[str, Any]] = []
# ``repo_root`` is the directory containing ``.git`` and must be passed
# to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a
# subdirectory via the user's CONF_PATH and is used for file lookups.
repo_dir = repo_root
if base_path := config.get(CONF_PATH):
repo_dir = repo_dir / base_path
@@ -236,13 +240,37 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
"""Load a YAML file from a remote package, validating min_version."""
try:
new_yaml = yaml_util.load_yaml(yaml_file)
except EsphomeError as e:
def _load(path: Path) -> dict | str | None:
try:
return yaml_util.load_yaml(path)
except EsphomeError as e:
raise cv.Invalid(
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
new_yaml = _load(yaml_file)
if not isinstance(new_yaml, dict):
# On Windows, git defaults to core.symlinks=false unless the user
# has Developer Mode enabled or is running elevated. Files stored
# in the repo as symlinks (tree mode 120000) are then checked out
# as plain text files containing the symlink target path, so
# parsing them as YAML yields a bare scalar instead of a mapping.
# Best-effort: follow the symlink target ourselves and re-load.
target = git.resolve_symlink_stub(repo_root, yaml_file)
if target is not None:
new_yaml = _load(target)
if not isinstance(new_yaml, dict):
raise cv.Invalid(
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
f"{filename} does not contain a YAML mapping at the top level "
f"(got {type(new_yaml).__name__}). "
f"If this file is a git symlink in the source repository, it "
f"may not have been materialized correctly on your platform "
f"(this is a known issue with git on Windows without Developer "
f"Mode enabled). Try pointing your package at the real file "
f"path instead."
)
esphome_config = new_yaml.get(CONF_ESPHOME) or {}
min_version = esphome_config.get(CONF_MIN_VERSION)
if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
+22
View File
@@ -1,5 +1,6 @@
import logging
import textwrap
from typing import Any
import esphome.codegen as cg
from esphome.components.const import CONF_IGNORE_NOT_FOUND
@@ -94,6 +95,27 @@ def is_guaranteed() -> bool:
return CORE.data.get(KEY_PSRAM_GUARANTEED, False)
def request_external_task_stack() -> None:
"""Allow FreeRTOS task stacks to be allocated in external RAM (PSRAM).
Components that expose a ``task_stack_in_psram`` option should call this from their
``to_code`` when the option is enabled. The sdkconfig option only permits external
stacks; it does not move any stack into PSRAM on its own, so it stays opt-in per task.
"""
add_idf_sdkconfig_option("CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True)
def validate_task_stack_in_psram(value: Any) -> bool:
"""Validate a ``task_stack_in_psram`` boolean, requiring the psram component only when enabled.
Validating the boolean first means an explicit ``false`` does not pull in the psram
requirement, so the option can still be set to false on devices without PSRAM.
"""
if value := cv.boolean(value):
return cv.requires_component(DOMAIN)(value)
return value
def validate_psram_mode(config):
esp32_config = fv.full_config.get()[PLATFORM_ESP32]
if config[CONF_SPEED] == "120MHZ":
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import audio, esp32, speaker
from esphome.components import audio, psram, speaker
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
@@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_BUFFER_DURATION, default="100ms"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean,
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024),
cv.Optional(CONF_TAPS, default=16): _validate_taps,
}
@@ -88,9 +88,7 @@ async def to_code(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
)
psram.request_external_task_stack()
cg.add(var.set_target_bits_per_sample(config[CONF_BITS_PER_SAMPLE]))
cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE]))
@@ -0,0 +1,123 @@
from esphome import automation, core
import esphome.codegen as cg
from esphome.components import audio, speaker
import esphome.config_validation as cv
from esphome.const import (
CONF_BITS_PER_SAMPLE,
CONF_ID,
CONF_NUM_CHANNELS,
CONF_OUTPUT_SPEAKER,
CONF_SAMPLE_RATE,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
CODEOWNERS = ["@kahrendt"]
CONF_OUTPUT_SPEAKERS = "output_speakers"
CONF_TARGET_SPEAKER = "target_speaker"
router_ns = cg.esphome_ns.namespace("router")
Router = router_ns.class_("Router", cg.Component, speaker.Speaker)
SwitchOutputAction = router_ns.class_("SwitchOutputAction", automation.Action)
SpeakerPtr = speaker.Speaker.operator("ptr")
def _set_stream_limits(config: ConfigType) -> ConfigType:
# Lock the router's stream limits to the user-declared format. Limits are set
# at CONFIG_SCHEMA time so they're visible to other components' FINAL_VALIDATE
# (which has no guaranteed ordering vs. ours).
audio.set_stream_limits(
min_bits_per_sample=config[CONF_BITS_PER_SAMPLE],
max_bits_per_sample=config[CONF_BITS_PER_SAMPLE],
min_channels=config[CONF_NUM_CHANNELS],
max_channels=config[CONF_NUM_CHANNELS],
min_sample_rate=config[CONF_SAMPLE_RATE],
max_sample_rate=config[CONF_SAMPLE_RATE],
)(config)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Router),
cv.Required(CONF_OUTPUT_SPEAKERS): cv.All(
cv.ensure_list(cv.use_id(speaker.Speaker)),
cv.Length(min=2, max=8),
),
# All outputs must agree on a single format so the producer can keep
# streaming through a switch without reconfiguring. These are required
# rather than inherited because downstream components (e.g. mixer)
# read them from the router's declaration during FINAL_VALIDATE,
# which can't depend on our FINAL_VALIDATE running first.
cv.Required(CONF_BITS_PER_SAMPLE): cv.int_range(8, 32),
cv.Required(CONF_NUM_CHANNELS): cv.int_range(1, 2),
cv.Required(CONF_SAMPLE_RATE): cv.int_range(8000, 96000),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
_set_stream_limits,
)
def _final_validate(config: ConfigType) -> ConfigType:
# Validate every configured output speaker can accept the router's format.
# Switching to an output that can't reproduce the format the producer is
# already sending would otherwise fail silently at runtime.
for spk_id in config[CONF_OUTPUT_SPEAKERS]:
proxy = {**config, CONF_OUTPUT_SPEAKER: spk_id}
audio.final_validate_audio_schema(
"router",
audio_device=CONF_OUTPUT_SPEAKER,
bits_per_sample=config[CONF_BITS_PER_SAMPLE],
channels=config[CONF_NUM_CHANNELS],
sample_rate=config[CONF_SAMPLE_RATE],
)(proxy)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# The first configured output is the default active output on boot.
speakers = config[CONF_OUTPUT_SPEAKERS]
cg.add(var.set_output_count(len(speakers)))
for spk_id in speakers:
spk = await cg.get_variable(spk_id)
cg.add(var.add_output(spk))
@automation.register_action(
"router.speaker.switch_output",
SwitchOutputAction,
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.use_id(Router),
cv.Required(CONF_TARGET_SPEAKER): cv.templatable(
cv.use_id(speaker.Speaker)
),
}
),
synchronous=True,
)
async def switch_output_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
parent = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, parent)
target = config[CONF_TARGET_SPEAKER]
if not isinstance(target, core.Lambda):
target = await cg.get_variable(target)
template_ = await cg.templatable(target, args, SpeakerPtr)
cg.add(var.set_target(template_))
return var
@@ -0,0 +1,236 @@
#include "router_speaker.h"
#ifdef USE_ESP32
#include "esphome/core/log.h"
#include "esp_timer.h"
#include <algorithm>
namespace esphome::router {
static const char *const TAG = "router.speaker";
static inline uint32_t atomic_subtract_clamped(std::atomic<uint32_t> &var, uint32_t amount) {
uint32_t current = var.load(std::memory_order_acquire);
uint32_t subtracted = 0;
if (current > 0) {
uint32_t new_value;
do {
subtracted = std::min(amount, current);
new_value = current - subtracted;
} while (!var.compare_exchange_weak(current, new_value, std::memory_order_release, std::memory_order_acquire));
}
return subtracted;
}
void Router::setup() {
// Register a callback on every configured output. Each lambda captures its own
// index and only forwards when that output is the active one. This is required
// because CallbackManager has no remove() API.
for (size_t i = 0; i < this->outputs_.size(); i++) {
this->outputs_[i]->add_audio_output_callback([this, i](uint32_t frames, int64_t timestamp_us) {
// Always suppress the draining previous output during a switch, even if it's
// also the reselected active output (switching back to the bus holder).
// loop() fires one synthetic credit for its in-flight frames instead.
if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) == static_cast<int8_t>(i)) {
return;
}
if (this->active_output_idx_.load(std::memory_order_relaxed) != static_cast<int8_t>(i)) {
return;
}
atomic_subtract_clamped(this->frames_in_pipeline_, frames);
this->audio_output_callback_.call(frames, timestamp_us);
});
}
}
void Router::loop() {
speaker::Speaker *active = this->get_active_output();
// Mid-switch: the new output's start() is deferred until the previous output
// fully releases shared hardware (e.g. a single i2s_audio bus driving two
// speakers). Starting earlier produces "Parent bus is busy" retries. The
// synthetic-credit callback is also deferred until prev is fully stopped, so
// that once its task has drained no natural callbacks can race ours.
const int8_t pending_prev_idx = this->pending_start_prev_idx_.load(std::memory_order_relaxed);
if (pending_prev_idx >= 0) {
speaker::Speaker *prev = this->outputs_[pending_prev_idx];
if (prev->is_stopped()) {
this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed);
// Credit any frames left in prev's ring buffer / DMA so producer frame
// accounting (SpeakerSourceMediaPlayer pending_frames, sendspin/AEC
// clocks) clears cleanly. The leftover audio is intentionally dropped and
// the producer is told it played "now", giving a clean discontinuity that
// keeps frame accounting consistent across the switch.
const uint32_t in_flight = this->frames_in_pipeline_.exchange(0, std::memory_order_acq_rel);
if (in_flight > 0) {
this->audio_output_callback_.call(in_flight, esp_timer_get_time());
}
this->apply_cached_state_to_active_();
this->state_ = speaker::STATE_STARTING;
active->start();
}
return;
}
// Mirror the active output's running/stopped state into our own state_ so that
// is_running() / is_stopped() stay accurate from the producer's perspective.
// Also catch the active output self-stopping (e.g. i2s_audio silence timeout):
// without this, our state_ would stay RUNNING forever and the next play() would
// skip start(). The output retains its own volume/mute across a restart (and we
// forward those live regardless), but stream info arrives via the non-virtual
// set_audio_stream_info() and never reaches the output on its own; if the format
// changed while stopped, only start()'s apply_cached_state_to_active_() pushes it
// down before the output's play()-side auto-start locks in the stale format.
if (active->is_stopped()) {
this->state_ = speaker::STATE_STOPPED;
} else if (this->state_ == speaker::STATE_STARTING && active->is_running()) {
this->state_ = speaker::STATE_RUNNING;
}
}
void Router::dump_config() {
ESP_LOGCONFIG(TAG,
"Router Speaker:\n"
" Outputs: %u",
static_cast<unsigned>(this->outputs_.size()));
}
size_t Router::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) {
speaker::Speaker *active = this->get_active_output();
// Drop frames during a mid-switch until the old output releases shared hardware;
// forwarding now would trigger the new output's play()-side auto-start while
// the bus is still busy.
if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) {
vTaskDelay(ticks_to_wait);
return 0;
}
// Producers (e.g. mixer) set stream info on us and then drive play() from a
// task without ever calling our start(). i2s_audio's play() auto-starts the
// underlying driver, so we must push our cached stream info to the active
// output before that auto-start, or it locks to its default (16k mono).
if (this->state_ == speaker::STATE_STOPPED) {
this->start();
vTaskDelay(ticks_to_wait);
ticks_to_wait = 0;
}
size_t written = active->play(data, length, ticks_to_wait);
if (written > 0) {
const uint32_t frames = this->audio_stream_info_.bytes_to_frames(written);
this->frames_in_pipeline_.fetch_add(frames, std::memory_order_release);
}
return written;
}
void Router::start() {
this->frames_in_pipeline_.store(0, std::memory_order_release);
this->apply_cached_state_to_active_();
this->state_ = speaker::STATE_STARTING;
this->get_active_output()->start();
}
void Router::stop() {
// Cancel any pending mid-switch start; the producer wants us stopped.
this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed);
this->state_ = speaker::STATE_STOPPING;
this->get_active_output()->stop();
}
void Router::finish() {
this->pending_start_prev_idx_.store(-1, std::memory_order_relaxed);
this->state_ = speaker::STATE_STOPPING;
this->get_active_output()->finish();
}
bool Router::has_buffered_data() const { return this->get_active_output()->has_buffered_data(); }
void Router::set_pause_state(bool pause_state) {
this->cached_pause_ = pause_state;
this->get_active_output()->set_pause_state(pause_state);
}
void Router::set_volume(float volume) {
this->volume_ = volume;
this->get_active_output()->set_volume(volume);
}
void Router::set_mute_state(bool mute_state) {
this->mute_state_ = mute_state;
this->get_active_output()->set_mute_state(mute_state);
}
bool Router::switch_to_output(speaker::Speaker *target) {
if (target == nullptr) {
return false;
}
int8_t new_idx = -1;
for (size_t i = 0; i < this->outputs_.size(); i++) {
if (this->outputs_[i] == target) {
new_idx = static_cast<int8_t>(i);
break;
}
}
if (new_idx < 0) {
ESP_LOGW(TAG, "Switch target is not a configured output");
return false;
}
if (new_idx == this->active_output_idx_.load(std::memory_order_relaxed)) {
return true;
}
// A switch is already in flight: pending_start_prev_idx_ is still releasing the
// shared bus and the current active output's start() is still deferred (it never
// started). Just redirect which output we start once the bus frees. Leave the bus
// holder (pending_start_prev_idx_), the in-flight frame counter (loop() still owes one
// synthetic credit for the bus holder's in-flight frames), and state_ alone, and
// don't stop the current active output, which never started.
if (this->pending_start_prev_idx_.load(std::memory_order_relaxed) >= 0) {
this->active_output_idx_.store(new_idx, std::memory_order_relaxed);
return true;
}
const bool was_active = (this->state_ == speaker::STATE_STARTING || this->state_ == speaker::STATE_RUNNING);
const int8_t old_idx = this->active_output_idx_.load(std::memory_order_relaxed);
if (was_active) {
this->outputs_[old_idx]->stop();
}
this->active_output_idx_.store(new_idx, std::memory_order_relaxed);
if (was_active) {
// Defer start and the synthetic-credit callback until the old output's
// task is fully stopped; loop() handles both. Firing the synthetic credit
// here would race the old task's still-in-flight natural callbacks,
// dispatching audio_output_callback_ concurrently from two threads, which
// some consumers (e.g. sendspin's progress sync) aren't reentrant-safe for.
// STATE_STOPPING keeps producers from observing a transient stopped state
// and lets our play() short-circuit so the new output's play() doesn't
// auto-start it while the shared bus is still being released.
this->state_ = speaker::STATE_STOPPING;
this->pending_start_prev_idx_.store(old_idx, std::memory_order_relaxed);
} else {
this->frames_in_pipeline_.store(0, std::memory_order_release);
}
return true;
}
void Router::apply_cached_state_to_active_() {
speaker::Speaker *active = this->get_active_output();
active->set_audio_stream_info(this->audio_stream_info_);
active->set_volume(this->volume_);
active->set_mute_state(this->mute_state_);
active->set_pause_state(this->cached_pause_);
}
} // namespace esphome::router
#endif // USE_ESP32
@@ -0,0 +1,92 @@
#pragma once
#ifdef USE_ESP32
#include "esphome/components/speaker/speaker.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <freertos/FreeRTOS.h>
#include <atomic>
namespace esphome::router {
class Router : public Component, public speaker::Speaker {
public:
float get_setup_priority() const override { return setup_priority::DATA; }
void setup() override;
void loop() override;
void dump_config() override;
size_t play(const uint8_t *data, size_t length) override { return this->play(data, length, 0); }
size_t play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) override;
void start() override;
void stop() override;
void finish() override;
bool has_buffered_data() const override;
void set_pause_state(bool pause_state) override;
bool get_pause_state() const override { return this->cached_pause_; }
void set_volume(float volume) override;
float get_volume() override { return this->volume_; }
void set_mute_state(bool mute_state) override;
bool get_mute_state() override { return this->mute_state_; }
// Allocate the output list to its final size. Must be called before add_output().
void set_output_count(size_t count) { this->outputs_.init(count); }
void add_output(speaker::Speaker *spk) { this->outputs_.push_back(spk); }
/// Switch the active output to the given speaker. Must be one of the configured outputs.
/// Returns false if `target` is not in the output list.
bool switch_to_output(speaker::Speaker *target);
// Always valid: active_output_idx_ stays within [0, outputs_.size()) and at least
// two outputs are required (validated in Python), so this never returns null.
speaker::Speaker *get_active_output() const {
return this->outputs_[this->active_output_idx_.load(std::memory_order_relaxed)];
}
protected:
// Frames written to the active output but not yet played: incremented in play() and decremented
// (clamped at zero) by the active output's audio_output_callback. Mirrors mixer_speaker's
// frames_in_pipeline_.
std::atomic<uint32_t> frames_in_pipeline_{0};
bool cached_pause_{false};
void apply_cached_state_to_active_();
// Index of the previously-active output we're waiting on to fully stop before
// starting the new one. -1 means no pending start. Set by switch_to_output()
// when switching mid-playback; cleared by loop() once the old output reports
// is_stopped(). Required because shared-bus drivers (e.g. two i2s_audio
// speakers on one i2s_bus) reject start() until the previous user releases.
std::atomic<int8_t> pending_start_prev_idx_{-1};
private:
FixedVector<speaker::Speaker *> outputs_;
// Index into outputs_, always within [0, outputs_.size()). Defaults to the first
// configured output; updated by switch_to_output().
std::atomic<int8_t> active_output_idx_{0};
};
template<typename... Ts> class SwitchOutputAction : public Action<Ts...> {
public:
explicit SwitchOutputAction(Router *parent) : parent_(parent) {}
TEMPLATABLE_VALUE(speaker::Speaker *, target)
void play(const Ts &...x) override { this->parent_->switch_to_output(this->target_.value(x...)); }
protected:
Router *parent_;
};
} // namespace esphome::router
#endif // USE_ESP32
+98 -6
View File
@@ -1,8 +1,10 @@
from collections.abc import Callable
import logging
from pathlib import Path
import re
from string import ascii_letters, digits
import subprocess
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -11,6 +13,7 @@ from esphome.const import (
CONF_FRAMEWORK,
CONF_PLATFORM_VERSION,
CONF_SOURCE,
CONF_VARIANT,
CONF_VERSION,
CONF_WATCHDOG_TIMEOUT,
KEY_CORE,
@@ -20,22 +23,33 @@ from esphome.const import (
PLATFORM_RP2040,
ThreadModel,
)
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.core import (
CORE,
CoroPriority,
EsphomeCore,
EsphomeError,
coroutine_with_priority,
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import copy_file_if_changed, read_file, write_file_if_changed
from esphome.types import ConfigType
from . import boards
from .const import (
CONF_ENABLE_FULL_PRINTF,
KEY_BOARD,
KEY_LWIP_OPTS,
KEY_PIO_FILES,
KEY_RP2040,
KEY_VARIANT,
MCU_TO_VARIANT,
STANDARD_BOARDS,
VARIANT_FRIENDLY,
VARIANTS,
rp2040_ns,
)
# force import gpio to register pin schema
from .gpio import rp2040_pin_to_code # noqa
from .gpio import rp2040_pin_to_code # noqa: F401
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@jesserockz"]
@@ -74,7 +88,7 @@ def board_id_has_wifi(board_id: str) -> bool:
return board_info.get("wifi", False)
def set_core_data(config):
def set_core_data(config: ConfigType) -> ConfigType:
CORE.data[KEY_RP2040] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino"
@@ -82,12 +96,46 @@ def set_core_data(config):
config[CONF_FRAMEWORK][CONF_VERSION]
)
CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD]
CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT]
CORE.data[KEY_RP2040][KEY_PIO_FILES] = {}
return config
def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str:
return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT]
def only_on_variant(
*,
supported: str | list[str] | None = None,
unsupported: str | list[str] | None = None,
msg_prefix: str = "This feature",
) -> Callable[[Any], Any]:
"""Config validator for features only available on some RP2040 variants."""
if supported is not None and not isinstance(supported, list):
supported = [supported]
if unsupported is not None and not isinstance(unsupported, list):
unsupported = [unsupported]
def validator_(obj: Any) -> Any:
if not CORE.is_rp2040:
raise cv.Invalid(f"{msg_prefix} is only available on RP2040")
variant = get_rp2040_variant()
if supported is not None and variant not in supported:
raise cv.Invalid(
f"{msg_prefix} is only available on {', '.join(supported)}"
)
if unsupported is not None and variant in unsupported:
raise cv.Invalid(
f"{msg_prefix} is not available on {', '.join(unsupported)}"
)
return obj
return validator_
def get_download_types(storage_json):
"""Binary-download entries for a built RP2040 firmware.
@@ -198,12 +246,52 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
_arduino_check_versions,
)
def _detect_variant(value: ConfigType) -> ConfigType:
value = value.copy()
board: str | None = value.get(CONF_BOARD)
variant: str | None = value.get(CONF_VARIANT)
if board is None:
# `cv.has_at_least_one_key` guarantees variant is set here.
board = STANDARD_BOARDS[variant]
value[CONF_BOARD] = board
board_info = boards.BOARDS.get(board)
if board_info is None:
if variant is None:
raise cv.Invalid(
"This board is unknown; please specify the chip variant using "
f"the '{CONF_VARIANT}' option.",
path=[CONF_BOARD],
)
_LOGGER.warning(
"This board is unknown; the specified variant '%s' will be used "
"but this may not work as expected.",
variant,
)
else:
board_variant = MCU_TO_VARIANT[board_info["mcu"]]
if variant is None:
variant = board_variant
elif variant != board_variant:
raise cv.Invalid(
f"Option '{CONF_VARIANT}' ({variant}) does not match the "
f"selected board '{board}' ({board_variant}).",
path=[CONF_VARIANT],
)
value[CONF_VARIANT] = variant
return value
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_BOARD): cv.All(
cv.Optional(CONF_BOARD): cv.All(
cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH)
),
cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True),
cv.Optional(CONF_FRAMEWORK, default={}): ARDUINO_FRAMEWORK_SCHEMA,
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="8388ms"): cv.All(
cv.positive_time_period_milliseconds,
@@ -212,6 +300,8 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
}
),
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
_detect_variant,
set_core_data,
)
@@ -229,7 +319,9 @@ async def to_code(config):
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.set_cpp_standard("gnu++20")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
cg.add_define("ESPHOME_VARIANT", "RP2040")
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_RP2040_VARIANT_{variant}")
cg.add_define("ESPHOME_VARIANT", VARIANT_FRIENDLY[variant])
cg.add_define(ThreadModel.SINGLE)
cg.add_platformio_option("extra_scripts", ["post:post_build.py"])
+26
View File
@@ -5,5 +5,31 @@ KEY_BOARD = "board"
KEY_LWIP_OPTS = "lwip_opts"
KEY_RP2040 = "rp2040"
KEY_PIO_FILES = "pio_files"
KEY_VARIANT = "variant"
VARIANT_RP2040 = "RP2040"
VARIANT_RP2350 = "RP2350"
VARIANTS = [
VARIANT_RP2040,
VARIANT_RP2350,
]
VARIANT_FRIENDLY = {
VARIANT_RP2040: "RP2040",
VARIANT_RP2350: "RP2350",
}
# Map BOARDS[board]["mcu"] (lowercase) to canonical variant constant
MCU_TO_VARIANT = {
"rp2040": VARIANT_RP2040,
"rp2350": VARIANT_RP2350,
}
# Default board chosen when only `variant` is specified — the Raspberry Pi
# Foundation reference boards (Pico W / Pico 2 W).
STANDARD_BOARDS = {
VARIANT_RP2040: "rpipicow",
VARIANT_RP2350: "rpipico2w",
}
rp2040_ns = cg.esphome_ns.namespace("rp2040")
+2 -2
View File
@@ -67,7 +67,7 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]:
for json_file in sorted(json_dir.glob("*.json")):
board_name = json_file.stem
with open(json_file, encoding="utf-8") as f:
with json_file.open(encoding="utf-8") as f:
data = json.load(f)
build = data.get("build", {})
@@ -136,7 +136,7 @@ def _get_variant(json_file: Path) -> str | None:
"""Get variant name from a board JSON file."""
if not json_file.exists():
return None
with open(json_file, encoding="utf-8") as f:
with json_file.open(encoding="utf-8") as f:
data = json.load(f)
return data.get("build", {}).get("variant")
+3 -14
View File
@@ -121,13 +121,6 @@ def register_player_config(config: ConfigType) -> None:
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.
@@ -152,7 +145,7 @@ 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.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
}
),
cv.only_on_esp32,
@@ -201,9 +194,7 @@ async def to_code(config: ConfigType) -> None:
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
)
psram.request_external_task_stack()
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
@@ -261,9 +252,7 @@ async def to_code(config: ConfigType) -> None:
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
)
psram.request_external_task_stack()
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
# starved by the HTTP server during the initial encoded-audio burst at stream start),
@@ -1,6 +1,6 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import media_source
from esphome.components import media_source, psram
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
@@ -19,7 +19,6 @@ from .. import (
CONF_SENDSPIN_ID,
MEMORY_LOCATIONS,
SendspinHub,
_validate_task_stack_in_psram,
register_player_config,
request_controller_support,
sendspin_ns,
@@ -71,7 +70,7 @@ CONFIG_SCHEMA = cv.All(
).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_TASK_STACK_IN_PSRAM): 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,
+6 -3
View File
@@ -1192,7 +1192,7 @@ def _std(x):
def _correlation_coeff(x, y):
m_x, m_y = _mean(x), _mean(y)
s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y))
s_xy = sum((x_ - m_x) * (y_ - m_y) for x_, y_ in zip(x, y, strict=True))
s_sq_x = sum((x_ - m_x) ** 2 for x_ in x)
s_sq_y = sum((y_ - m_y) ** 2 for y_ in y)
return s_xy / math.sqrt(s_sq_x * s_sq_y)
@@ -1228,7 +1228,7 @@ def _mat_copy(m):
def _mat_transpose(m):
return _mat_copy(zip(*m))
return _mat_copy(zip(*m, strict=True))
def _mat_identity(n):
@@ -1237,7 +1237,10 @@ def _mat_identity(n):
def _mat_dot(a, b):
b_t = _mat_transpose(b)
return [[sum(x * y for x, y in zip(row_a, col_b)) for col_b in b_t] for row_a in a]
return [
[sum(x * y for x, y in zip(row_a, col_b, strict=True)) for col_b in b_t]
for row_a in a
]
def _mat_inverse(m):
@@ -7,7 +7,6 @@ import esphome.codegen as cg
from esphome.components import (
audio,
audio_file,
esp32,
media_player,
network,
ota,
@@ -155,9 +154,7 @@ CONFIG_SCHEMA = cv.All(
# Remove before 2026.10.0
cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string),
cv.Optional(CONF_FILES): audio_file.audio_files_schema(),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
cv.boolean, cv.requires_component(psram.DOMAIN)
),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage,
cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage,
cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage,
@@ -198,9 +195,7 @@ async def to_code(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
)
psram.request_external_task_stack()
cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT]))
cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL]))
+6 -13
View File
@@ -39,16 +39,13 @@ void TextSensor::publish_state(const char *state, size_t len) {
#ifdef USE_TEXT_SENSOR_FILTER
} else {
// Has filters: need separate raw storage
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
// Only assign if changed to avoid heap allocation
if (len != this->raw_state.size() || memcmp(state, this->raw_state.data(), len) != 0) {
this->raw_state.assign(state, len);
if (len != this->raw_state_.size() || memcmp(state, this->raw_state_.data(), len) != 0) {
this->raw_state_.assign(state, len);
}
this->raw_callback_.call(this->raw_state);
ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state.c_str());
this->filter_list_->input(this->raw_state);
#pragma GCC diagnostic pop
this->raw_callback_.call(this->raw_state_);
ESP_LOGV(TAG, "'%s': Received new state %s", this->name_.c_str(), this->raw_state_.c_str());
this->filter_list_->input(this->raw_state_);
}
#endif
}
@@ -89,11 +86,7 @@ const std::string &TextSensor::get_state() const { return this->state; }
const std::string &TextSensor::get_raw_state() const {
#ifdef USE_TEXT_SENSOR_FILTER
if (this->filter_list_ != nullptr) {
// Suppress deprecation warning - get_raw_state() is the replacement API
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return this->raw_state;
#pragma GCC diagnostic pop
return this->raw_state_;
}
#endif
return this->state; // No filters, raw == filtered
+2 -8
View File
@@ -29,19 +29,12 @@ class TextSensor : public EntityBase {
public:
std::string state;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
/// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0.
ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0")
std::string raw_state;
TextSensor() = default;
~TextSensor() = default;
#pragma GCC diagnostic pop
/// Getter-syntax for .state.
const std::string &get_state() const;
/// Getter-syntax for .raw_state
/// Returns the raw (pre-filter) state.
const std::string &get_raw_state() const;
void publish_state(const std::string &state);
@@ -84,6 +77,7 @@ class TextSensor : public EntityBase {
/// Notify frontend that state has changed (assumes this->state is already set)
void notify_frontend_();
#ifdef USE_TEXT_SENSOR_FILTER
std::string raw_state_; ///< Backing storage for the raw (pre-filter) value. Only used when a filter is attached.
LazyCallbackManager<void(const std::string &)> raw_callback_; ///< Storage for raw state callbacks.
#endif
LazyCallbackManager<void(const std::string &)> callback_; ///< Storage for filtered state callbacks.
+46 -27
View File
@@ -30,13 +30,21 @@ from esphome.const import (
CONF_SECONDS,
CONF_TIMEZONE,
CONF_TRIGGER_ID,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_LN882X,
PLATFORM_RP2040,
PLATFORM_RTL87XX,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@esphome/core"]
IS_PLATFORM_COMPONENT = True
DOMAIN = "time"
time_ns = cg.esphome_ns.namespace("time")
RealTimeClock = time_ns.class_("RealTimeClock", cg.PollingComponent)
@@ -88,24 +96,38 @@ def _extract_tz_string(tzfile: bytes) -> str:
return tzfile.split(b"\n")[-2].decode()
except (IndexError, UnicodeDecodeError):
_LOGGER.error("Could not determine TZ string. Please report this issue.")
_LOGGER.error("tzfile contents: %s", tzfile, exc_info=True)
_LOGGER.exception("tzfile contents: %s", tzfile)
raise
def detect_tz() -> str:
def detect_tz() -> str | None:
if CORE.target_platform not in {
PLATFORM_ESP8266,
PLATFORM_ESP32,
PLATFORM_RP2040,
PLATFORM_BK72XX,
PLATFORM_RTL87XX,
PLATFORM_LN882X,
PLATFORM_HOST,
}:
return None
# Avoids duplicate logger messages when multiple time components are configured
if cached := CORE.data.setdefault(DOMAIN, {}).get(CONF_TIMEZONE):
return cached
iana_key = tzlocal.get_localzone_name()
if iana_key is None:
raise cv.Invalid(
raise EsphomeError(
"Could not automatically determine timezone, please set timezone manually."
)
_LOGGER.info("Detected timezone '%s'", iana_key)
tzfile = _load_tzdata(iana_key)
if tzfile is None:
raise cv.Invalid(
raise EsphomeError(
"Could not automatically determine timezone, please set timezone manually."
)
ret = _extract_tz_string(tzfile)
_LOGGER.info("Detected timezone '%s'", iana_key)
_LOGGER.debug(" -> TZ string %s", ret)
CORE.data.setdefault(DOMAIN, {})[CONF_TIMEZONE] = ret
return ret
@@ -182,7 +204,7 @@ def cron_expression_validator(name, min_value, max_value, special_mapping=None):
raise cv.Invalid(
f"{name} {v} is out of range (min={min_value} max={max_value})."
)
return list(sorted(value))
return sorted(value)
value = cv.string(value)
values = set()
for part in value.split(","):
@@ -312,16 +334,7 @@ def validate_tz(value: str) -> str:
TIME_SCHEMA = cv.Schema(
{
cv.SplitDefault(
CONF_TIMEZONE,
esp8266=detect_tz,
esp32=detect_tz,
rp2040=detect_tz,
bk72xx=detect_tz,
rtl87xx=detect_tz,
ln882x=detect_tz,
host=detect_tz,
): cv.All(
cv.Optional(CONF_TIMEZONE): cv.All(
cv.only_with_framework(["arduino", "esp-idf", "host"]),
validate_tz,
),
@@ -384,26 +397,32 @@ def _emit_parsed_timezone_fields(parsed):
async def setup_time_core_(time_var, config):
if timezone := config.get(CONF_TIMEZONE):
timezone = config.get(CONF_TIMEZONE)
# an empty timezone is treated as disabling timezones completely as before
if timezone is None:
timezone = detect_tz()
if timezone:
cg.add_define("USE_TIME_TIMEZONE")
if CORE.is_host:
# Host platform needs setenv("TZ")/tzset() for libc compatibility
cg.add(cg.RawExpression(f'setenv("TZ", "{timezone}", 1)'))
cg.add(cg.RawExpression("tzset()"))
# Pre-parse at codegen time, emit struct directly
parsed = parse_posix_tz_python(timezone)
_emit_parsed_timezone_fields(parsed)
cg.add(time_var.set_timezone(timezone))
else:
# Embedded: pre-parse at codegen time, emit struct directly
try:
parsed = parse_posix_tz_python(timezone)
_emit_parsed_timezone_fields(parsed)
except ValueError as e:
raise EsphomeError(f"Invalid timezone: {timezone}") from e
for conf in config.get(CONF_ON_TIME, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var)
seconds = conf.get(CONF_SECONDS, list(range(0, 61)))
seconds = conf.get(CONF_SECONDS, list(range(61)))
cg.add(trigger.add_seconds(seconds))
minutes = conf.get(CONF_MINUTES, list(range(0, 60)))
minutes = conf.get(CONF_MINUTES, list(range(60)))
cg.add(trigger.add_minutes(minutes))
hours = conf.get(CONF_HOURS, list(range(0, 24)))
hours = conf.get(CONF_HOURS, list(range(24)))
cg.add(trigger.add_hours(hours))
days_of_month = conf.get(CONF_DAYS_OF_MONTH, list(range(1, 32)))
cg.add(trigger.add_days_of_month(days_of_month))
@@ -15,7 +15,7 @@ from esphome.const import (
CONF_SPEAKER,
)
AUTO_LOAD = ["ring_buffer", "socket"]
AUTO_LOAD = ["audio", "ring_buffer", "socket"]
DEPENDENCIES = ["api", "microphone"]
CODEOWNERS = ["@jesserockz", "@kahrendt"]
@@ -4,6 +4,7 @@
#ifdef USE_VOICE_ASSISTANT
#include "esphome/components/socket/socket.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -26,11 +27,16 @@ static const size_t SEND_BUFFER_SIZE = SEND_BUFFER_SAMPLES * sizeof(int16_t);
static const size_t RECEIVE_SIZE = 1024;
static const size_t SPEAKER_BUFFER_SIZE = 16 * RECEIVE_SIZE;
// If one microphone channel keeps producing audio while another configured channel produces none for this
// long, treat the silent channel as failed and stop the stream. A working microphone exposes a chunk every
// SEND_BUFFER_SAMPLES (32 ms), so this is far longer than any legitimate gap between chunks.
static const uint32_t AUDIO_CHANNEL_STALL_TIMEOUT_MS = 2000;
VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; }
void VoiceAssistant::setup() {
this->mic_source_->add_data_callback([this](const std::vector<uint8_t> &data) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer != nullptr) {
temp_ring_buffer->write((void *) data.data(), data.size());
}
@@ -39,7 +45,7 @@ void VoiceAssistant::setup() {
// Second microphone channel
if (this->mic_source2_ != nullptr) {
this->mic_source2_->add_data_callback([this](const std::vector<uint8_t> &data) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer2_;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer2_.lock();
if (temp_ring_buffer != nullptr) {
temp_ring_buffer->write((void *) data.data(), data.size());
}
@@ -125,63 +131,51 @@ bool VoiceAssistant::allocate_buffers_() {
}
#endif
if (this->ring_buffer_ == nullptr) {
this->ring_buffer_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE);
if (this->ring_buffer_ == nullptr) {
if (this->audio_source_ == nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE);
if (temp_ring_buffer == nullptr) {
ESP_LOGE(TAG, "Could not allocate ring buffer");
return false;
}
}
if (this->send_buffer_ == nullptr) {
RAMAllocator<uint8_t> send_allocator;
this->send_buffer_ = send_allocator.allocate(SEND_BUFFER_SIZE);
if (send_buffer_ == nullptr) {
ESP_LOGW(TAG, "Could not allocate send buffer");
// Zero-copy source that reads directly from the ring buffer; frame-aligned to never split an int16 sample.
this->audio_source_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t));
if (this->audio_source_ == nullptr) {
ESP_LOGE(TAG, "Could not allocate audio source");
return false;
}
this->ring_buffer_ = temp_ring_buffer;
}
// Second microphone channel
if (this->mic_source2_ != nullptr) {
if (this->ring_buffer2_ == nullptr) {
this->ring_buffer2_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE);
if (this->ring_buffer2_ == nullptr) {
ESP_LOGE(TAG, "Could not allocate second ring buffer");
return false;
}
if ((this->mic_source2_ != nullptr) && (this->audio_source2_ == nullptr)) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE);
if (temp_ring_buffer == nullptr) {
ESP_LOGE(TAG, "Could not allocate second ring buffer");
return false;
}
if (this->send_buffer2_ == nullptr) {
RAMAllocator<uint8_t> send_allocator;
this->send_buffer2_ = send_allocator.allocate(SEND_BUFFER_SIZE);
if (this->send_buffer2_ == nullptr) {
ESP_LOGW(TAG, "Could not allocate second send buffer");
return false;
}
this->audio_source2_ = audio::RingBufferAudioSource::create(temp_ring_buffer, SEND_BUFFER_SIZE, sizeof(int16_t));
if (this->audio_source2_ == nullptr) {
ESP_LOGE(TAG, "Could not allocate second audio source");
return false;
}
this->ring_buffer2_ = temp_ring_buffer;
}
return true;
}
void VoiceAssistant::clear_buffers_() {
if (this->send_buffer_ != nullptr) {
memset(this->send_buffer_, 0, SEND_BUFFER_SIZE);
}
if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset();
if (this->audio_source_ != nullptr) {
this->audio_source_->clear_buffered_data();
}
// Second microphone channel
if (this->send_buffer2_ != nullptr) {
memset(this->send_buffer2_, 0, SEND_BUFFER_SIZE);
if (this->audio_source2_ != nullptr) {
this->audio_source2_->clear_buffered_data();
}
if (this->ring_buffer2_ != nullptr) {
this->ring_buffer2_->reset();
}
// Reset the multi-channel stall watchdog (see audio_channel_stall_start_).
this->audio_channel_stall_start_ = 0;
#ifdef USE_SPEAKER
if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) {
@@ -195,22 +189,11 @@ void VoiceAssistant::clear_buffers_() {
}
void VoiceAssistant::deallocate_buffers_() {
if (this->send_buffer_ != nullptr) {
RAMAllocator<uint8_t> send_deallocator;
send_deallocator.deallocate(this->send_buffer_, SEND_BUFFER_SIZE);
this->send_buffer_ = nullptr;
}
this->ring_buffer_.reset();
// Destroying each source releases its ring buffer; the matching weak_ptr then expires automatically.
this->audio_source_.reset();
// Second microphone channel
if (this->send_buffer2_ != nullptr) {
RAMAllocator<uint8_t> send_deallocator;
send_deallocator.deallocate(this->send_buffer2_, SEND_BUFFER_SIZE);
this->send_buffer2_ = nullptr;
}
this->ring_buffer2_.reset();
this->audio_source2_.reset();
#ifdef USE_SPEAKER
if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) {
@@ -226,6 +209,79 @@ void VoiceAssistant::reset_conversation_id() {
ESP_LOGD(TAG, "reset conversation ID");
}
void VoiceAssistant::stream_api_audio_() {
// Both microphone channels are sent together, if configured. Home Assistant feeds one of the
// channels to its speech-to-text stream and treats an empty payload on that channel as
// end-of-stream, and the device cannot know which channel it picked, so only send once every
// configured channel has audio exposed, and always send them together. We don't target any
// particular message size: Home Assistant re-chunks the audio, and each fill() exposes at most
// SEND_BUFFER_SIZE bytes.
while (true) {
// fill() exposes a new chunk, or returns 0 if a previous chunk is still exposed; available()
// reports the currently exposed bytes either way.
this->audio_source_->fill(0, false);
size_t available = this->audio_source_->available();
size_t available2 = 0;
if (this->audio_source2_ != nullptr) {
this->audio_source2_->fill(0, false);
available2 = this->audio_source2_->available();
}
const bool channel_empty = (available == 0);
const bool channel2_empty = (this->audio_source2_ != nullptr) && (available2 == 0);
if (channel_empty || channel2_empty) {
// A configured channel has no audio yet, so keep any chunk exposed on the other channel for the
// next pass rather than sending an empty payload.
this->handle_channel_stall_(available, available2);
break;
}
// Both channels have audio exposed; clear any in-progress stall timer.
this->audio_channel_stall_start_ = 0;
api::VoiceAssistantAudio msg;
// Zero-copy: send_message() copies the data out before we consume it.
msg.data = this->audio_source_->data();
msg.data_len = available;
if (this->audio_source2_ != nullptr) {
msg.data2 = this->audio_source2_->data();
msg.data2_len = available2;
}
this->api_client_->send_message(msg);
this->audio_source_->consume(available);
if (this->audio_source2_ != nullptr) {
this->audio_source2_->consume(available2);
}
}
}
void VoiceAssistant::handle_channel_stall_(size_t available, size_t available2) {
// Called when at least one configured channel has no audio exposed. When one channel has data and the
// other does not, watch how long the empty channel stays starved: Home Assistant has no stream timeout
// and would never tell us to stop, so a channel that fails outright would otherwise hang streaming
// forever with the live channel's chunk held. Stop the stream with an error after a prolonged imbalance.
if ((available == 0) && (available2 == 0)) {
// Both channels are idle (no audio buffered yet); normal, not a stalled channel.
this->audio_channel_stall_start_ = 0;
return;
}
const uint32_t now = App.get_loop_component_start_time();
if (this->audio_channel_stall_start_ == 0) {
this->audio_channel_stall_start_ = now;
} else if ((now - this->audio_channel_stall_start_) >= AUDIO_CHANNEL_STALL_TIMEOUT_MS) {
ESP_LOGW(TAG, "Mic channel %d stalled, stopping stream", (available == 0) ? 0 : 1);
this->audio_channel_stall_start_ = 0;
this->signal_stop_();
this->set_state_(State::STOP_MICROPHONE, State::IDLE);
this->defer([this]() {
this->error_trigger_.trigger("mic-channel-stalled", "A microphone channel stopped producing audio");
});
}
}
void VoiceAssistant::loop() {
if (this->api_client_ == nullptr && this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE &&
this->state_ != State::STOPPING_MICROPHONE) {
@@ -316,52 +372,27 @@ void VoiceAssistant::loop() {
break; // State changed when udp server port received
}
case State::STREAMING_MICROPHONE: {
// pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact).
if (this->audio_mode_ == AUDIO_MODE_API) {
// API audio
// Both microphone channels are sent, if configured
bool is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE;
bool is_available2 = false;
if (this->mic_source2_) {
is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE;
}
while (is_available || is_available2) {
api::VoiceAssistantAudio msg;
if (is_available) {
size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0);
msg.data = this->send_buffer_;
msg.data_len = read_bytes;
}
// Second microphone channel
if (is_available2) {
size_t read_bytes = this->ring_buffer2_->read((void *) this->send_buffer2_, SEND_BUFFER_SIZE, 0);
msg.data2 = this->send_buffer2_;
msg.data2_len = read_bytes;
}
this->api_client_->send_message(msg);
is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE;
if (this->mic_source2_) {
is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE;
} else {
is_available2 = false;
}
}
this->stream_api_audio_();
} else {
// UDP (will eventually be deprecated)
// Only the primary microphone channel is used
while (this->ring_buffer_->available() >= SEND_BUFFER_SIZE) {
size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0);
while (true) {
this->audio_source_->fill(0, false);
size_t available = this->audio_source_->available();
if (available == 0) {
break;
}
if (!this->udp_socket_running_) {
if (!this->start_udp_socket_()) {
this->set_state_(State::STOP_MICROPHONE, State::IDLE);
break;
}
}
this->socket_->sendto(this->send_buffer_, read_bytes, 0, (struct sockaddr *) &this->dest_addr_,
this->socket_->sendto(this->audio_source_->data(), available, 0, (struct sockaddr *) &this->dest_addr_,
sizeof(this->dest_addr_));
this->audio_source_->consume(available);
}
} // audio mode
break;
@@ -862,8 +893,8 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) {
});
State new_state = this->local_output_ ? State::STREAMING_RESPONSE : State::IDLE;
if (new_state != this->state_) {
// Don't needlessly change the state. The intent progress stage may have already changed the state to streaming
// response.
// Don't needlessly change the state. The intent progress stage may have already changed the state to
// streaming response.
this->set_state_(new_state, new_state);
}
break;
@@ -9,6 +9,7 @@
#include "esphome/core/helpers.h"
#include "esphome/components/api/api_connection.h"
#include "esphome/components/audio/audio_transfer_buffer.h"
#include "esphome/components/ring_buffer/ring_buffer.h"
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/microphone/microphone_source.h"
@@ -243,6 +244,12 @@ class VoiceAssistant : public Component {
void signal_stop_();
void start_playback_timeout_();
// Drains the exposed microphone audio and sends it to Home Assistant over the API in one loop() pass.
void stream_api_audio_();
// Handles a pass where at least one configured channel has no audio exposed, timing out a channel that
// stalls. See audio_channel_stall_start_.
void handle_channel_stall_(size_t available, size_t available2);
std::unique_ptr<socket::Socket> socket_ = nullptr;
struct sockaddr_storage dest_addr_;
@@ -306,8 +313,20 @@ class VoiceAssistant : public Component {
std::string wake_word_;
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer_;
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer2_;
// Zero-copy sources that read directly from each microphone channel's ring buffer internal storage.
// Each source owns its ring buffer; the matching ``ring_buffer_``/``ring_buffer2_`` weak_ptr is used by
// the microphone callback (a different thread) to write into it.
std::unique_ptr<audio::RingBufferAudioSource> audio_source_;
std::unique_ptr<audio::RingBufferAudioSource> audio_source2_;
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer_;
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer2_;
// When streaming multiple channels, the send loop holds an exposed chunk on one channel until the other
// channel also has audio so the channels are always sent together (an empty payload looks like
// end-of-stream to Home Assistant). Home Assistant has no stream timeout, so a channel that stops
// producing entirely would hang streaming forever. This records when such an imbalance began so a
// prolonged one can be detected and stopped; 0 means no imbalance is currently being timed.
uint32_t audio_channel_stall_start_{0};
bool use_wake_word_;
uint8_t noise_suppression_level_;
@@ -315,9 +334,6 @@ class VoiceAssistant : public Component {
float volume_multiplier_;
uint32_t conversation_timeout_;
uint8_t *send_buffer_{nullptr};
uint8_t *send_buffer2_{nullptr};
bool continuous_{false};
bool silence_detection_;

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