mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 14:19:03 +00:00
Merge remote-tracking branch 'upstream/api-break-circular-include' into integration
This commit is contained in:
4
.github/actions/restore-python/action.yml
vendored
4
.github/actions/restore-python/action.yml
vendored
@@ -35,6 +35,10 @@ runs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
|
||||
shell: bash
|
||||
|
||||
4
.github/workflows/ci-api-proto.yml
vendored
4
.github/workflows/ci-api-proto.yml
vendored
@@ -32,6 +32,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install apt dependencies
|
||||
run: |
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -60,6 +60,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -175,6 +179,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Install device-builder + esphome from PR
|
||||
# Install device-builder with its esphome + test extras
|
||||
# first so its pinned versions of pytest/etc. land, then
|
||||
@@ -237,7 +245,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Save Python virtual environment cache
|
||||
@@ -365,6 +373,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
||||
4
.github/workflows/sync-device-classes.yml
vendored
4
.github/workflows/sync-device-classes.yml
vendored
@@ -50,6 +50,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "api_connection.h"
|
||||
#ifdef USE_API
|
||||
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
|
||||
#ifdef USE_API_NOISE
|
||||
#include "api_frame_helper_noise.h"
|
||||
#endif
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_server.h"
|
||||
#include "list_entities.h"
|
||||
#include "subscribe_state.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/component.h"
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
@@ -36,6 +37,9 @@ class ComponentIterator;
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
|
||||
class APIServer;
|
||||
|
||||
// Keepalive timeout in milliseconds
|
||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
@@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Non-template buffer management for send_message
|
||||
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||
|
||||
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
|
||||
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
|
||||
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
|
||||
const void *msg, APIConnection *conn,
|
||||
uint32_t remaining_size) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
if (conn->flags_.log_only_mode) {
|
||||
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
|
||||
DumpBuffer dump_buf;
|
||||
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
const uint8_t footer_size = conn->helper_->frame_footer_size();
|
||||
|
||||
// First message uses max padding (already in buffer), subsequent use exact header size
|
||||
size_t to_add;
|
||||
if (conn->flags_.batch_first_message) {
|
||||
conn->flags_.batch_first_message = false;
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_padding();
|
||||
to_add = calculated_size;
|
||||
} else {
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
|
||||
to_add = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
}
|
||||
|
||||
// Check if it fits (using actual header size, not max padding)
|
||||
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
if (total_calculated_size > remaining_size)
|
||||
return 0;
|
||||
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
|
||||
const void *msg, APIConnection *conn, uint32_t remaining_size);
|
||||
|
||||
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
|
||||
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
|
||||
@@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Read by process_batch_multi_ to pass into MessageInfo.
|
||||
uint8_t batch_header_size_{0};
|
||||
|
||||
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
uint32_t get_batch_delay_ms_() const;
|
||||
// Message will use 8 more bytes than the minimum size, and typical
|
||||
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
|
||||
// If its IPv6 the header is 40 bytes, and if its IPv4
|
||||
|
||||
54
esphome/components/api/api_connection_buffer.h
Normal file
54
esphome/components/api/api_connection_buffer.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
|
||||
// Inline APIConnection methods that need APIServer complete. Include this
|
||||
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
|
||||
|
||||
#include "api_connection.h"
|
||||
#include "api_server.h"
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
|
||||
MessageEncodeFn encode_fn, const void *msg,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
if (conn->flags_.log_only_mode) {
|
||||
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
|
||||
DumpBuffer dump_buf;
|
||||
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
const uint8_t footer_size = conn->helper_->frame_footer_size();
|
||||
|
||||
// First message uses max padding (already in buffer), subsequent use exact header size
|
||||
size_t to_add;
|
||||
if (conn->flags_.batch_first_message) {
|
||||
conn->flags_.batch_first_message = false;
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_padding();
|
||||
to_add = calculated_size;
|
||||
} else {
|
||||
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
|
||||
to_add = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
}
|
||||
|
||||
// Check if it fits (using actual header size, not max padding)
|
||||
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
|
||||
if (total_calculated_size > remaining_size)
|
||||
return 0;
|
||||
|
||||
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
|
||||
shared_buf.resize(shared_buf.size() + to_add);
|
||||
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
|
||||
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
|
||||
|
||||
return total_calculated_size;
|
||||
}
|
||||
|
||||
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
|
||||
} // namespace esphome::api
|
||||
#endif
|
||||
@@ -30,11 +30,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
|
||||
|
||||
APIServer::APIServer() { global_api_server = this; }
|
||||
|
||||
// Custom deleter defined here so `delete` sees the complete APIConnection type.
|
||||
// This prevents libc++ from emitting an "incomplete type" error when other
|
||||
// translation units only have the forward declaration of APIConnection.
|
||||
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
|
||||
|
||||
void APIServer::socket_failed_(const LogString *msg) {
|
||||
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
|
||||
this->destroy_socket_();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "api_buffer.h"
|
||||
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
|
||||
#include "api_connection.h"
|
||||
#include "api_noise_context.h"
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
@@ -12,8 +14,6 @@
|
||||
#include "esphome/core/controller.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
#include "list_entities.h"
|
||||
#include "subscribe_state.h"
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#endif
|
||||
@@ -191,15 +191,9 @@ class APIServer final : public Component,
|
||||
bool is_connected_with_state_subscription() const;
|
||||
|
||||
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
|
||||
// to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the
|
||||
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
|
||||
// APIConnection but cannot reset/move the slot and break the count invariant.
|
||||
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
|
||||
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
|
||||
// only the forward declaration of APIConnection is visible (incomplete type).
|
||||
struct APIConnectionDeleter {
|
||||
void operator()(APIConnection *p) const;
|
||||
};
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection>;
|
||||
class ActiveClientsView {
|
||||
const APIConnectionPtr *begin_;
|
||||
const APIConnectionPtr *end_;
|
||||
|
||||
@@ -335,7 +335,7 @@ async def to_code(config):
|
||||
|
||||
add_idf_component(
|
||||
name="esphome/esp-audio-libs",
|
||||
ref="3.0.0",
|
||||
ref="3.1.0",
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "bluetooth_proxy.h"
|
||||
|
||||
#include "esphome/components/api/api_server.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/macros.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
@@ -113,6 +113,7 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
|
||||
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
|
||||
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
|
||||
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
|
||||
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
|
||||
|
||||
LOG_LEVELS_IDF = [
|
||||
"NONE",
|
||||
@@ -792,19 +793,15 @@ PLATFORM_VERSION_LOOKUP = {
|
||||
}
|
||||
|
||||
|
||||
def _check_pio_versions(config):
|
||||
config = config.copy()
|
||||
value = config[CONF_FRAMEWORK]
|
||||
def _resolve_framework_version(value: ConfigType) -> cv.Version:
|
||||
"""Resolve a named or raw framework version and validate the minimum.
|
||||
|
||||
Normalises value[CONF_VERSION] to its string form and returns the parsed
|
||||
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
|
||||
specific concerns (source defaults, platform_version) live in the per-
|
||||
toolchain functions.
|
||||
"""
|
||||
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
|
||||
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
|
||||
raise cv.Invalid(
|
||||
"Version needs to be explicitly set when a custom source or platform_version is used."
|
||||
)
|
||||
|
||||
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
|
||||
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
|
||||
|
||||
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
|
||||
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
|
||||
else:
|
||||
@@ -817,7 +814,38 @@ def _check_pio_versions(config):
|
||||
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
|
||||
if version < cv.Version(3, 0, 0):
|
||||
raise cv.Invalid("Only Arduino 3.0+ is supported.")
|
||||
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
else:
|
||||
if version < cv.Version(5, 0, 0):
|
||||
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
|
||||
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
|
||||
if version != recommended:
|
||||
_LOGGER.warning(
|
||||
"The selected framework version is not the recommended one. "
|
||||
"If there are connectivity or build issues please remove the manual version."
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def _check_pio_versions(config: ConfigType) -> ConfigType:
|
||||
config = config.copy()
|
||||
value = config[CONF_FRAMEWORK]
|
||||
|
||||
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
|
||||
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
|
||||
raise cv.Invalid(
|
||||
"Version needs to be explicitly set when a custom source or platform_version is used."
|
||||
)
|
||||
if is_named_version:
|
||||
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
|
||||
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
|
||||
)
|
||||
|
||||
version = _resolve_framework_version(value)
|
||||
|
||||
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
|
||||
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
|
||||
value[CONF_SOURCE] = value.get(
|
||||
CONF_SOURCE, _format_framework_arduino_version(version)
|
||||
@@ -825,9 +853,6 @@ def _check_pio_versions(config):
|
||||
if _is_framework_url(value[CONF_SOURCE]):
|
||||
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
|
||||
else:
|
||||
if version < cv.Version(5, 0, 0):
|
||||
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
|
||||
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
|
||||
value[CONF_SOURCE] = value.get(
|
||||
CONF_SOURCE,
|
||||
@@ -843,12 +868,6 @@ def _check_pio_versions(config):
|
||||
)
|
||||
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
|
||||
|
||||
if version != recommended_version:
|
||||
_LOGGER.warning(
|
||||
"The selected framework version is not the recommended one. "
|
||||
"If there are connectivity or build issues please remove the manual version."
|
||||
)
|
||||
|
||||
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
|
||||
str(PLATFORM_VERSION_LOOKUP["recommended"])
|
||||
):
|
||||
@@ -860,19 +879,26 @@ def _check_pio_versions(config):
|
||||
return config
|
||||
|
||||
|
||||
def _check_esp_idf_versions(config):
|
||||
config = _check_pio_versions(config)
|
||||
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
config = config.copy()
|
||||
value = config[CONF_FRAMEWORK]
|
||||
|
||||
# Remove unwanted keys if present
|
||||
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
|
||||
value.pop(key, None)
|
||||
# platform_version is a PlatformIO concept; drop it if a user carried it
|
||||
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
|
||||
# it lets a user override the framework tarball URL under the esp-idf
|
||||
# toolchain (the espidf framework downloader consults it).
|
||||
value.pop(CONF_PLATFORM_VERSION, None)
|
||||
|
||||
# Official ESP-IDF frameworks don't use extra
|
||||
version = cv.Version.parse(value[CONF_VERSION])
|
||||
version = cv.Version(version.major, version.minor, version.patch)
|
||||
version = _resolve_framework_version(value)
|
||||
|
||||
value[CONF_VERSION] = str(version)
|
||||
if CONF_SOURCE in value:
|
||||
_LOGGER.warning(
|
||||
"A custom framework source is set. "
|
||||
"If there are connectivity or build issues please remove the manual source."
|
||||
)
|
||||
|
||||
# Official ESP-IDF frameworks don't use the 'extra' semver component.
|
||||
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
|
||||
|
||||
return config
|
||||
|
||||
@@ -1718,6 +1744,31 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL - 1)
|
||||
async def _finalize_arduino_aware_flags():
|
||||
"""Build flags that depend on whether arduino-esp32 is linked in.
|
||||
|
||||
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
|
||||
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
|
||||
by then ``KEY_COMPONENTS`` is fully populated.
|
||||
|
||||
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
|
||||
wraps the same symbol and the linker errors on the duplicate.
|
||||
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
|
||||
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
|
||||
framework=arduino branch already adds it inline in to_code.
|
||||
"""
|
||||
arduino_linked = (
|
||||
CORE.using_arduino
|
||||
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
|
||||
)
|
||||
if not arduino_linked:
|
||||
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
|
||||
cg.add_define("USE_ESP32_CRASH_HANDLER")
|
||||
elif not CORE.using_arduino:
|
||||
cg.add_build_flag("-DUSE_ARDUINO")
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
|
||||
conf = config[CONF_FRAMEWORK]
|
||||
@@ -1777,11 +1828,8 @@ async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_ESP32")
|
||||
cg.add_define("USE_NATIVE_64BIT_TIME")
|
||||
cg.add_build_flag("-Wl,-z,noexecstack")
|
||||
# Arduino already wraps esp_panic_handler for its own backtrace handler,
|
||||
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
|
||||
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
|
||||
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
|
||||
cg.add_define("USE_ESP32_CRASH_HANDLER")
|
||||
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
|
||||
CORE.add_job(_finalize_arduino_aware_flags)
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
variant = config[CONF_VARIANT]
|
||||
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
|
||||
@@ -1962,7 +2010,7 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
|
||||
|
||||
# Setup watchdog
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
|
||||
@@ -2004,7 +2052,8 @@ async def to_code(config):
|
||||
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
|
||||
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
|
||||
# Kconfig range is [1,63]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
|
||||
|
||||
_configure_lwip_max_sockets(conf)
|
||||
|
||||
@@ -2096,7 +2145,6 @@ async def to_code(config):
|
||||
for key, flag in ASSERTION_LEVELS.items():
|
||||
add_idf_sdkconfig_option(flag, assertion_level == key)
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
|
||||
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
|
||||
for key, flag in COMPILER_OPTIMIZATIONS.items():
|
||||
add_idf_sdkconfig_option(flag, compiler_optimization == key)
|
||||
@@ -2251,7 +2299,8 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
|
||||
elif advanced[CONF_DISABLE_FATFS]:
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
|
||||
# Kconfig range is [1,10]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
|
||||
|
||||
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
|
||||
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
|
||||
@@ -2488,9 +2537,8 @@ def _write_sdkconfig():
|
||||
|
||||
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
|
||||
dependency: dict[str, str] = {}
|
||||
name, version, path = generate_idf_component(library)
|
||||
name, _version, path = generate_idf_component(library)
|
||||
dependency["override_path"] = str(path)
|
||||
dependency["version"] = version
|
||||
return name, dependency
|
||||
|
||||
|
||||
@@ -2542,7 +2590,7 @@ def _write_idf_component_yml():
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
add_idf_component(
|
||||
name="espressif/arduino-esp32",
|
||||
name=ARDUINO_ESP32_COMPONENT_NAME,
|
||||
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
)
|
||||
|
||||
|
||||
@@ -196,43 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
|
||||
(*this->on_read_callback_)(param->read.conn_id);
|
||||
}
|
||||
|
||||
uint16_t max_offset = 22;
|
||||
|
||||
// Use the client-supplied offset for long reads; short reads always start at 0.
|
||||
// The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
|
||||
// just provide as much data as we have from the requested offset and let the stack
|
||||
// handle framing. The client issues subsequent blob reads with increasing offsets
|
||||
// until it has received the whole value.
|
||||
const uint16_t offset = param->read.is_long ? param->read.offset : 0;
|
||||
esp_gatt_status_t status = ESP_GATT_OK;
|
||||
esp_gatt_rsp_t response;
|
||||
if (param->read.is_long) {
|
||||
if (this->value_read_offset_ >= this->value_.size()) {
|
||||
response.attr_value.len = 0;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
this->value_read_offset_ = 0;
|
||||
} else if (this->value_.size() - this->value_read_offset_ < max_offset) {
|
||||
// Last message in the chain
|
||||
response.attr_value.len = this->value_.size() - this->value_read_offset_;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
|
||||
this->value_read_offset_ = 0;
|
||||
} else {
|
||||
response.attr_value.len = max_offset;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
|
||||
this->value_read_offset_ += max_offset;
|
||||
}
|
||||
response.attr_value.offset = offset;
|
||||
|
||||
if (offset > this->value_.size()) {
|
||||
status = ESP_GATT_INVALID_OFFSET;
|
||||
response.attr_value.len = 0;
|
||||
} else {
|
||||
response.attr_value.offset = 0;
|
||||
response.attr_value.len = this->value_.size();
|
||||
if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) {
|
||||
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len,
|
||||
ESP_GATT_MAX_ATTR_LEN);
|
||||
response.attr_value.len = ESP_GATT_MAX_ATTR_LEN;
|
||||
size_t remaining = this->value_.size() - offset;
|
||||
if (remaining > ESP_GATT_MAX_ATTR_LEN) {
|
||||
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
|
||||
static_cast<unsigned>(remaining), ESP_GATT_MAX_ATTR_LEN);
|
||||
remaining = ESP_GATT_MAX_ATTR_LEN;
|
||||
}
|
||||
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
|
||||
this->value_read_offset_ = 0;
|
||||
response.attr_value.len = remaining;
|
||||
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
|
||||
}
|
||||
|
||||
response.attr_value.handle = this->handle_;
|
||||
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
|
||||
|
||||
esp_err_t err =
|
||||
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response);
|
||||
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ class BLECharacteristic {
|
||||
esp_gatt_char_prop_t properties_;
|
||||
uint16_t handle_{0xFFFF};
|
||||
|
||||
uint16_t value_read_offset_{0};
|
||||
std::vector<uint8_t> value_;
|
||||
std::vector<BLEDescriptor *> descriptors_;
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
|
||||
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
|
||||
return pgm_read_word(addr); // NOLINT
|
||||
}
|
||||
// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
|
||||
// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
|
||||
// ESP8266 is ~4x as many flash accesses as the bulk path.
|
||||
__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
|
||||
memcpy_P(dst, src, len); // NOLINT
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
|
||||
|
||||
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
|
||||
|
||||
def _set_stream_limits(config):
|
||||
if config.get(CONF_SPDIF_MODE, False):
|
||||
# SPDIF mode: fixed to 16-bit stereo at configured sample rate
|
||||
# SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
|
||||
audio.set_stream_limits(
|
||||
min_bits_per_sample=16,
|
||||
max_bits_per_sample=16,
|
||||
max_bits_per_sample=32,
|
||||
min_channels=2,
|
||||
max_channels=2,
|
||||
min_sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
@@ -213,9 +213,6 @@ def _final_validate(config):
|
||||
)
|
||||
if config[CONF_CHANNEL] != CONF_STEREO:
|
||||
raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
|
||||
# bits_per_sample is converted to float by the schema
|
||||
if config[CONF_BITS_PER_SAMPLE] != 16:
|
||||
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
|
||||
if not config[CONF_USE_APLL]:
|
||||
raise cv.Invalid(
|
||||
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
|
||||
|
||||
@@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
|
||||
xQueueReset(this->write_records_queue_);
|
||||
|
||||
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
|
||||
// Ensure ring buffer duration is at least the duration of all DMA buffers
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
|
||||
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
|
||||
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
|
||||
|
||||
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
|
||||
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz),
|
||||
// not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size.
|
||||
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
|
||||
const size_t bytes_to_fill_single_dma_buffer =
|
||||
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
|
||||
|
||||
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
|
||||
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
|
||||
const size_t requested_ring_buffer_bytes =
|
||||
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
||||
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
|
||||
@@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
// on_sent events drain in lockstep without crediting any audio frames.
|
||||
this->spdif_encoder_->set_preload_mode(true);
|
||||
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
|
||||
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
|
||||
// i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait.
|
||||
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0);
|
||||
if (preload_err != ESP_OK) {
|
||||
break; // DMA preload buffer full or error
|
||||
}
|
||||
@@ -410,8 +411,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
|
||||
this->sample_rate_, audio_stream_info.get_sample_rate());
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (audio_stream_info.get_bits_per_sample() != 16) {
|
||||
ESP_LOGE(TAG, "Only supports 16 bits per sample");
|
||||
const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample();
|
||||
if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) {
|
||||
ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (audio_stream_info.get_channels() != 2) {
|
||||
@@ -419,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
|
||||
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
|
||||
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
// Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
|
||||
this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
|
||||
|
||||
if (!this->parent_->try_lock()) {
|
||||
ESP_LOGE(TAG, "Parent bus is busy");
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
// Shared constants used by both standard and SPDIF speaker implementations
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static constexpr size_t TASK_STACK_SIZE = 4096;
|
||||
static constexpr ssize_t TASK_PRIORITY = 19;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace esphome::i2s_audio {
|
||||
|
||||
static const char *const TAG = "i2s_audio.speaker.std";
|
||||
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static constexpr size_t DMA_BUFFERS_COUNT = 4;
|
||||
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
|
||||
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
|
||||
|
||||
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
|
||||
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
|
||||
|
||||
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
|
||||
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33.
|
||||
// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
|
||||
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
|
||||
|
||||
// Constexpr BMC encoder for compile-time LUT generation.
|
||||
@@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
|
||||
return bmc;
|
||||
}
|
||||
|
||||
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
|
||||
// Index: 4-bit data value (0-15), always phase=true start
|
||||
// Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
|
||||
static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) {
|
||||
uint32_t p = 0;
|
||||
for (uint32_t b = 0; b < num_bits; b++)
|
||||
p ^= (value >> b) & 1u;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Combined BMC + phase-delta lookup tables.
|
||||
// Each entry packs the BMC pattern (lower bits, phase=high start) together with
|
||||
// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0).
|
||||
// XORing the delta into the running phase mask propagates parity across chunks
|
||||
// without an explicit popcount.
|
||||
|
||||
// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash.
|
||||
// Bits 0-7 : 8-bit BMC pattern (phase=high start)
|
||||
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
|
||||
static constexpr auto BMC_LUT_4 = [] {
|
||||
std::array<uint8_t, 16> t{};
|
||||
for (uint32_t i = 0; i < 16; i++)
|
||||
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4));
|
||||
std::array<uint32_t, 16> t{};
|
||||
for (uint32_t i = 0; i < 16; i++) {
|
||||
uint32_t bmc = bmc_lut_encode(i, 4);
|
||||
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
|
||||
t[i] = bmc | delta;
|
||||
}
|
||||
return t;
|
||||
}();
|
||||
|
||||
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
|
||||
// Index: 8-bit data value (0-255), always phase=true start
|
||||
// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
|
||||
// Bits 0-15 : 16-bit BMC pattern (phase=high start)
|
||||
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
|
||||
static constexpr auto BMC_LUT_8 = [] {
|
||||
std::array<uint16_t, 256> t{};
|
||||
for (uint32_t i = 0; i < 256; i++)
|
||||
t[i] = bmc_lut_encode(i, 8);
|
||||
std::array<uint32_t, 256> t{};
|
||||
for (uint32_t i = 0; i < 256; i++) {
|
||||
uint32_t bmc = bmc_lut_encode(i, 8);
|
||||
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
|
||||
t[i] = bmc | delta;
|
||||
}
|
||||
return t;
|
||||
}();
|
||||
|
||||
@@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() {
|
||||
}
|
||||
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
|
||||
|
||||
// Build initial channel status block with default sample rate
|
||||
// Build initial channel status block with default sample rate and width
|
||||
this->build_channel_status_();
|
||||
|
||||
this->reset();
|
||||
@@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() {
|
||||
void SPDIFEncoder::reset() {
|
||||
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
|
||||
this->frame_in_block_ = 0;
|
||||
this->is_left_channel_ = true;
|
||||
this->block_buf_is_silence_block_ = false;
|
||||
}
|
||||
|
||||
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
|
||||
@@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
|
||||
}
|
||||
}
|
||||
|
||||
void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) {
|
||||
if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) {
|
||||
ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample);
|
||||
return;
|
||||
}
|
||||
if (this->bytes_per_sample_ != bytes_per_sample) {
|
||||
this->bytes_per_sample_ = bytes_per_sample;
|
||||
this->build_channel_status_();
|
||||
// Discard any partial block built at the previous width so we never mix widths on the wire.
|
||||
this->reset();
|
||||
ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8);
|
||||
}
|
||||
}
|
||||
|
||||
void SPDIFEncoder::build_channel_status_() {
|
||||
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
|
||||
// Transmitted LSB-first within each byte, one bit per frame via C bit
|
||||
//
|
||||
// Byte 0: Control bits
|
||||
// Bit 0: 0 = Consumer format (not professional AES3)
|
||||
// Bit 1: 0 = PCM audio (not non-audio data like AC3)
|
||||
// Bit 2: 0 = No copyright assertion
|
||||
// Bits 3-5: 000 = No pre-emphasis
|
||||
// Bits 6-7: 00 = Mode 0 (basic consumer format)
|
||||
//
|
||||
// Byte 1: Category code (0x00 = general, 0x01 = CD, etc.)
|
||||
//
|
||||
// Byte 2: Source/channel numbers
|
||||
// Bits 0-3: Source number (0 = unspecified)
|
||||
// Bits 4-7: Channel number (0 = unspecified)
|
||||
//
|
||||
// Byte 3: Sample frequency and clock accuracy
|
||||
// Bits 0-3: Sample frequency code
|
||||
// Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32)
|
||||
// Bits 6-7: Reserved (0)
|
||||
//
|
||||
// Bytes 4-23: Reserved (zeros for basic compliance)
|
||||
// Transmitted LSB-first within each byte, one bit per frame via C bit.
|
||||
|
||||
// Any cached silence block was built for the previous channel status; it is now stale.
|
||||
this->block_buf_is_silence_block_ = false;
|
||||
|
||||
// Clear all bytes first
|
||||
this->channel_status_.fill(0);
|
||||
|
||||
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
|
||||
@@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() {
|
||||
// Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5
|
||||
this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0
|
||||
|
||||
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.)
|
||||
// Byte 4: Word length encoding (IEC 60958-3 consumer)
|
||||
// bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits)
|
||||
// bits 1-3: word length code relative to the max
|
||||
// For our supported widths:
|
||||
// 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20"
|
||||
// 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24"
|
||||
// 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code.
|
||||
uint8_t word_length_code;
|
||||
switch (this->bytes_per_sample_) {
|
||||
case 2:
|
||||
word_length_code = 0x02;
|
||||
break;
|
||||
case 3: // Shared case
|
||||
case 4:
|
||||
word_length_code = 0x0D;
|
||||
break;
|
||||
default:
|
||||
word_length_code = 0x00; // not specified
|
||||
break;
|
||||
}
|
||||
this->channel_status_[4] = word_length_code;
|
||||
}
|
||||
|
||||
HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) {
|
||||
// ============================================================================
|
||||
// Build raw 32-bit subframe (IEC 60958 format)
|
||||
// ============================================================================
|
||||
// Bit layout:
|
||||
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
|
||||
// Bits 4-7: Auxiliary audio data (zeros for 16-bit audio)
|
||||
// Bits 8-11: Audio LSB extension (zeros for 16-bit audio)
|
||||
// Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field)
|
||||
// Bit 28: V (Validity) - 0 = valid audio
|
||||
// Bit 29: U (User data) - 0
|
||||
// Bit 30: C (Channel status) - from channel status block
|
||||
// Bit 31: P (Parity) - even parity over bits 4-31
|
||||
// ============================================================================
|
||||
// Extract the C bit for the given frame from channel_status_ and shift it into bit 30
|
||||
// so it can be OR'd directly into a raw subframe.
|
||||
ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
|
||||
uint32_t frame) {
|
||||
return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
|
||||
}
|
||||
|
||||
// Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB)
|
||||
uint32_t raw_subframe = (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
|
||||
// ============================================================================
|
||||
// IEC 60958 subframe bit layout
|
||||
// ============================================================================
|
||||
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
|
||||
// Bits 4-7: Auxiliary audio data / 24-bit audio LSB
|
||||
// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit)
|
||||
// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode)
|
||||
// Bit 28: V (Validity) - 0 = valid audio
|
||||
// Bit 29: U (User data) - 0
|
||||
// Bit 30: C (Channel status) - from channel status block
|
||||
// Bit 31: P (Parity) - even parity over bits 4-31
|
||||
// ============================================================================
|
||||
|
||||
// V = 0 (valid audio), U = 0 (no user data)
|
||||
// C = channel status bit for current frame (same bit used for both L and R subframes)
|
||||
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_);
|
||||
if (c_bit) {
|
||||
raw_subframe |= (1U << 30);
|
||||
// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
|
||||
// Caller is responsible for OR-ing in the C bit and parity.
|
||||
template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
|
||||
static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
|
||||
if constexpr (Bps == 2) {
|
||||
// 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27.
|
||||
return (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
|
||||
} else if constexpr (Bps == 3) {
|
||||
// 24-bit input: full 24-bit audio field, bits 4-27.
|
||||
return (static_cast<uint32_t>(pcm_sample[2]) << 20) | (static_cast<uint32_t>(pcm_sample[1]) << 12) |
|
||||
(static_cast<uint32_t>(pcm_sample[0]) << 4);
|
||||
} else { // Bps == 4
|
||||
// 32-bit input truncated to 24-bit: drop the lowest byte.
|
||||
return (static_cast<uint32_t>(pcm_sample[3]) << 20) | (static_cast<uint32_t>(pcm_sample[2]) << 12) |
|
||||
(static_cast<uint32_t>(pcm_sample[1]) << 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate even parity over bits 4-30
|
||||
// This ensures consistent BMC ending phase regardless of audio content
|
||||
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30)
|
||||
uint32_t ones_count = __builtin_popcount(bits_4_30);
|
||||
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even
|
||||
raw_subframe |= parity << 31; // Set P bit to make total even
|
||||
// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
|
||||
// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
|
||||
// derived from the cumulative parity-mask delta of the per-byte LUT lookups.
|
||||
//
|
||||
// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
|
||||
// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first.
|
||||
// All preambles end at phase HIGH, so phase=true at the start of bit 4.
|
||||
//
|
||||
// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each
|
||||
// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the
|
||||
// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to
|
||||
// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the
|
||||
// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit
|
||||
// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output
|
||||
// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower
|
||||
// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`.
|
||||
template<uint8_t Bps>
|
||||
ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) {
|
||||
if constexpr (Bps == 2) {
|
||||
// 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants.
|
||||
// Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even),
|
||||
// so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity.
|
||||
uint32_t nibble = (raw_subframe >> 12) & 0xF;
|
||||
uint32_t lut_n = BMC_LUT_4[nibble];
|
||||
uint32_t bmc_12_15 = lut_n & 0xFFu;
|
||||
uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0
|
||||
|
||||
// ============================================================================
|
||||
// Select preamble based on position in block and channel
|
||||
// ============================================================================
|
||||
// B = block start (left channel, frame 0 of 192-frame block)
|
||||
// M = left channel (frames 1-191)
|
||||
// W = right channel (all frames)
|
||||
uint8_t preamble;
|
||||
if (this->is_left_channel_) {
|
||||
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
|
||||
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
|
||||
uint32_t lut_m = BMC_LUT_8[byte_mid];
|
||||
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
|
||||
phase_mask ^= lut_m >> 16;
|
||||
|
||||
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
|
||||
uint32_t lut_h = BMC_LUT_8[byte_hi];
|
||||
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
|
||||
phase_mask ^= lut_h >> 16;
|
||||
// phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31.
|
||||
bmc_24_31 ^= phase_mask & 1u;
|
||||
|
||||
dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
|
||||
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
|
||||
} else {
|
||||
preamble = PREAMBLE_W;
|
||||
// 24-bit (and 32-bit truncated) path: bits 4-11 are live audio.
|
||||
uint32_t byte_lo = (raw_subframe >> 4) & 0xFF;
|
||||
uint32_t lut_l = BMC_LUT_8[byte_lo];
|
||||
uint32_t bmc_4_11 = lut_l & 0xFFFFu;
|
||||
uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0
|
||||
|
||||
uint32_t nibble = (raw_subframe >> 12) & 0xF;
|
||||
uint32_t lut_n = BMC_LUT_4[nibble];
|
||||
uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu);
|
||||
phase_mask ^= lut_n >> 16;
|
||||
|
||||
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
|
||||
uint32_t lut_m = BMC_LUT_8[byte_mid];
|
||||
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
|
||||
phase_mask ^= lut_m >> 16;
|
||||
|
||||
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
|
||||
uint32_t lut_h = BMC_LUT_8[byte_hi];
|
||||
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
|
||||
phase_mask ^= lut_h >> 16;
|
||||
bmc_24_31 ^= phase_mask & 1u;
|
||||
|
||||
// word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15)
|
||||
// word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31)
|
||||
dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast<uint32_t>(preamble) << 24);
|
||||
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BMC encode the data portion (bits 4-31) using lookup tables
|
||||
// ============================================================================
|
||||
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15.
|
||||
// This applies to BOTH word[0] and word[1].
|
||||
//
|
||||
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15]
|
||||
// For correct S/PDIF subframe order (preamble → aux → audio):
|
||||
// - bits 16-23: preamble (8 BMC bits)
|
||||
// - bits 24-31: BMC(subframe bits 4-7) - first aux nibble
|
||||
// - bits 0-7: BMC(subframe bits 8-11) - second aux nibble
|
||||
// - bits 8-15: BMC(subframe bits 12-15) - audio low nibble
|
||||
//
|
||||
// word[1] transmission order: [16-31] → [0-15]
|
||||
// For correct S/PDIF subframe order:
|
||||
// - bits 16-31: BMC(subframe bits 16-23) - audio mid byte
|
||||
// - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP
|
||||
// ============================================================================
|
||||
|
||||
// All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio;
|
||||
// two zero nibbles flip phase 8 times total → back to HIGH.
|
||||
// So bits 12-15 always start encoding at phase=true.
|
||||
|
||||
// Bits 12-15: 4-bit LUT lookup (always phase=true start)
|
||||
uint32_t nibble = (raw_subframe >> 12) & 0xF;
|
||||
uint32_t bmc_12_15 = BMC_LUT_4[nibble];
|
||||
|
||||
// Phase tracking via branchless XOR mask:
|
||||
// - 0x0000 means phase=true (use LUT value directly)
|
||||
// - 0xFFFF means phase=false (complement LUT value)
|
||||
// End phase = start XOR (popcount & 1) since zero-bits flip phase,
|
||||
// and for even bit widths: #zeros parity == popcount parity.
|
||||
uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF;
|
||||
|
||||
// Bits 16-23: 8-bit LUT lookup with phase correction
|
||||
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
|
||||
uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask;
|
||||
phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF;
|
||||
|
||||
// Bits 24-31: 8-bit LUT lookup with phase correction
|
||||
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF;
|
||||
uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask;
|
||||
|
||||
// ============================================================================
|
||||
// Combine with correct positioning for I2S transmission
|
||||
// ============================================================================
|
||||
// I2S with halfword swap: transmits bits 16-31, then bits 0-15.
|
||||
// Within each halfword, MSB (highest bit) is transmitted first.
|
||||
//
|
||||
// For upper halfword (bits 16-31): bit 31 → bit 16
|
||||
// For lower halfword (bits 0-15): bit 15 → bit 0
|
||||
//
|
||||
// Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15
|
||||
//
|
||||
// word[0] layout for correct transmission:
|
||||
// bits 24-31: preamble (transmitted 1st, as MSB of upper halfword)
|
||||
// bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7)
|
||||
// bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11)
|
||||
// bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble)
|
||||
//
|
||||
// word[1] layout:
|
||||
// bits 16-31: bmc_16_23 (transmitted 5th)
|
||||
// bits 0-15: bmc_24_31 (transmitted 6th)
|
||||
this->spdif_block_ptr_[0] =
|
||||
bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
|
||||
this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16);
|
||||
this->spdif_block_ptr_ += 2;
|
||||
|
||||
// ============================================================================
|
||||
// Update position tracking
|
||||
// ============================================================================
|
||||
if (!this->is_left_channel_) {
|
||||
// Completed a stereo frame, advance frame counter
|
||||
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
|
||||
this->frame_in_block_ = 0;
|
||||
}
|
||||
template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
|
||||
static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0};
|
||||
uint32_t raw = build_raw_subframe<Bps>(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_);
|
||||
uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
|
||||
bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
|
||||
bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
|
||||
this->spdif_block_ptr_ += 4;
|
||||
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
|
||||
this->frame_in_block_ = 0;
|
||||
}
|
||||
this->is_left_channel_ = !this->is_left_channel_;
|
||||
}
|
||||
|
||||
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
|
||||
@@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
|
||||
return err;
|
||||
}
|
||||
|
||||
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
|
||||
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
|
||||
return 0;
|
||||
template<uint8_t Bps>
|
||||
HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
|
||||
uint32_t *blocks_sent, size_t *bytes_consumed) {
|
||||
const uint8_t *pcm_data = src;
|
||||
const uint8_t *const pcm_end = src + size;
|
||||
uint32_t block_count = 0;
|
||||
|
||||
// Hot state lives in locals so the compiler can keep it in registers across the
|
||||
// per-frame encoding work; byte writes through block_ptr may alias the member fields,
|
||||
// which would block register allocation if the encoding read them directly from this->*.
|
||||
uint32_t *block_ptr = this->spdif_block_ptr_;
|
||||
uint32_t *const block_buf = this->spdif_block_buf_.get();
|
||||
uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32;
|
||||
uint32_t frame = this->frame_in_block_;
|
||||
const std::array<uint8_t, 24> &channel_status = this->channel_status_;
|
||||
|
||||
auto save_state = [&]() {
|
||||
this->spdif_block_ptr_ = block_ptr;
|
||||
this->frame_in_block_ = static_cast<uint8_t>(frame);
|
||||
};
|
||||
|
||||
auto report_out_params = [&]() {
|
||||
if (blocks_sent != nullptr)
|
||||
*blocks_sent = block_count;
|
||||
if (bytes_consumed != nullptr)
|
||||
*bytes_consumed = pcm_data - src;
|
||||
};
|
||||
|
||||
// Send a completed block if the buffer is full, propagating any error.
|
||||
// send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it
|
||||
// unchanged on error -- mirror both behaviors in our local block_ptr.
|
||||
auto maybe_send = [&]() -> esp_err_t {
|
||||
if (block_ptr >= block_end) {
|
||||
esp_err_t err = this->send_block_(ticks_to_wait);
|
||||
if (err != ESP_OK) {
|
||||
save_state();
|
||||
report_out_params();
|
||||
return err;
|
||||
}
|
||||
block_ptr = block_buf;
|
||||
++block_count;
|
||||
}
|
||||
return ESP_OK;
|
||||
};
|
||||
|
||||
// Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only
|
||||
// buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from
|
||||
// 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or
|
||||
// preamble branch. The encoding body is inlined here so block_ptr lives in a register
|
||||
// for the duration of the loop.
|
||||
while (pcm_data + 2 * Bps <= pcm_end) {
|
||||
if (frame == 0) {
|
||||
esp_err_t err = maybe_send();
|
||||
if (err != ESP_OK)
|
||||
return err;
|
||||
|
||||
uint32_t c_bit = c_bit_for_frame(channel_status, 0);
|
||||
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
|
||||
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
|
||||
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_B, block_ptr);
|
||||
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
|
||||
block_ptr += 4;
|
||||
frame = 1;
|
||||
pcm_data += 2 * Bps;
|
||||
}
|
||||
|
||||
// The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The
|
||||
// input-size bound is folded into end_frame so a single `frame < end_frame` test
|
||||
// governs termination.
|
||||
uint32_t input_frames = static_cast<uint32_t>(pcm_end - pcm_data) / (2u * Bps);
|
||||
uint32_t end_frame = SPDIF_BLOCK_SAMPLES;
|
||||
if (frame + input_frames < end_frame)
|
||||
end_frame = frame + input_frames;
|
||||
|
||||
while (frame < end_frame) {
|
||||
uint32_t c_bit = c_bit_for_frame(channel_status, frame);
|
||||
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
|
||||
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
|
||||
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_M, block_ptr);
|
||||
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
|
||||
block_ptr += 4;
|
||||
++frame;
|
||||
pcm_data += 2 * Bps;
|
||||
}
|
||||
if (frame >= SPDIF_BLOCK_SAMPLES)
|
||||
frame = 0;
|
||||
}
|
||||
// Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer
|
||||
// So pending uint32s / 2 = pending samples, and each sample is 2 bytes
|
||||
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get();
|
||||
size_t pending_samples = pending_uint32s / 2;
|
||||
return pending_samples * 2; // 2 bytes per sample
|
||||
|
||||
// Send any complete block that was just finished.
|
||||
if (block_ptr >= block_end) {
|
||||
esp_err_t err = this->send_block_(ticks_to_wait);
|
||||
if (err != ESP_OK) {
|
||||
save_state();
|
||||
report_out_params();
|
||||
return err;
|
||||
}
|
||||
block_ptr = block_buf;
|
||||
++block_count;
|
||||
}
|
||||
|
||||
save_state();
|
||||
report_out_params();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
|
||||
size_t *bytes_consumed) {
|
||||
const uint8_t *pcm_data = src;
|
||||
const uint8_t *pcm_end = src + size;
|
||||
uint32_t block_count = 0;
|
||||
if (size > 0) {
|
||||
// Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
|
||||
this->block_buf_is_silence_block_ = false;
|
||||
}
|
||||
switch (this->bytes_per_sample_) {
|
||||
case 2:
|
||||
return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
|
||||
case 3:
|
||||
return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
|
||||
case 4:
|
||||
return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
|
||||
default:
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
while (pcm_data < pcm_end) {
|
||||
// Check if there's a pending complete block from a previous failed send
|
||||
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
|
||||
esp_err_t err = this->send_block_(ticks_to_wait);
|
||||
if (err != ESP_OK) {
|
||||
if (blocks_sent != nullptr) {
|
||||
*blocks_sent = block_count;
|
||||
}
|
||||
if (bytes_consumed != nullptr) {
|
||||
*bytes_consumed = pcm_data - src;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
++block_count;
|
||||
template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
|
||||
// If a complete block is already pending (from a previous failed send), emit just that block.
|
||||
// Otherwise pad the partial block with silence (or generate a full silence block if empty) and
|
||||
// send. Always emits exactly one block on success.
|
||||
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
|
||||
const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
|
||||
// Continuous-silence idle case: a full silence block is byte-identical every time for the
|
||||
// active channel status, so when the buffer already holds one, re-send it as-is.
|
||||
if (was_empty && this->block_buf_is_silence_block_) {
|
||||
return this->send_block_(ticks_to_wait);
|
||||
}
|
||||
|
||||
// Encode one 16-bit sample
|
||||
this->encode_sample_(pcm_data);
|
||||
pcm_data += 2;
|
||||
}
|
||||
|
||||
// Send any complete block that was just finished
|
||||
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
|
||||
esp_err_t err = this->send_block_(ticks_to_wait);
|
||||
if (err != ESP_OK) {
|
||||
if (blocks_sent != nullptr) {
|
||||
*blocks_sent = block_count;
|
||||
}
|
||||
if (bytes_consumed != nullptr) {
|
||||
*bytes_consumed = pcm_data - src;
|
||||
}
|
||||
return err;
|
||||
// Pad with silence frames at the configured width.
|
||||
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
|
||||
this->encode_silence_frame_<Bps>();
|
||||
}
|
||||
++block_count;
|
||||
// The buffer is a reusable full-silence block only if it was built entirely from silence; a
|
||||
// partial real-audio block padded out with silence is not.
|
||||
this->block_buf_is_silence_block_ = was_empty;
|
||||
}
|
||||
|
||||
if (blocks_sent != nullptr) {
|
||||
*blocks_sent = block_count;
|
||||
}
|
||||
if (bytes_consumed != nullptr) {
|
||||
*bytes_consumed = size;
|
||||
}
|
||||
return ESP_OK;
|
||||
return this->send_block_(ticks_to_wait);
|
||||
}
|
||||
|
||||
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
|
||||
// If a complete block is already pending (from a previous failed send), emit just that block.
|
||||
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
|
||||
// and send. Always emits exactly one block on success.
|
||||
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
|
||||
static const uint8_t SILENCE[2] = {0, 0};
|
||||
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
|
||||
this->encode_sample_(SILENCE);
|
||||
}
|
||||
switch (this->bytes_per_sample_) {
|
||||
case 2:
|
||||
return this->flush_with_silence_typed_<2>(ticks_to_wait);
|
||||
case 3:
|
||||
return this->flush_with_silence_typed_<3>(ticks_to_wait);
|
||||
case 4:
|
||||
return this->flush_with_silence_typed_<4>(ticks_to_wait);
|
||||
default:
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return this->send_block_(ticks_to_wait);
|
||||
}
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
@@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT
|
||||
static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768
|
||||
// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo)
|
||||
static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames
|
||||
// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels)
|
||||
static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes
|
||||
|
||||
/// Callback signature for block completion (raw function pointer for minimal overhead)
|
||||
/// @param user_ctx User context pointer passed during callback registration
|
||||
@@ -64,8 +62,16 @@ class SPDIFEncoder {
|
||||
/// @brief Check if currently in preload mode
|
||||
bool is_preload_mode() const { return this->preload_mode_; }
|
||||
|
||||
/// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire).
|
||||
/// Must be called before write() if input width changes from the default (16-bit). Triggers a
|
||||
/// channel-status rebuild to reflect the new word length.
|
||||
void set_bytes_per_sample(uint8_t bytes_per_sample);
|
||||
|
||||
/// @brief Get the configured input PCM width in bytes per sample
|
||||
uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; }
|
||||
|
||||
/// @brief Convert PCM audio data to SPDIF BMC encoded data
|
||||
/// @param src Source PCM audio data (16-bit stereo)
|
||||
/// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
|
||||
/// @param size Size of source data in bytes
|
||||
/// @param ticks_to_wait Timeout for blocking writes
|
||||
/// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent
|
||||
@@ -74,17 +80,6 @@ class SPDIFEncoder {
|
||||
esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr,
|
||||
size_t *bytes_consumed = nullptr);
|
||||
|
||||
/// @brief Get the number of PCM bytes currently pending in the partial block buffer
|
||||
/// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1)
|
||||
size_t get_pending_pcm_bytes() const;
|
||||
|
||||
/// @brief Get the number of PCM frames currently pending in the partial block buffer
|
||||
/// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1)
|
||||
uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; }
|
||||
|
||||
/// @brief Check if there is a partial block pending
|
||||
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
|
||||
|
||||
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
|
||||
/// or send a full silence block if nothing is pending. Always produces exactly one block on success.
|
||||
/// @param ticks_to_wait Timeout for blocking writes
|
||||
@@ -95,7 +90,7 @@ class SPDIFEncoder {
|
||||
void reset();
|
||||
|
||||
/// @brief Set the sample rate for Channel Status Block encoding
|
||||
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000)
|
||||
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
|
||||
/// Call this before writing audio data to ensure correct channel status.
|
||||
void set_sample_rate(uint32_t sample_rate);
|
||||
|
||||
@@ -103,8 +98,19 @@ class SPDIFEncoder {
|
||||
uint32_t get_sample_rate() const { return this->sample_rate_; }
|
||||
|
||||
protected:
|
||||
/// @brief Encode a single 16-bit PCM sample into the current block position
|
||||
HOT void encode_sample_(const uint8_t *pcm_sample);
|
||||
/// @brief Encode a single stereo silence frame at the current block position.
|
||||
/// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the
|
||||
/// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers.
|
||||
template<uint8_t Bps> void encode_silence_frame_();
|
||||
|
||||
/// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_.
|
||||
template<uint8_t Bps>
|
||||
HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
|
||||
size_t *bytes_consumed);
|
||||
|
||||
/// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width
|
||||
/// (or builds a full silence block when nothing is pending) and sends it. Always emits one block.
|
||||
template<uint8_t Bps> esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait);
|
||||
|
||||
/// @brief Send the completed block via the appropriate callback
|
||||
esp_err_t send_block_(TickType_t ticks_to_wait);
|
||||
@@ -112,15 +118,6 @@ class SPDIFEncoder {
|
||||
/// @brief Build the channel status block from current configuration
|
||||
void build_channel_status_();
|
||||
|
||||
/// @brief Get the channel status bit for a specific frame
|
||||
/// @param frame Frame number (0-191)
|
||||
/// @return The C bit value for this frame
|
||||
ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const {
|
||||
// Channel status is 192 bits transmitted over 192 frames
|
||||
// Bit N is transmitted in frame N, LSB-first within each byte
|
||||
return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1;
|
||||
}
|
||||
|
||||
// Member ordering optimized to minimize padding (largest alignment first)
|
||||
|
||||
// 4-byte aligned members (pointers and uint32_t)
|
||||
@@ -133,9 +130,13 @@ class SPDIFEncoder {
|
||||
uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding
|
||||
|
||||
// 1-byte aligned members (grouped together to avoid internal padding)
|
||||
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
|
||||
bool is_left_channel_{true}; // Alternates L/R for stereo samples
|
||||
bool preload_mode_{false}; // Whether to use preload callback vs write callback
|
||||
uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire.
|
||||
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
|
||||
bool preload_mode_{false}; // Whether to use preload callback vs write callback
|
||||
// True when spdif_block_buf_ currently holds a complete full-silence block valid for the active
|
||||
// channel status. A full silence block is deterministic for a given sample rate and word length,
|
||||
// so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding.
|
||||
bool block_buf_is_silence_block_{false};
|
||||
|
||||
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
|
||||
// Placed last since std::array<uint8_t> has 1-byte alignment
|
||||
|
||||
@@ -25,7 +25,6 @@ from esphome.const import (
|
||||
CONF_TEMPERATURE_COMPENSATION,
|
||||
CONF_TIME_CONSTANT,
|
||||
CONF_VOC,
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM1,
|
||||
DEVICE_CLASS_PM10,
|
||||
@@ -77,7 +76,6 @@ def _gas_sensor(
|
||||
return sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
).extend(
|
||||
{
|
||||
|
||||
@@ -14,7 +14,6 @@ from esphome.const import (
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TYPE,
|
||||
CONF_VOC,
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM1,
|
||||
@@ -93,13 +92,11 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(CONF_VOC): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_NOX): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_CO2): sensor.sensor_schema(
|
||||
|
||||
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.0")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from esphome.const import (
|
||||
CONF_STORE_BASELINE,
|
||||
CONF_TEMPERATURE_SOURCE,
|
||||
CONF_VOC,
|
||||
DEVICE_CLASS_AQI,
|
||||
ICON_RADIATOR,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
@@ -72,13 +71,11 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_VOC): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
).extend(VOC_SENSOR),
|
||||
cv.Optional(CONF_NOX): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
).extend(NOX_SENSOR),
|
||||
cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean,
|
||||
|
||||
@@ -206,15 +206,17 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff
|
||||
if (this->status_pin_reported_ != -1) {
|
||||
this->init_state_ = TuyaInitState::INIT_DATAPOINT;
|
||||
this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY);
|
||||
bool is_pin_equals =
|
||||
this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_;
|
||||
// Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send
|
||||
if (!is_pin_equals) {
|
||||
ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.",
|
||||
if (this->status_pin_ != nullptr) {
|
||||
if (this->status_pin_->get_pin() != this->status_pin_reported_) {
|
||||
ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.",
|
||||
this->status_pin_reported_);
|
||||
}
|
||||
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
|
||||
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
|
||||
} else {
|
||||
ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.",
|
||||
this->status_pin_reported_);
|
||||
}
|
||||
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
|
||||
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
|
||||
} else {
|
||||
this->init_state_ = TuyaInitState::INIT_WIFI;
|
||||
ESP_LOGV(TAG, "Configured WIFI_STATE periodic send");
|
||||
|
||||
@@ -50,6 +50,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@luar123", "@tomaszduda23"]
|
||||
|
||||
CONFLICTS_WITH = ["openthread"]
|
||||
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_REPORT): cv.All(
|
||||
|
||||
@@ -117,15 +117,11 @@ def final_validate_esp32(config: ConfigType) -> ConfigType:
|
||||
if not CORE.is_esp32:
|
||||
return config
|
||||
if CONF_WIFI in fv.full_config.get():
|
||||
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
|
||||
raise cv.Invalid(
|
||||
"Only Zigbee End Device can be used together with a Wifi Access Point."
|
||||
)
|
||||
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
|
||||
_LOGGER.warning(
|
||||
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback."
|
||||
raise cv.Invalid(
|
||||
"A Wifi Access Point can not be used together with Zigbee."
|
||||
)
|
||||
elif config[CONF_ROUTER]:
|
||||
if config[CONF_ROUTER]:
|
||||
_LOGGER.warning(
|
||||
"The Zigbee Router might miss packets while Wifi is active and could destabilize "
|
||||
"your network. Use only if Wifi is off most of the time."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include "gpio.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/time_64.h"
|
||||
@@ -42,6 +43,9 @@ void __attribute__((noreturn)) arch_restart();
|
||||
inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; }
|
||||
inline const char *progmem_read_ptr(const char *const *addr) { return *addr; }
|
||||
inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; }
|
||||
// Bulk copy out of PROGMEM. PROGMEM is a no-op everywhere except ESP8266, so a
|
||||
// plain `std::memcpy` is correct and the fast path here.
|
||||
inline void progmem_memcpy(void *dst, const void *src, size_t len) { std::memcpy(dst, src, len); }
|
||||
#endif
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -93,7 +93,7 @@ class URLSource(Source):
|
||||
|
||||
|
||||
class GitSource(Source):
|
||||
def __init__(self, url: str, ref: str):
|
||||
def __init__(self, url: str, ref: str | None):
|
||||
self.url = url
|
||||
self.ref = ref
|
||||
|
||||
@@ -109,7 +109,7 @@ class GitSource(Source):
|
||||
return path
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.url}#{self.ref}"
|
||||
return f"{self.url}#{self.ref}" if self.ref else self.url
|
||||
|
||||
|
||||
class InvalidIDFComponent(Exception):
|
||||
@@ -154,41 +154,6 @@ class IDFComponent:
|
||||
self.path = self.source.download(self.get_sanitized_name(), force=force)
|
||||
|
||||
|
||||
def _sanitize_version(version: str) -> str:
|
||||
"""
|
||||
Sanitize a version string by removing common requirement prefixes or a leading v.
|
||||
|
||||
Args:
|
||||
version: Version string to clean.
|
||||
|
||||
Returns:
|
||||
Cleaned version string without common requirement symbols.
|
||||
"""
|
||||
version = version.strip()
|
||||
|
||||
prefixes = (
|
||||
"^",
|
||||
"~=",
|
||||
"~",
|
||||
">=",
|
||||
"<=",
|
||||
"==",
|
||||
"!=",
|
||||
">",
|
||||
"<",
|
||||
"=",
|
||||
"v",
|
||||
"V",
|
||||
)
|
||||
|
||||
for p in prefixes:
|
||||
if version.startswith(p):
|
||||
version = version[len(p) :]
|
||||
break
|
||||
|
||||
return version.strip()
|
||||
|
||||
|
||||
def _get_package_from_pio_registry(
|
||||
username: str | None, pkgname: str, requirements: str
|
||||
) -> tuple[str, str, str | None, str | None]:
|
||||
@@ -387,7 +352,6 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
|
||||
IDFComponent: The resolved component with name, version, and URL
|
||||
|
||||
Raises:
|
||||
ValueError: If a repository URL is missing a reference (#)
|
||||
RuntimeError: If no artifact can be found for the library
|
||||
"""
|
||||
name = None
|
||||
@@ -396,20 +360,25 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
|
||||
|
||||
# Repository is provided directly
|
||||
if library.repository:
|
||||
# Parse repository URL to extract name and version
|
||||
# Parse repository URL: path becomes the component name, fragment
|
||||
# (if any) becomes the git ref stored on GitSource. A missing
|
||||
# fragment is fine -- clone_or_update leaves the depth-1 clone on
|
||||
# the remote's default branch, matching PIO's lib_deps behavior
|
||||
# and external_components handling.
|
||||
split_result = urlsplit(library.repository)
|
||||
if not split_result.fragment.strip():
|
||||
raise ValueError(f"Missing ref in URL {library.repository}")
|
||||
|
||||
# Sanitize name
|
||||
name = str(split_result.path).strip("/")
|
||||
name = name.removesuffix(".git")
|
||||
|
||||
# Sanitize version
|
||||
version = _sanitize_version(split_result.fragment)
|
||||
# IDF Component Manager only accepts "*", a 40-char commit hash, or
|
||||
# semver here. The actual git ref is preserved in GitSource.ref;
|
||||
# override_path makes this field cosmetic at build time.
|
||||
version = "*"
|
||||
repository = urlunsplit(split_result._replace(fragment=""))
|
||||
|
||||
source = GitSource(str(repository), split_result.fragment)
|
||||
ref = split_result.fragment.strip() or None
|
||||
source = GitSource(str(repository), ref)
|
||||
|
||||
# Version is provided - resolve using PlatformIO registry
|
||||
elif library.version:
|
||||
@@ -619,9 +588,6 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
|
||||
if description:
|
||||
data["description"] = description
|
||||
|
||||
# Do not use the version from library.json/library.properties; it may be incorrect.
|
||||
data["version"] = component.version
|
||||
|
||||
repository = component.data.get("repository", {}).get("url", None)
|
||||
if repository:
|
||||
data["repository"] = repository
|
||||
@@ -631,20 +597,11 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
|
||||
if "dependencies" not in data:
|
||||
data["dependencies"] = {}
|
||||
|
||||
# Add this dependency to dependencies
|
||||
dep = {}
|
||||
dep["version"] = dependency.version
|
||||
|
||||
# Should use dependency.path as override path
|
||||
try:
|
||||
dep["override_path"] = str(dependency.path)
|
||||
except RuntimeError as e:
|
||||
# No local path: only a GitSource can substitute its URL.
|
||||
if not isinstance(dependency.source, GitSource):
|
||||
raise e
|
||||
dep["git"] = dependency.source.url
|
||||
|
||||
data["dependencies"][dependency.get_sanitized_name()] = dep
|
||||
# Every dependency goes through _generate_idf_component →
|
||||
# component.download() before this runs, so .path is always set.
|
||||
data["dependencies"][dependency.get_sanitized_name()] = {
|
||||
"override_path": str(dependency.path),
|
||||
}
|
||||
|
||||
return yaml_util.dump(data)
|
||||
|
||||
@@ -699,6 +656,26 @@ def _process_dependencies(component: IDFComponent):
|
||||
if not dependencies:
|
||||
return
|
||||
|
||||
# PIO's library.json accepts both the list-of-dicts form and the
|
||||
# shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize
|
||||
# the dict form so the loop below sees a uniform list. Iterating a
|
||||
# dict gives string keys, which would silently fail the
|
||||
# ``"name" in dependency`` substring check and skip every entry.
|
||||
if isinstance(dependencies, dict):
|
||||
normalized = []
|
||||
for raw_name, spec in dependencies.items():
|
||||
if "/" in raw_name:
|
||||
owner, pkgname = raw_name.split("/", 1)
|
||||
else:
|
||||
owner, pkgname = None, raw_name
|
||||
entry = {"name": pkgname, "owner": owner}
|
||||
if isinstance(spec, dict):
|
||||
entry.update(spec)
|
||||
else:
|
||||
entry["version"] = spec
|
||||
normalized.append(entry)
|
||||
dependencies = normalized
|
||||
|
||||
_LOGGER.info("Processing %s@%s component dependencies...", name, version)
|
||||
for dependency in dependencies:
|
||||
# Validate dependency structure
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -17,7 +18,7 @@ import requests
|
||||
|
||||
from esphome.config_validation import Version
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import ProgressBar, get_str_env, rmtree
|
||||
from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed
|
||||
|
||||
PathType = str | os.PathLike
|
||||
|
||||
@@ -549,11 +550,11 @@ def _tar_extract_all(
|
||||
if not (mode & stat.S_IXUSR):
|
||||
mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
mode |= stat.S_IRUSR | stat.S_IWUSR
|
||||
elif member.isdir() or member.issym():
|
||||
# Ignore mode for directories & symlinks
|
||||
mode = None
|
||||
else:
|
||||
# Block special files
|
||||
elif not (member.isdir() or member.issym()):
|
||||
# Block special files. Directories and symlinks keep
|
||||
# their masked-original mode — passing None here would
|
||||
# crash tarfile.extract on Python <3.12 (its chmod
|
||||
# path calls os.chmod unconditionally).
|
||||
continue
|
||||
|
||||
member.mode = mode
|
||||
@@ -783,12 +784,109 @@ def download_from_mirrors(
|
||||
return None
|
||||
|
||||
|
||||
def _write_idf_version_txt(framework_path: Path, version: str) -> None:
|
||||
"""Write <framework_path>/version.txt if missing.
|
||||
|
||||
IDF's build.cmake picks the version it embeds in the firmware (and
|
||||
stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt``
|
||||
if present, else ``git describe`` against IDF_PATH, else the
|
||||
``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``.
|
||||
On a clean esphome-libs tarball ``.git`` is fully stripped, so
|
||||
git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet
|
||||
wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom
|
||||
framework.source pointed at a real git URL where build artifacts
|
||||
mark the tree dirty) makes git_describe return ``<hash>-dirty``,
|
||||
which is what then gets baked into the bootloader. Dropping
|
||||
version.txt forces the right answer regardless.
|
||||
"""
|
||||
version_txt = framework_path / "version.txt"
|
||||
if version_txt.exists():
|
||||
return
|
||||
try:
|
||||
version_txt.write_text(f"v{version}\n", encoding="utf-8")
|
||||
except OSError as e:
|
||||
_LOGGER.warning(
|
||||
"Could not write %s (%s); bootloader version string may be incorrect.",
|
||||
version_txt,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release
|
||||
# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no
|
||||
# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a
|
||||
# matching binary or grabs the x86_64 one, which can't execute on
|
||||
# aarch64. cmake is already populated across the same release range; we
|
||||
# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1
|
||||
# tools.json where the fix landed natively.
|
||||
_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = {
|
||||
"1.12.1": {
|
||||
"rename_dist": "ninja-linux-arm64-v1.12.1.zip",
|
||||
"sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1",
|
||||
"size": 121787,
|
||||
"url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
|
||||
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
|
||||
|
||||
Idempotent: a tools.json that already has the entry, or a host that
|
||||
isn't aarch64, is a no-op. Applied unconditionally on every install
|
||||
check so a build dir extracted before the backport got fixed up
|
||||
without forcing a clean.
|
||||
"""
|
||||
if platform.machine() != "aarch64":
|
||||
return
|
||||
|
||||
tools_json = framework_path / "tools" / "tools.json"
|
||||
if not tools_json.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(tools_json, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
_LOGGER.warning(
|
||||
"Could not parse %s for linux-arm64 backport (%s); "
|
||||
"skipping. A clean reinstall of the framework directory "
|
||||
"may be needed.",
|
||||
tools_json,
|
||||
e,
|
||||
)
|
||||
return
|
||||
|
||||
changed = False
|
||||
for tool in data.get("tools", []):
|
||||
if tool.get("name") != "ninja":
|
||||
continue
|
||||
for ver in tool.get("versions", []):
|
||||
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
|
||||
if entry is None or ver.get("linux-arm64"):
|
||||
continue
|
||||
ver["linux-arm64"] = entry
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
# write_file_if_changed stages a tempfile in the destination dir
|
||||
# and atomically replaces — safe against mid-write interruption
|
||||
# and concurrent invocations.
|
||||
write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n")
|
||||
_LOGGER.info(
|
||||
"Patched %s to add ninja linux-arm64 download "
|
||||
"(espressif/esp-idf#18272 backport).",
|
||||
tools_json,
|
||||
)
|
||||
|
||||
|
||||
def _check_esphome_idf_framework_install(
|
||||
version: str,
|
||||
targets: list[str],
|
||||
tools: list[str],
|
||||
force: bool = False,
|
||||
env: dict[str, str] | None = None,
|
||||
source_url: str | None = None,
|
||||
) -> tuple[Path, bool]:
|
||||
"""
|
||||
Check and install ESP-IDF framework.
|
||||
@@ -799,6 +897,11 @@ def _check_esphome_idf_framework_install(
|
||||
tools: list of tools to install
|
||||
force: If True, force reinstallation
|
||||
env: Optional dictionary of environment variables to set
|
||||
source_url: Optional override URL for the framework tarball. Supports
|
||||
the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` /
|
||||
``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When
|
||||
set, it replaces the default mirror list — no implicit fallback,
|
||||
so a misspelled URL fails loudly.
|
||||
|
||||
Returns:
|
||||
tuple of (framework_path, install_flag)
|
||||
@@ -820,6 +923,10 @@ def _check_esphome_idf_framework_install(
|
||||
env_stamp_file = framework_path / ESPHOME_STAMP_FILE
|
||||
idf_tools_path = framework_path / "tools" / "idf_tools.py"
|
||||
_LOGGER.info("Checking ESP-IDF %s framework ...", version)
|
||||
# Logged every invocation (not just on install) so the user can verify the
|
||||
# override. A changed URL needs ``esphome clean`` to force a re-download.
|
||||
if source_url:
|
||||
_LOGGER.info("Using framework source override: %s", source_url)
|
||||
|
||||
# 2. Download and extract the framework if not already extracted.
|
||||
# The marker is written last after extraction succeeds, so its presence
|
||||
@@ -847,14 +954,23 @@ def _check_esphome_idf_framework_install(
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
download_from_mirrors(
|
||||
ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file
|
||||
)
|
||||
mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
|
||||
download_from_mirrors(mirrors, substitutions, tmp.file)
|
||||
|
||||
_LOGGER.info("Extracting ESP-IDF %s framework ...", version)
|
||||
archive_extract_all(tmp.file, framework_path, progress_header="Extracting")
|
||||
extracted_marker.touch()
|
||||
|
||||
# Idempotent post-extract patch: written every invocation so a build
|
||||
# dir extracted before this fix gets the file too, without forcing a
|
||||
# clean. Skips when version.txt already exists.
|
||||
_write_idf_version_txt(framework_path, version)
|
||||
|
||||
# Apply the ninja linux-arm64 backport on every invocation, not just on
|
||||
# fresh extracts — idempotent and cheap, and lets a build dir carrying
|
||||
# a pre-patch tools.json get fixed up without forcing a clean.
|
||||
_patch_tools_json_for_linux_arm64(framework_path)
|
||||
|
||||
# 3. Check if the framework tools are the same and correctly installed
|
||||
if not install:
|
||||
install = True
|
||||
@@ -1011,6 +1127,7 @@ def check_esp_idf_install(
|
||||
tools: list[str] | None = None,
|
||||
features: list[str] | None = None,
|
||||
force: bool = False,
|
||||
source_url: str | None = None,
|
||||
) -> tuple[Path, Path]:
|
||||
"""
|
||||
Check and install ESP-IDF framework and Python environment.
|
||||
@@ -1021,6 +1138,10 @@ def check_esp_idf_install(
|
||||
tools: list of tools to install
|
||||
features: Features to install
|
||||
force: If True, force reinstallation
|
||||
source_url: Optional override URL for the framework tarball. When
|
||||
set, it replaces the default mirror list (no fallback). Forwarded
|
||||
to ``_check_esphome_idf_framework_install``; supports the same URL
|
||||
substitutions.
|
||||
|
||||
Returns:
|
||||
tuple of (framework_path, python_env_path)
|
||||
@@ -1043,7 +1164,7 @@ def check_esp_idf_install(
|
||||
|
||||
# 1) Framework
|
||||
framework_path, installed = _check_esphome_idf_framework_install(
|
||||
version, targets, tools, force=force, env=env
|
||||
version, targets, tools, force=force, env=env, source_url=source_url
|
||||
)
|
||||
|
||||
features = features or ESPHOME_IDF_DEFAULT_FEATURES
|
||||
|
||||
@@ -66,6 +66,12 @@ FILTER_IDF_LINES: list[str] = [
|
||||
# Drop the blank line rich emits after the note so the build log
|
||||
# doesn't end with an orphan gap before ESPHome's own status lines.
|
||||
r"\s*$",
|
||||
# ESP-IDF shells out to ``git rev-parse`` to embed a commit hash;
|
||||
# esphome-libs strips ``.git`` from the tarball so those probes fail
|
||||
# noisily without affecting the build.
|
||||
r"-- git rev-parse returned ",
|
||||
r"fatal: not a git repository",
|
||||
r"Stopping at filesystem boundary",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -94,9 +94,10 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
|
||||
_LOGGER.debug("Skipping size summary: %s", e)
|
||||
return
|
||||
|
||||
dram = data.get("memory_types", {}).get("DRAM") or {}
|
||||
ram_used = dram.get("used")
|
||||
ram_total = dram.get("size")
|
||||
memory_types = data.get("memory_types", {})
|
||||
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
|
||||
ram_used = ram_region.get("used")
|
||||
ram_total = ram_region.get("size")
|
||||
if ram_total and ram_used is not None:
|
||||
print(f"RAM: {_format_bar(ram_used, ram_total)}")
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import shutil
|
||||
import subprocess
|
||||
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
|
||||
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
|
||||
from esphome.espidf.size_summary import print_summary
|
||||
@@ -37,13 +38,27 @@ def _get_core_framework_version():
|
||||
return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION])
|
||||
|
||||
|
||||
def _get_framework_source_override() -> str | None:
|
||||
"""Return the user-supplied esp32.framework.source override, if any.
|
||||
|
||||
The override lets a user point the IDF tarball download at a custom URL
|
||||
(mirror, fork, local server). Substitutions like ``{VERSION}`` /
|
||||
``{MAJOR}`` etc. work the same as in the default mirror list.
|
||||
"""
|
||||
if CORE.config is None:
|
||||
return None
|
||||
return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE)
|
||||
|
||||
|
||||
def _get_esphome_esp_idf_paths(
|
||||
version: str | None = None,
|
||||
) -> tuple[os.PathLike, os.PathLike]:
|
||||
version = version or _get_core_framework_version()
|
||||
paths = _cache().paths
|
||||
if version not in paths:
|
||||
paths[version] = check_esp_idf_install(version)
|
||||
paths[version] = check_esp_idf_install(
|
||||
version, source_url=_get_framework_source_override()
|
||||
)
|
||||
return paths[version]
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ dependencies:
|
||||
bblanchon/arduinojson:
|
||||
version: "7.4.2"
|
||||
esphome/esp-audio-libs:
|
||||
version: 3.0.0
|
||||
version: 3.1.0
|
||||
esphome/esp-micro-speech-features:
|
||||
version: 1.2.3
|
||||
esphome/micro-decoder:
|
||||
@@ -100,6 +100,6 @@ dependencies:
|
||||
esp32async/asynctcp:
|
||||
version: 3.4.91
|
||||
sendspin/sendspin-cpp:
|
||||
version: 0.5.0
|
||||
version: 0.6.0
|
||||
lvgl/lvgl:
|
||||
version: 9.5.0
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import write_file_if_changed
|
||||
@@ -98,6 +99,7 @@ class StorageJSON:
|
||||
no_mdns: bool,
|
||||
framework: str | None = None,
|
||||
core_platform: str | None = None,
|
||||
toolchain: str | None = None,
|
||||
) -> None:
|
||||
# Version of the storage JSON schema
|
||||
assert storage_version is None or isinstance(storage_version, int)
|
||||
@@ -134,6 +136,8 @@ class StorageJSON:
|
||||
self.framework = framework
|
||||
# The core platform of this firmware. Like "esp32", "rp2040", "host" etc.
|
||||
self.core_platform = core_platform
|
||||
# The toolchain used for the build ("platformio" / "esp-idf")
|
||||
self.toolchain = toolchain
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
@@ -153,6 +157,7 @@ class StorageJSON:
|
||||
"no_mdns": self.no_mdns,
|
||||
"framework": self.framework,
|
||||
"core_platform": self.core_platform,
|
||||
"toolchain": self.toolchain,
|
||||
}
|
||||
|
||||
def to_json(self):
|
||||
@@ -189,6 +194,7 @@ class StorageJSON:
|
||||
),
|
||||
framework=esph.target_framework,
|
||||
core_platform=esph.target_platform,
|
||||
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -236,6 +242,7 @@ class StorageJSON:
|
||||
no_mdns = storage.get("no_mdns", False)
|
||||
framework = storage.get("framework")
|
||||
core_platform = storage.get("core_platform")
|
||||
toolchain = storage.get("toolchain")
|
||||
return StorageJSON(
|
||||
storage_version,
|
||||
name,
|
||||
@@ -253,6 +260,7 @@ class StorageJSON:
|
||||
no_mdns,
|
||||
framework,
|
||||
core_platform,
|
||||
toolchain,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -273,6 +281,18 @@ class StorageJSON:
|
||||
"""
|
||||
CORE.name = self.name
|
||||
CORE.build_path = self.build_path
|
||||
# Restore toolchain so upload/logs picks the right firmware_bin path.
|
||||
# An unknown value (corrupt sidecar, or written by a newer ESPHome)
|
||||
# just leaves CORE.toolchain None — the fallback then picks PlatformIO.
|
||||
if self.toolchain and CORE.toolchain is None:
|
||||
try:
|
||||
CORE.toolchain = Toolchain(self.toolchain)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Ignoring unknown toolchain %r from %s",
|
||||
self.toolchain,
|
||||
storage_path(),
|
||||
)
|
||||
target_platform = self.core_platform or self.target_platform.lower()
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: target_platform,
|
||||
|
||||
@@ -12,8 +12,8 @@ platformio==6.1.19
|
||||
esptool==5.2.0
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260425.0
|
||||
aioesphomeapi==45.0.3
|
||||
zeroconf==0.149.7
|
||||
aioesphomeapi==45.0.4
|
||||
zeroconf==0.149.16
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
ruamel.yaml.clib==0.2.15 # dashboard_import
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
substitutions:
|
||||
i2s_bclk_pin: GPIO27
|
||||
i2s_lrclk_pin: GPIO26
|
||||
i2s_mclk_pin: GPIO25
|
||||
i2s_dout_pin: GPIO12
|
||||
spdif_data_pin: GPIO4
|
||||
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
|
||||
i2s_audio:
|
||||
- id: i2s_output
|
||||
|
||||
@@ -20,6 +10,5 @@ speaker:
|
||||
use_apll: true
|
||||
timeout: 2s
|
||||
sample_rate: 48000
|
||||
bits_per_sample: 16bit
|
||||
channel: stereo
|
||||
i2s_mode: primary
|
||||
@@ -0,0 +1,8 @@
|
||||
substitutions:
|
||||
i2s_bclk_pin: GPIO27
|
||||
i2s_lrclk_pin: GPIO26
|
||||
i2s_mclk_pin: GPIO25
|
||||
i2s_dout_pin: GPIO12
|
||||
spdif_data_pin: GPIO4
|
||||
|
||||
<<: !include common-spdif_mode.yaml
|
||||
@@ -203,7 +203,7 @@ def test_generate_idf_component_yml_basic(tmp_component):
|
||||
tmp_component.data = {"description": "test", "repository": {"url": "http://aaa"}}
|
||||
result = generate_idf_component_yml(tmp_component)
|
||||
|
||||
assert result == "description: test\nversion: 1.0.0\nrepository: http://aaa\n"
|
||||
assert result == "description: test\nrepository: http://aaa\n"
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
|
||||
@@ -217,18 +217,16 @@ def test_generate_idf_component_yml_with_dependencies(tmp_component, tmp_path):
|
||||
|
||||
assert (
|
||||
result
|
||||
== f"""version: 1.0.0
|
||||
dependencies:
|
||||
== f"""dependencies:
|
||||
dep:
|
||||
version: '1.0'
|
||||
override_path: {dep.path}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def test_generate_idf_component_yml_missing_path_reraises(tmp_component):
|
||||
# A dep without a path and without a recognised source should re-raise
|
||||
# the underlying RuntimeError instead of silently producing a bad manifest.
|
||||
def test_generate_idf_component_yml_missing_path_raises(tmp_component):
|
||||
# A dep without a path is a contract violation — every dep is expected
|
||||
# to have been downloaded before YAML generation. Raise loudly.
|
||||
dep = IDFComponent("foo/bar", "1.0", source=None)
|
||||
|
||||
tmp_component.dependencies = [dep]
|
||||
@@ -422,15 +420,37 @@ def test_convert_library_with_repository():
|
||||
result = _convert_library_to_component(lib)
|
||||
|
||||
assert result.name == "foo/bar"
|
||||
assert result.version == "1.2.3"
|
||||
assert result.version == "*"
|
||||
assert isinstance(result.source, GitSource)
|
||||
assert result.source.ref == "v1.2.3"
|
||||
|
||||
|
||||
def test_convert_library_missing_ref():
|
||||
def test_convert_library_with_branch_ref():
|
||||
lib = Library("name", None, "https://github.com/foo/bar.git#some-branch")
|
||||
|
||||
result = _convert_library_to_component(lib)
|
||||
|
||||
assert result.name == "foo/bar"
|
||||
assert result.version == "*"
|
||||
assert isinstance(result.source, GitSource)
|
||||
assert result.source.ref == "some-branch"
|
||||
|
||||
|
||||
def test_convert_library_missing_ref_uses_default_branch():
|
||||
"""A bare URL with no #ref clones the remote's default branch.
|
||||
|
||||
Matches PIO's lib_deps behavior and external_components handling --
|
||||
git.clone_or_update with ref=None leaves the depth-1 clone on
|
||||
whatever branch the remote HEAD points at.
|
||||
"""
|
||||
lib = Library("name", None, "https://github.com/foo/bar.git")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_convert_library_to_component(lib)
|
||||
result = _convert_library_to_component(lib)
|
||||
|
||||
assert result.name == "foo/bar"
|
||||
assert result.version == "*"
|
||||
assert isinstance(result.source, GitSource)
|
||||
assert result.source.ref is None
|
||||
|
||||
|
||||
def test_convert_library_registry(monkeypatch):
|
||||
@@ -485,3 +505,113 @@ def test_process_dependencies_skips_invalid(tmp_component):
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert tmp_component.dependencies == []
|
||||
|
||||
|
||||
def test_process_dependencies_dict_form(tmp_component, monkeypatch):
|
||||
"""PIO library.json shorthand ``{"owner/Name": "version"}`` is honored.
|
||||
|
||||
Iterating a dict gives string keys, which would silently fail the
|
||||
``"name" in dependency`` substring check. Normalize to list-of-dicts
|
||||
first so the dict form (used by e.g. tesla-ble for its nanopb dep)
|
||||
is treated the same as the verbose list form.
|
||||
"""
|
||||
captured: list[Library] = []
|
||||
|
||||
def fake_generate(library):
|
||||
captured.append(library)
|
||||
return IDFComponent(
|
||||
library.name, library.version, source=URLSource("http://dummy.com")
|
||||
)
|
||||
|
||||
tmp_component.data = {
|
||||
"dependencies": {
|
||||
"nanopb/Nanopb": "^0.4.91",
|
||||
"BareName": "1.2.3",
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component, "_generate_idf_component", fake_generate
|
||||
)
|
||||
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert len(tmp_component.dependencies) == 2
|
||||
names = sorted(lib.name for lib in captured)
|
||||
versions = sorted(lib.version for lib in captured)
|
||||
assert names == ["BareName", "nanopb/Nanopb"]
|
||||
assert versions == ["1.2.3", "^0.4.91"]
|
||||
|
||||
|
||||
def test_process_dependencies_dict_form_with_url_value(tmp_component, monkeypatch):
|
||||
"""A dict-value that's a URL gets routed to ``repository`` like the list form."""
|
||||
captured: list[Library] = []
|
||||
|
||||
def fake_generate(library):
|
||||
captured.append(library)
|
||||
return IDFComponent(library.name, "*", source=URLSource("http://dummy.com"))
|
||||
|
||||
tmp_component.data = {
|
||||
"dependencies": {
|
||||
"foo/Bar": "https://github.com/foo/bar.git#main",
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component, "_generate_idf_component", fake_generate
|
||||
)
|
||||
monkeypatch.setattr(esphome.espidf.component, "_check_library_data", lambda x: None)
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].name == "foo/Bar"
|
||||
assert captured[0].version is None
|
||||
assert captured[0].repository == "https://github.com/foo/bar.git#main"
|
||||
|
||||
|
||||
def test_process_dependencies_dict_form_with_nested_spec(tmp_component, monkeypatch):
|
||||
"""A dict-value that's itself a dict is merged into the entry.
|
||||
|
||||
PIO's library.json allows ``{"owner/Name": {"version": "...", ...}}``
|
||||
for entries that need fields beyond just a version (platforms,
|
||||
frameworks, etc.). The extra fields flow into _check_library_data
|
||||
via the entry merge.
|
||||
"""
|
||||
captured: list[Library] = []
|
||||
checked: list[dict] = []
|
||||
|
||||
def fake_generate(library):
|
||||
captured.append(library)
|
||||
return IDFComponent(
|
||||
library.name, library.version, source=URLSource("http://dummy.com")
|
||||
)
|
||||
|
||||
tmp_component.data = {
|
||||
"dependencies": {
|
||||
"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component, "_generate_idf_component", fake_generate
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
esphome.espidf.component,
|
||||
"_check_library_data",
|
||||
checked.append,
|
||||
)
|
||||
|
||||
_process_dependencies(tmp_component)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].name == "nanopb/Nanopb"
|
||||
assert captured[0].version == "^0.4.91"
|
||||
# Extra spec fields reach _check_library_data so platform/framework
|
||||
# gating still applies.
|
||||
assert checked == [
|
||||
{
|
||||
"name": "Nanopb",
|
||||
"owner": "nanopb",
|
||||
"version": "^0.4.91",
|
||||
"platforms": "espidf",
|
||||
}
|
||||
]
|
||||
|
||||
58
tests/unit_tests/test_espidf_toolchain.py
Normal file
58
tests/unit_tests/test_espidf_toolchain.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Tests for esphome.espidf.toolchain helpers."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
|
||||
from esphome.core import CORE
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
|
||||
def test_get_framework_source_override_no_config():
|
||||
"""When CORE.config hasn't been set, no override is returned."""
|
||||
CORE.config = None
|
||||
assert toolchain._get_framework_source_override() is None
|
||||
|
||||
|
||||
def test_get_framework_source_override_no_esp32_section():
|
||||
"""A config without an esp32 section yields no override."""
|
||||
CORE.config = {}
|
||||
assert toolchain._get_framework_source_override() is None
|
||||
|
||||
|
||||
def test_get_framework_source_override_no_framework_source():
|
||||
"""An esp32 section without framework.source yields no override."""
|
||||
CORE.config = {"esp32": {CONF_FRAMEWORK: {}}}
|
||||
assert toolchain._get_framework_source_override() is None
|
||||
|
||||
|
||||
def test_get_framework_source_override_returns_value():
|
||||
"""A user-supplied framework source is returned verbatim."""
|
||||
url = "https://example.com/esp-idf-v{VERSION}.tar.xz"
|
||||
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
|
||||
assert toolchain._get_framework_source_override() == url
|
||||
|
||||
|
||||
def test_get_esphome_esp_idf_paths_forwards_source_override():
|
||||
"""_get_esphome_esp_idf_paths threads the override into check_esp_idf_install."""
|
||||
url = "https://my-mirror/esp-idf-v{VERSION}.tar.xz"
|
||||
CORE.config = {"esp32": {CONF_FRAMEWORK: {CONF_SOURCE: url}}}
|
||||
# Hit a fresh cache key so check_esp_idf_install is actually called.
|
||||
toolchain._cache().paths.clear()
|
||||
with patch.object(
|
||||
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
|
||||
) as mock_install:
|
||||
toolchain._get_esphome_esp_idf_paths("5.5.4")
|
||||
mock_install.assert_called_once_with("5.5.4", source_url=url)
|
||||
|
||||
|
||||
def test_get_esphome_esp_idf_paths_no_override():
|
||||
"""When no source override is configured, source_url=None is passed."""
|
||||
CORE.config = {}
|
||||
toolchain._cache().paths.clear()
|
||||
with patch.object(
|
||||
toolchain, "check_esp_idf_install", return_value=("/fw", "/penv")
|
||||
) as mock_install:
|
||||
toolchain._get_esphome_esp_idf_paths("5.5.4")
|
||||
mock_install.assert_called_once_with("5.5.4", source_url=None)
|
||||
128
tests/unit_tests/test_size_summary.py
Normal file
128
tests/unit_tests/test_size_summary.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Tests for esphome.espidf.size_summary.print_summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf.size_summary import print_summary
|
||||
|
||||
|
||||
def _write_size_json(tmp_path: Path, data: dict) -> Path:
|
||||
"""Drop a fake esp_idf_size.json under ``tmp_path`` and return the path."""
|
||||
out = tmp_path / "esp_idf_size.json"
|
||||
out.write_text(json.dumps(data))
|
||||
return out
|
||||
|
||||
|
||||
def _esp32_size_data() -> dict:
|
||||
"""Synthetic esp_idf_size.json for the original ESP32 (split IRAM/DRAM)."""
|
||||
return {
|
||||
"image_size": 827455,
|
||||
"memory_types": {
|
||||
"DRAM": {
|
||||
"size": 180736,
|
||||
"used": 47332,
|
||||
"sections": {
|
||||
".dram0.bss": {"abbrev_name": ".bss", "size": 30616},
|
||||
".dram0.data": {"abbrev_name": ".data", "size": 16716},
|
||||
},
|
||||
},
|
||||
"IRAM": {
|
||||
"size": 131072,
|
||||
"used": 80351,
|
||||
"sections": {
|
||||
".iram0.text": {"abbrev_name": ".text", "size": 79323},
|
||||
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _s3_size_data() -> dict:
|
||||
"""Synthetic esp_idf_size.json for ESP32-S3 (unified DIRAM)."""
|
||||
return {
|
||||
"image_size": 724215,
|
||||
"memory_types": {
|
||||
"DIRAM": {
|
||||
"size": 341760,
|
||||
"used": 104999,
|
||||
"sections": {
|
||||
".iram0.text": {"abbrev_name": ".text", "size": 58051},
|
||||
".dram0.bss": {"abbrev_name": ".bss", "size": 27088},
|
||||
".dram0.data": {"abbrev_name": ".data", "size": 19708},
|
||||
".noinit": {"abbrev_name": ".noinit", "size": 152},
|
||||
},
|
||||
},
|
||||
"IRAM": {
|
||||
"size": 16384,
|
||||
"used": 16384,
|
||||
"sections": {
|
||||
".iram0.text": {"abbrev_name": ".text", "size": 15356},
|
||||
".iram0.vectors": {"abbrev_name": ".vectors", "size": 1028},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_print_summary_esp32_uses_dram(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Original ESP32: DRAM has no ``.text``, so RAM = DRAM.used / DRAM.size unchanged."""
|
||||
size_json = _write_size_json(tmp_path, _esp32_size_data())
|
||||
print_summary(size_json, partitions_csv=None)
|
||||
out = capsys.readouterr().out
|
||||
assert "RAM:" in out
|
||||
assert "used 47332 bytes from 180736 bytes" in out
|
||||
|
||||
|
||||
def test_print_summary_s3_falls_back_to_diram(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""ESP32-S3 with no DRAM key falls back to DIRAM and reports raw region usage."""
|
||||
size_json = _write_size_json(tmp_path, _s3_size_data())
|
||||
print_summary(size_json, partitions_csv=None)
|
||||
out = capsys.readouterr().out
|
||||
assert "used 104999 bytes from 341760 bytes" in out
|
||||
|
||||
|
||||
def test_print_summary_skips_when_diram_total_collapses(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A zero-size region drops the RAM line rather than divide by zero."""
|
||||
size_json = _write_size_json(
|
||||
tmp_path,
|
||||
{
|
||||
"memory_types": {
|
||||
"DIRAM": {
|
||||
"size": 0,
|
||||
"used": 0,
|
||||
"sections": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
print_summary(size_json, partitions_csv=None)
|
||||
out = capsys.readouterr().out
|
||||
assert "RAM:" not in out
|
||||
|
||||
|
||||
def test_print_summary_handles_missing_json(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Missing size json is non-fatal and prints nothing."""
|
||||
print_summary(tmp_path / "does_not_exist.json", partitions_csv=None)
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
def test_print_summary_handles_no_memory_types(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A size json without ``memory_types`` still doesn't crash."""
|
||||
size_json = _write_size_json(tmp_path, {"image_size": 0})
|
||||
print_summary(size_json, partitions_csv=None)
|
||||
assert capsys.readouterr().out == ""
|
||||
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
import pytest
|
||||
|
||||
from esphome import storage_json
|
||||
from esphome.const import CONF_DISABLED, CONF_MDNS
|
||||
from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
@@ -308,6 +308,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
|
||||
mock_core.loaded_platforms = {"sensor"}
|
||||
mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}}
|
||||
mock_core.target_framework = "esp-idf"
|
||||
mock_core.toolchain = Toolchain.ESP_IDF
|
||||
|
||||
with patch("esphome.components.esp32.get_esp32_variant") as mock_variant:
|
||||
mock_variant.return_value = "ESP32-C3"
|
||||
@@ -327,6 +328,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
|
||||
assert result.no_mdns is True
|
||||
assert result.framework == "esp-idf"
|
||||
assert result.core_platform == "esp32"
|
||||
assert result.toolchain == "esp-idf"
|
||||
|
||||
|
||||
def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
|
||||
@@ -345,10 +347,12 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
|
||||
mock_core.loaded_platforms = set()
|
||||
mock_core.config = {} # No MDNS config means enabled
|
||||
mock_core.target_framework = "arduino"
|
||||
mock_core.toolchain = None
|
||||
|
||||
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
|
||||
|
||||
assert result.no_mdns is False
|
||||
assert result.toolchain is None
|
||||
|
||||
|
||||
def test_storage_json_load_valid_file(tmp_path: Path) -> None:
|
||||
@@ -470,6 +474,73 @@ def test_storage_json_equality() -> None:
|
||||
assert storage1 != "not a storage object"
|
||||
|
||||
|
||||
def _make_storage_with_toolchain(
|
||||
toolchain: str | None,
|
||||
) -> storage_json.StorageJSON:
|
||||
return storage_json.StorageJSON(
|
||||
storage_version=1,
|
||||
name="dev",
|
||||
friendly_name=None,
|
||||
comment=None,
|
||||
esphome_version="2024.1.0",
|
||||
src_version=1,
|
||||
address="dev.local",
|
||||
web_port=None,
|
||||
target_platform="ESP32",
|
||||
build_path=Path("/build"),
|
||||
firmware_bin_path=Path("/build/firmware.bin"),
|
||||
loaded_integrations=set(),
|
||||
loaded_platforms=set(),
|
||||
no_mdns=False,
|
||||
framework="esp-idf",
|
||||
core_platform="esp32",
|
||||
toolchain=toolchain,
|
||||
)
|
||||
|
||||
|
||||
def test_storage_json_toolchain_round_trip(setup_core: Path) -> None:
|
||||
"""Sidecar toolchain survives save -> load -> apply_to_core."""
|
||||
storage = _make_storage_with_toolchain("esp-idf")
|
||||
path = setup_core / "storage.json"
|
||||
path.write_text(storage.to_json())
|
||||
|
||||
# Serialization key is stable -- device-builder relies on it.
|
||||
assert json.loads(path.read_text())["toolchain"] == "esp-idf"
|
||||
|
||||
loaded = storage_json.StorageJSON.load(path)
|
||||
assert loaded is not None
|
||||
assert loaded.toolchain == "esp-idf"
|
||||
|
||||
CORE.toolchain = None
|
||||
with patch("esphome.components.esp32.get_esp32_variant"):
|
||||
loaded.apply_to_core()
|
||||
assert CORE.toolchain == Toolchain.ESP_IDF
|
||||
|
||||
|
||||
def test_storage_json_apply_to_core_preserves_cli_toolchain(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A CLI-set CORE.toolchain wins over the sidecar value."""
|
||||
loaded = _make_storage_with_toolchain("esp-idf")
|
||||
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
with patch("esphome.components.esp32.get_esp32_variant"):
|
||||
loaded.apply_to_core()
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
|
||||
def test_storage_json_apply_to_core_ignores_unknown_toolchain(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None."""
|
||||
loaded = _make_storage_with_toolchain("gcc")
|
||||
|
||||
CORE.toolchain = None
|
||||
with patch("esphome.components.esp32.get_esp32_variant"):
|
||||
loaded.apply_to_core()
|
||||
assert CORE.toolchain is None
|
||||
|
||||
|
||||
def test_esphome_storage_json_as_dict() -> None:
|
||||
"""Test EsphomeStorageJSON.as_dict returns correct dictionary."""
|
||||
storage = storage_json.EsphomeStorageJSON(
|
||||
|
||||
Reference in New Issue
Block a user