mirror of
https://github.com/esphome/esphome.git
synced 2026-08-31 01:56:01 +00:00
Merge remote-tracking branch 'origin/cv-sensitive-redact-sentinel' into integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 +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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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{};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,7 +58,7 @@ from .effects import (
|
||||
RGB_EFFECTS,
|
||||
validate_effects,
|
||||
)
|
||||
from .types import ( # noqa
|
||||
from .types import ( # noqa: F401
|
||||
AddressableLight,
|
||||
AddressableLightState,
|
||||
ColorMode,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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_.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user