Merge branch 'esp8266-native-build-spec' into esp8266-native-ninja-emission

This commit is contained in:
J. Nick Koston
2026-09-17 08:46:09 -05:00
137 changed files with 2264 additions and 1284 deletions
+1
View File
@@ -0,0 +1 @@
../.agents/skills
+1
View File
@@ -0,0 +1 @@
../.agents/skills
+2
View File
@@ -265,6 +265,7 @@ esphome/components/i2s_audio/* @jesserockz
esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/iaqcore/* @yozik04
esphome/components/icnt86/* @danepowell
esphome/components/ili9xxx/* @clydebarrow @nielsnl68
esphome/components/improv_base/* @esphome/core
esphome/components/improv_ble/* @jesserockz
@@ -477,6 +478,7 @@ esphome/components/sendspin/image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
esphome/components/sendspin/switch/* @kahrendt
esphome/components/sendspin/text_sensor/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
+8 -5
View File
@@ -90,9 +90,10 @@ def get_project_cmakelists(
"""
idf_target = variant_to_idf_target(get_esp32_variant())
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
# --format=raw because the legacy mode doesn't support it.
# esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng;
# 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks
# total_size, hence the ELF fallback in espidf/size_summary.py; both
# go away together when 1.x support is dropped.
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
# Project-wide compile options: -D defines and -W warning flags (skip
@@ -211,10 +212,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
project({CORE.name})
# Emit raw JSON size data for ESPHome to read post-build.
# Emit per-memory-type JSON size data for ESPHome to read post-build.
# json2 stays small; raw dumps every symbol (~2s on a large map) and
# this command runs inside the link edge, blocking everything downstream.
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
+1 -6
View File
@@ -720,12 +720,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection *
}
void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) {
ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch)
if (msg.state) {
a_switch->turn_on();
} else {
a_switch->turn_off();
}
a_switch->control(msg.state);
}
#endif
+1 -1
View File
@@ -339,7 +339,7 @@ async def to_code(config: ConfigType) -> None:
# HTTPS streams verify the server against the root certificate bundle
require_certificate_bundle()
add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0")
add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1")
data = _get_data()
@@ -17,11 +17,7 @@ class BinaryLightOutput final : public light::LightOutput {
void write_state(light::LightState *state) override {
bool binary;
state->current_values_as_binary(&binary);
if (binary) {
this->output_->turn_on();
} else {
this->output_->turn_off();
}
this->output_->set_state(binary);
}
protected:
@@ -626,7 +626,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
// explicit kick the MTU would only be exchanged on the first GATT query,
// which never happens on a V3_WITH_CACHE connection.
// Both registration calls above return void (BTstack 075a078, arduino-pico
// 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by
// 6.1.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by
// the connect timeout in loop().
gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_);
}
@@ -13,12 +13,6 @@ void CopySwitch::setup() {
void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); }
void CopySwitch::write_state(bool state) {
if (state) {
source_->turn_on();
} else {
source_->turn_off();
}
}
void CopySwitch::write_state(bool state) { this->source_->control(state); }
} // namespace esphome::copy
+4 -1
View File
@@ -3,6 +3,7 @@
#include <utility>
#include <numbers>
#include "display_color_utils.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
@@ -770,10 +771,12 @@ Rect Display::get_clipping() const {
void Display::clear_clipping_() { this->clipping_rectangle_.clear(); }
void Display::feed_wdt_pixel_slow_() { App.feed_wdt(); }
bool Display::clip(int x, int y) {
if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height())
return false;
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return false;
return true;
}
+19
View File
@@ -758,6 +758,13 @@ class Display : public PollingComponent {
bool is_clipping() const { return !this->clipping_rectangle_.empty(); }
/// Whether (x, y) falls outside the active clipping rectangle. Tests the
/// stack top in place: get_clipping() is out of line and returns the Rect
/// by value, which per pixel drawing cannot afford.
bool ESPHOME_ALWAYS_INLINE is_point_clipped(int x, int y) const {
return this->is_clipping() && !this->clipping_rectangle_.back().inside(x, y);
}
/** Check if pixel is within region of display.
*/
bool clip(int x, int y);
@@ -774,6 +781,17 @@ class Display : public PollingComponent {
void do_update_();
void clear_clipping_();
/// Watchdog feed for per pixel loops. App.feed_wdt() is already rate
/// limited, but every call reads the clock; only every 256th pixel makes
/// that call, so the real feeds are unchanged and a pixel costs a counter.
/// At 20 us per pixel on the slowest e-paper path that is about 5 ms
/// between clock reads.
void ESPHOME_ALWAYS_INLINE feed_wdt_per_pixel_() {
if (++this->wdt_pixel_counter_ == 0)
this->feed_wdt_pixel_slow_();
}
void feed_wdt_pixel_slow_();
virtual int get_height_internal() = 0;
virtual int get_width_internal() = 0;
@@ -793,6 +811,7 @@ class Display : public PollingComponent {
std::vector<DisplayOnPageChangeTrigger *> on_page_change_triggers_;
bool auto_clear_enabled_{true};
std::vector<Rect> clipping_rectangle_;
uint8_t wdt_pixel_counter_{0};
bool show_test_card_{false};
};
@@ -2,7 +2,6 @@
#include <utility>
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::display {
@@ -44,7 +43,7 @@ int DisplayBuffer::get_height() {
}
void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return; // NOLINT
switch (this->rotation_) {
@@ -64,7 +63,7 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) {
break;
}
this->draw_absolute_pixel_internal(x, y, color);
App.feed_wdt();
this->feed_wdt_per_pixel_();
}
} // namespace esphome::display
-10
View File
@@ -63,16 +63,6 @@ bool Rect::equal(Rect rect) const {
return (rect.x == this->x) && (rect.w == this->w) && (rect.y == this->y) && (rect.h == this->h);
}
bool Rect::inside(int16_t test_x, int16_t test_y, bool absolute) const { // NOLINT
if (!this->is_set()) {
return true;
}
if (absolute) {
return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2();
}
return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h;
}
bool Rect::inside(Rect rect) const {
if (!this->is_set() || !rect.is_set()) {
return true;
+9 -1
View File
@@ -26,7 +26,15 @@ class Rect {
void shrink(Rect rect);
bool inside(Rect rect) const;
bool inside(int16_t test_x, int16_t test_y, bool absolute = true) const;
bool ESPHOME_ALWAYS_INLINE inside(int16_t test_x, int16_t test_y, bool absolute = true) const {
if (!this->is_set()) {
return true;
}
if (absolute) {
return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2();
}
return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h;
}
bool equal(Rect rect) const;
void info(const std::string &prefix = "rect info:");
};
+1 -1
View File
@@ -299,7 +299,7 @@ bool EPaperBase::initialise(bool partial) {
* @return false if the coordinates are out of bounds
*/
bool EPaperBase::rotate_coordinates_(int &x, int &y) {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return false;
if (this->effective_transform_ & SWAP_XY)
std::swap(x, y);
+58
View File
@@ -111,6 +111,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample"
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components"
CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_FLASH_CHIP = "flash_chip"
CONF_KEY_ID = "key_id"
CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision"
CONF_NVS_ENCRYPTION = "nvs_encryption"
@@ -464,6 +465,20 @@ ESP32_CHIP_REVISIONS = {
"3.1": "CONFIG_ESP32_REV_MIN_3_1",
}
# Flash vendor drivers ESP-IDF can link; each costs IRAM plus a 124 B table in DRAM
# and only the one matching the flash ID is ever used
ESP32_FLASH_CHIPS = {
"gd": "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP",
"issi": "CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP",
"mxic": "CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP",
"winbond": "CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP",
"boya": "CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP",
"th": "CONFIG_SPI_FLASH_SUPPORT_TH_CHIP",
"mxic_opi": "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP",
}
FLASH_CHIP_GENERIC = "generic"
FLASH_CHIP_OPI = "mxic_opi" # the octal driver, ESP32-S3 only
# Socket limit configuration for ESP-IDF
# ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10
DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default
@@ -1519,6 +1534,13 @@ def final_validate(config) -> None:
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_MINIMUM_CHIP_REVISION],
)
)
if config[CONF_VARIANT] != VARIANT_ESP32S3 and config.get(CONF_FLASH_MODE) == "opi":
errs.append(
cv.Invalid(
f"'{CONF_FLASH_MODE}: opi' is only supported on {VARIANT_ESP32S3}",
path=[CONF_FLASH_MODE],
)
)
if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_SRAM1_AS_IRAM]:
errs.append(
cv.Invalid(
@@ -1526,6 +1548,25 @@ def final_validate(config) -> None:
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_SRAM1_AS_IRAM],
)
)
if (flash_chip := advanced.get(CONF_FLASH_CHIP)) is not None:
opi = flash_chip == FLASH_CHIP_OPI
if opi and config[CONF_VARIANT] != VARIANT_ESP32S3:
errs.append(
cv.Invalid(
f"'{CONF_FLASH_CHIP}: {flash_chip}' is only supported on {VARIANT_ESP32S3}",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP],
)
)
elif opi != (config.get(CONF_FLASH_MODE) == "opi"):
errs.append(
cv.Invalid(
f"'{CONF_FLASH_CHIP}: {flash_chip}' requires '{CONF_FLASH_MODE}: opi'"
if opi
else f"'{CONF_FLASH_CHIP}: {flash_chip}' does not match "
f"'{CONF_FLASH_MODE}: opi'; octal flash uses {FLASH_CHIP_OPI}",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP],
)
)
if (
config[CONF_VARIANT] != VARIANT_ESP32P4
and config.get(CONF_ENGINEERING_SAMPLE) is not None
@@ -1964,6 +2005,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
*ESP32_CHIP_REVISIONS, string=True
),
cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean,
cv.Optional(CONF_FLASH_CHIP): cv.one_of(
FLASH_CHIP_GENERIC, *ESP32_FLASH_CHIPS, lower=True
),
# DHCP server is needed for WiFi AP mode. When WiFi component is used,
# it will handle disabling DHCP server when AP is not configured.
# Default to false (disabled) when WiFi is not used.
@@ -2609,6 +2653,13 @@ async def to_code(config):
# NVS finds stored preferences by key, so preference key migration is possible
cg.add_define("USE_PREFERENCE_KEY_LOOKUP")
cg.add_build_flag("-Wl,-z,noexecstack")
# assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and
# IDF's noflash placement puts the flash driver's copies in DRAM. The
# basename keeps the panic output useful at a fraction of the size.
# __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2.
if idf_version() >= cv.Version(5, 1, 0):
cg.add_build_flag("-D__FILE__=__FILE_NAME__")
cg.add_build_flag("-Wno-builtin-macro-redefined")
# 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])
@@ -2725,6 +2776,8 @@ async def to_code(config):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True
)
# the opi mode choice only exists once octal flash is enabled
add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_OCT_FLASH", flash_mode == "opi")
if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
@@ -2749,6 +2802,11 @@ async def to_code(config):
add_idf_sdkconfig_option(flag, rev == min_rev)
cg.add_define("USE_ESP32_MIN_CHIP_REVISION_SET")
# Keep only the flash vendor driver the board needs; the boot log names it
if (flash_chip := conf[CONF_ADVANCED].get(CONF_FLASH_CHIP)) is not None:
for chip, flag in ESP32_FLASH_CHIPS.items():
add_idf_sdkconfig_option(flag, chip == flash_chip)
# Use SRAM1 region as IRAM on ESP32 (original) variant
# This provides an additional 40KB of IRAM by using SRAM1 memory that was previously
# reserved for bootloader DRAM. Requires a bootloader from ESP-IDF v5.1 or later.
+9 -5
View File
@@ -173,7 +173,10 @@ static const char *const TAG = "esp32.crash";
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static uint32_t s_current_build_time = static_cast<uint32_t>(ESPHOME_BUILD_TIME);
void crash_handler_read_and_clear() {
// Validate the NOINIT record. Runs on every has_data() call; re-running is
// harmless and the magic is left alone so the record survives an OTA
// rollback reboot, crash_handler_clear() drops it once an API client has it.
static void read_crash_data() {
if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) {
s_crash_data_valid = true;
// Clamp counts to prevent out-of-bounds reads from corrupt .noinit data
@@ -194,11 +197,12 @@ void crash_handler_read_and_clear() {
s_raw_crash_data.other_reg_frame_count = s_raw_crash_data.other_backtrace_count;
#endif
}
// Don't clear magic here — crash data must survive OTA rollback reboots.
// Magic is cleared by crash_handler_clear() after an API client receives the data.
}
bool crash_handler_has_data() { return s_crash_data_valid; }
bool crash_handler_has_data() {
read_crash_data();
return s_crash_data_valid;
}
void crash_handler_clear() {
// Only clear the magic so data doesn't survive the next reboot.
@@ -426,7 +430,7 @@ static void log_foreign_addresses() {
// crashes again during boot, and allowing the CLI's process_stacktrace to match
// and decode each address individually.
void crash_handler_log() {
if (!s_crash_data_valid)
if (!crash_handler_has_data())
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
+1 -6
View File
@@ -4,11 +4,6 @@
namespace esphome::esp32 {
/// Read and validate crash data from NOINIT memory.
/// Does not clear the magic marker — call crash_handler_clear() after
/// the data has been delivered to an API client so it survives OTA rollback reboots.
void crash_handler_read_and_clear();
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
@@ -16,7 +11,7 @@ void crash_handler_log();
/// Call after the data has been delivered to an API client.
void crash_handler_clear();
/// Returns true if crash data was found this boot.
/// Returns true if crash data was found this boot, reading it first if needed.
bool crash_handler_has_data();
} // namespace esphome::esp32
-8
View File
@@ -1,9 +1,6 @@
#ifdef USE_ESP32
// defines.h must come before crash_handler.h so USE_ESP32_CRASH_HANDLER is set
// before crash_handler.h's #ifdef-guarded namespace block is parsed.
#include "esphome/core/defines.h"
#include "crash_handler.h"
#include "esphome/core/hal.h"
#include <esp_clk_tree.h>
@@ -45,11 +42,6 @@ void arch_restart() {
}
void arch_init() {
#ifdef USE_ESP32_CRASH_HANDLER
// Read crash data from previous boot before anything else
esp32::crash_handler_read_and_clear();
#endif
// Enable the task watchdog only on the loop task (from which we're currently running)
esp_task_wdt_add(nullptr);
@@ -169,7 +169,7 @@ void Esp32HostedUpdate::dump_config() {
ESP_LOGCONFIG(TAG,
" Mode: HTTP\n"
" Source URL: %s",
this->source_url_.c_str());
this->source_url_);
#else
ESP_LOGCONFIG(TAG,
" Mode: Embedded\n"
@@ -215,7 +215,7 @@ bool Esp32HostedUpdate::fetch_manifest_() {
auto container = this->http_request_parent_->get(this->source_url_);
if (container == nullptr || container->status_code != 200) {
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str());
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_);
this->status_set_error(LOG_STR("Failed to fetch manifest"));
return false;
}
@@ -25,7 +25,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// HTTP mode setters
void set_source_url(const std::string &url) { this->source_url_ = url; }
void set_source_url(const char *url) { this->source_url_ = url; }
void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; }
#else
// Embedded mode setters
@@ -38,7 +38,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// HTTP mode members
http_request::HttpRequestComponent *http_request_parent_{nullptr};
std::string source_url_;
const char *source_url_{nullptr}; // literal from codegen
std::string firmware_url_;
// HTTP mode helpers
-8
View File
@@ -367,14 +367,6 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ENABLE_SERIAL1):
enable_serial1()
# Arduino 2 has a non-standards conformant new that returns a nullptr instead of failing when
# out of memory and exceptions are disabled. Since Arduino 2.6.0, this flag can be used to make
# new abort instead. Use it so that OOM fails early (on allocation) instead of on dereference of
# a NULL pointer (so the stacktrace makes more sense), and for consistency with Arduino 3,
# which always aborts if exceptions are disabled.
# For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;`
cg.add_build_flag("-DNEW_OOM_ABORT")
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
# See throw_stubs.h for details. Must be prepended before <string>, so this
+10 -2
View File
@@ -166,9 +166,17 @@ def ota_esphome_final_validate(config: ConfigType) -> None:
CONF_PASSWORD,
)
# web_server and prometheus keep the shared listener up; the captive
# portal's copy only exists on the fallback AP and is the recovery path
# portal's copy only exists on the fallback AP and is the recovery path.
# web_server `ota: false` gates /update behind the captive portal on
# every listener
web_server_conf = full_conf.get(CONF_WEB_SERVER)
plaintext_update_reachable = (
web_server_conf.get(CONF_OTA) is not False
if web_server_conf is not None
else "prometheus" in full_conf
)
if (
(CONF_WEB_SERVER in full_conf or "prometheus" in full_conf)
plaintext_update_reachable
and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf)
and any(
CONF_ENCRYPTION in conf
+2 -10
View File
@@ -13,18 +13,10 @@ void GPIOSwitch::setup() {
bool initial_state = this->get_initial_state_with_restore_mode().value_or(false);
// write state before setup
if (initial_state) {
this->turn_on();
} else {
this->turn_off();
}
this->control(initial_state);
this->pin_->setup();
// write after setup again for other IOs
if (initial_state) {
this->turn_on();
} else {
this->turn_off();
}
this->control(initial_state);
}
void GPIOSwitch::dump_config() {
LOG_SWITCH("", "GPIO Switch", this);
+2 -3
View File
@@ -1,5 +1,4 @@
#include "hub75_component.h"
#include "esphome/core/application.h"
#include <cinttypes>
@@ -124,11 +123,11 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) {
if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]]
return;
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return;
driver_->set_pixel(x, y, color.r, color.g, color.b);
App.feed_wdt();
this->feed_wdt_per_pixel_();
}
void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order,
@@ -48,10 +48,11 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic
auto *speaker = static_cast<I2SAudioSpeakerSPDIF *>(user_ctx);
size_t bytes_written = 0;
esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait);
if (err != ESP_OK) {
if (err != ESP_OK || bytes_written != size) {
ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size);
return (err != ESP_OK) ? err : ESP_FAIL;
}
return err;
return ESP_OK;
}
void I2SAudioSpeakerSPDIF::setup() {
@@ -167,33 +168,44 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
}
}
if (!successful_setup) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
} else {
// Preload DMA buffers with SPDIF-encoded silence before enabling the channel.
// This ensures the first data transmitted is valid SPDIF (not raw zeros from
// auto_clear) and prevents phantom DMA events before real audio is available.
// Each preloaded block pushes a 0-real-frame record so that the corresponding
// on_sent events drain in lockstep without crediting any audio frames.
// Preload DMA buffers with SPDIF-encoded silence before enabling the channel.
// This ensures the first data transmitted is valid SPDIF (not raw zeros from
// auto_clear) and prevents phantom DMA events before real audio is available.
// Each preloaded block pushes a 0-real-frame record so that the corresponding
// on_sent events drain in lockstep without crediting any audio frames. Runs with
// the channel disabled: at startup and after a resync.
auto preload_silence = [&]() -> bool {
bool ok = true;
this->spdif_encoder_->set_preload_mode(true);
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
// 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
}
const uint32_t silence_record = 0;
xQueueSendToBack(this->write_records_queue_, &silence_record, 0);
if ((this->spdif_encoder_->flush_with_silence(0) != ESP_OK) ||
(xQueueSendToBack(this->write_records_queue_, &silence_record, 0) != pdTRUE)) {
ok = false;
break;
}
}
this->spdif_encoder_->set_preload_mode(false);
this->spdif_encoder_->reset(); // Clean encoder state for the main loop
return ok;
};
// Now register the callback and enable the channel
if (successful_setup) {
successful_setup = preload_silence();
}
if (successful_setup) {
// Register the callback before enabling so the first transmitted block generates a queued event.
xQueueReset(this->i2s_event_queue_);
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
i2s_channel_enable(this->tx_handle_);
successful_setup = i2s_channel_enable(this->tx_handle_) == ESP_OK;
}
if (!successful_setup) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
} else {
// Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer).
// We drain real PCM up to one block from the ring buffer and silence-pad any remainder.
// Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard
@@ -210,24 +222,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
uint32_t spdif_pending_frames = 0;
int64_t spdif_pending_timestamp = 0;
uint32_t spdif_dma_event_count = 0;
bool resync_needed = false;
// Real frames consumed from the ring buffer that never reached a write record
uint32_t unrecorded_frames = 0;
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
// SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data
// to keep the receiver synced. Exits only via break (stream info change, silence timeout,
// lockstep desync, dropped event, or partial-write failure).
// or a failed lockstep resync).
while (true) {
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
// The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion
// event; that desyncs the lockstep queues permanently and the only safe recovery is a full
// task restart.
if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) {
ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep");
break;
}
// User-initiated stop. In SPDIF continuous mode, transition to silence output rather
// than tearing the task down.
this->spdif_silence_start_ = millis();
@@ -244,6 +252,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
break;
}
if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) {
ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep");
resync_needed = true;
}
if (resync_needed) {
// Rebuild the lockstep in place. Frames held back by decimation are credited too, since their
// blocks are discarded with the rest of the DMA contents.
this->spdif_encoder_->reset();
const uint32_t credited_frames = unrecorded_frames + spdif_pending_frames;
const bool resynced = this->resync_lockstep_(credited_frames, preload_silence);
unrecorded_frames = 0;
spdif_pending_frames = 0;
spdif_dma_event_count = 0;
resync_needed = false;
if (credited_frames > 0) {
// Real audio was dropped, so the silence timer's start no longer reflects the stream
this->spdif_silence_start_ = 0;
}
if (!resynced) {
ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task");
break;
}
}
// Drain ISR completion events, popping a matching record for each.
int64_t write_timestamp;
bool lockstep_broken = false;
@@ -253,8 +285,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// order matches DMA completion order. Empty records queue here means lockstep broke.
uint32_t real_frames = 0;
if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) {
ESP_LOGV(TAG, "Event without matching write record");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep");
lockstep_broken = true;
break;
}
@@ -290,8 +321,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
}
}
if (lockstep_broken) {
ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task");
break;
resync_needed = true;
continue;
}
// Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write
@@ -322,9 +353,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
&blocks_sent, &pcm_consumed);
if (err != ESP_OK) {
// A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer;
// resuming would credit the next iteration's bytes against an old block. Bail and
// let loop() restart the task with a clean encoder.
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
// resuming would credit the next iteration's bytes against an old block.
ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep");
partial_write_failure = true;
break;
}
@@ -341,7 +371,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
}
if (partial_write_failure) {
break;
unrecorded_frames += real_frames_in_block;
resync_needed = true;
continue;
}
if (!block_committed) {
@@ -349,16 +381,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// or emit a full silence block if the encoder is empty.
esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks);
if (err != ESP_OK) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
break;
ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep");
unrecorded_frames += real_frames_in_block;
resync_needed = true;
continue;
}
}
// One block committed to DMA; push exactly one record carrying its real-audio frame count.
// Failure here means the records queue is full, which violates the lockstep invariant.
if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
break;
ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep");
unrecorded_frames += real_frames_in_block;
resync_needed = true;
continue;
}
// Silence-timeout tracking and graceful-stop reset.
@@ -80,17 +80,6 @@ void I2SAudioSpeakerBase::loop() {
}
if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) {
ESP_LOGV(TAG, "Stopping");
// Lockstep-breaking error bits are latched by the task and cleared along with all other bits
// when TASK_STOPPED is processed; log them here, exactly once, as the task winds down.
if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) {
ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync");
}
if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) {
ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task");
}
if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) {
ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task");
}
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
this->state_ = speaker::STATE_STOPPING;
}
@@ -325,16 +314,10 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s
I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx;
if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) {
// Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_``
// and any per-buffer record queue maintained by the task are permanently desynced, so the task
// must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP
// (so the task bails immediately, closing the race where loop() could clear the error bit
// before the task observes it).
// Queue is full, so discard the oldest event. The lockstep queues are now desynced; the task resyncs them.
int64_t dummy;
xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1);
xEventGroupSetBitsFromISR(this_speaker->event_group_,
SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP,
&need_yield2);
xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT, &need_yield2);
}
xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3);
@@ -342,6 +325,24 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s
return need_yield1 | need_yield2 | need_yield3;
}
void I2SAudioSpeakerBase::drain_lockstep_(uint32_t extra_frames) {
// Stop DMA so no more completion events arrive while the queues are rebuilt
i2s_channel_disable(this->tx_handle_);
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT);
uint32_t frames = extra_frames;
uint32_t record_frames = 0;
while (xQueueReceive(this->write_records_queue_, &record_frames, 0) == pdTRUE) {
frames += record_frames;
}
xQueueReset(this->i2s_event_queue_);
if (frames > 0) {
ESP_LOGV(TAG, "Crediting %" PRIu32 " dropped frames as played", frames);
this->audio_output_callback_(frames, esp_timer_get_time());
}
}
void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) {
#ifdef USE_AUDIO_DAC
if (this->audio_dac_ != nullptr) {
@@ -36,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t {
ERR_ESP_NO_MEM = (1 << 19),
ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event
ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested
ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync
ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
};
@@ -134,6 +132,21 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
/// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup.
virtual void on_task_stopped() {}
/// @brief Rebuilds the lockstep queues in place: disables the channel, credits every in-flight real frame as
/// played now, empties both queues, preloads silence through ``preload`` and re-enables the channel. Speaker
/// task only.
/// @param extra_frames Real frames the caller consumed that never reached a write record
/// @param preload Callable returning true once every DMA descriptor holds silence with a matching record
/// @return false if the preload or the channel enable failed; the caller should restart the task
template<typename F> bool resync_lockstep_(uint32_t extra_frames, F &&preload) {
this->drain_lockstep_(extra_frames);
return preload() && (i2s_channel_enable(this->tx_handle_) == ESP_OK);
}
/// @brief Disables the channel, credits ``extra_frames`` plus every real frame still recorded as in flight,
/// and empties both lockstep queues.
void drain_lockstep_(uint32_t extra_frames);
/// @brief Apply software volume control by running the samples through the gain ramp. Called from the
/// speaker task only.
/// @param data Pointer to audio sample data (modified in place)
@@ -134,27 +134,29 @@ void I2SAudioSpeaker::run_speaker_task() {
}
}
if (successful_setup) {
// Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer.
// This guarantees that every on_sent event has a corresponding write record from the start, so
// ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime.
// Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer, so every
// on_sent event has a write record from the start. Runs with the channel disabled: at startup and after a resync.
auto preload_silence = [&]() -> bool {
for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) {
size_t bytes_loaded = 0;
esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded);
if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) {
ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err,
(unsigned) bytes_loaded);
successful_setup = false;
break;
return false;
}
uint32_t zero_real_frames = 0;
if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) {
// Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries.
ESP_LOGV(TAG, "Failed to push preload write record");
successful_setup = false;
break;
return false;
}
}
return true;
};
if (successful_setup) {
successful_setup = preload_silence();
}
if (successful_setup) {
@@ -177,6 +179,9 @@ void I2SAudioSpeaker::run_speaker_task() {
// stop to wait until every real-audio buffer has been confirmed played by an ISR event.
uint32_t pending_real_buffers = 0;
uint32_t last_data_received_time = millis();
bool resync_needed = false;
// Real frames consumed from the ring buffer that never reached a write record
uint32_t unrecorded_frames = 0;
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
@@ -197,8 +202,6 @@ void I2SAudioSpeaker::run_speaker_task() {
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
// COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion
// event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases).
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
break;
@@ -214,6 +217,22 @@ void I2SAudioSpeaker::run_speaker_task() {
break;
}
if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) {
ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep");
resync_needed = true;
}
if (resync_needed) {
// Rebuild the lockstep in place; the ring buffer keeps accepting audio throughout
const bool resynced = this->resync_lockstep_(unrecorded_frames, preload_silence);
unrecorded_frames = 0;
pending_real_buffers = 0;
resync_needed = false;
if (!resynced) {
ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task");
break;
}
}
// Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_
// entry by construction (preloaded records at startup, plus exactly one record pushed per
// iteration alongside exactly one DMA-buffer-sized write).
@@ -223,8 +242,7 @@ void I2SAudioSpeaker::run_speaker_task() {
uint32_t real_frames = 0;
if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) {
// Should never happen: would indicate the lockstep invariant is broken.
ESP_LOGV(TAG, "Event without matching write record");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep");
lockstep_broken = true;
break;
}
@@ -240,7 +258,8 @@ void I2SAudioSpeaker::run_speaker_task() {
}
}
if (lockstep_broken) {
break;
resync_needed = true;
continue;
}
// Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring
@@ -299,10 +318,12 @@ void I2SAudioSpeaker::run_speaker_task() {
size_t bw = 0;
i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS);
if (bw != output_bytes) {
// A short real-audio write breaks DMA descriptor alignment for every subsequent event;
// the only safe recovery is to restart the task.
ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
// A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather
// than retry it: it was already narrowed in place.
ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw,
(unsigned) output_bytes);
audio_source->consume(input_bytes);
real_frames_total += frames_to_write;
partial_write_failure = true;
break;
}
@@ -316,7 +337,9 @@ void I2SAudioSpeaker::run_speaker_task() {
}
if (partial_write_failure) {
break;
unrecorded_frames += real_frames_total;
resync_needed = true;
continue;
}
const size_t silence_bytes = dma_buffer_bytes - bytes_written_total;
@@ -325,19 +348,22 @@ void I2SAudioSpeaker::run_speaker_task() {
i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS);
if (bw != silence_bytes) {
// Same descriptor-alignment hazard as a partial real-audio write.
ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
break;
ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw,
(unsigned) silence_bytes);
unrecorded_frames += real_frames_total;
resync_needed = true;
continue;
}
}
// Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this
// succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep
// invariant is broken and every subsequent timestamp would be silently wrong, so bail.
// invariant is broken and every subsequent timestamp would be silently wrong, so rebuild it.
if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) {
ESP_LOGV(TAG, "Exiting: write records queue full");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
break;
ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep");
unrecorded_frames += real_frames_total;
resync_needed = true;
continue;
}
if (real_frames_total > 0) {
pending_real_buffers++;
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@danepowell"]
+84
View File
@@ -0,0 +1,84 @@
#include "icnt86.h"
#include "esphome/core/log.h"
namespace esphome::icnt86 {
static const char *const TAG = "icnt86";
static constexpr uint16_t REG_TOUCH_NUM = 0x1001;
static constexpr uint16_t REG_POINT1 = 0x1002;
static constexpr uint8_t MAX_TOUCHES = 5;
static constexpr uint8_t POINT_SIZE = 7;
void ICNT86Touchscreen::setup() {
ESP_LOGCONFIG(TAG, "Setting up icnt86 Touchscreen...");
// Register interrupt pin
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// Perform reset if necessary
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(false);
delay(10);
this->reset_pin_->digital_write(true);
}
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
void ICNT86Touchscreen::update_touches() {
uint8_t buf[MAX_TOUCHES * POINT_SIZE] = {0};
uint8_t mask[1] = {0x00};
if (this->read_register16(REG_TOUCH_NUM, buf, 1) != i2c::ERROR_OK) {
this->status_set_warning();
this->skip_update_ = true;
ESP_LOGW(TAG, "Failed to read touch count");
return;
}
uint8_t touch_count = buf[0];
if (touch_count == 0x00 || touch_count > MAX_TOUCHES) { // No new touch
this->status_clear_warning();
return;
}
if (this->read_register16(REG_POINT1, buf, touch_count * POINT_SIZE) != i2c::ERROR_OK) {
this->status_set_warning();
this->skip_update_ = true;
ESP_LOGW(TAG, "Failed to read touch points");
return;
}
this->write_register16(REG_TOUCH_NUM, mask, 1);
ESP_LOGV(TAG, "Touch count: %d", touch_count);
this->status_clear_warning();
for (uint8_t i = 0; i < touch_count; i++) {
uint16_t x = ((uint16_t) buf[2 + 7 * i] << 8) + buf[1 + 7 * i];
uint16_t y = ((uint16_t) buf[4 + 7 * i] << 8) + buf[3 + 7 * i];
uint8_t pressure = buf[5 + 7 * i];
uint8_t touch_id = buf[6 + 7 * i];
// A zero-pressure report just means this point is no longer touched; skipping it here leaves is_touched_
// false (when no other point is active) so send_touches_() reports the release as normal.
if (pressure != 0) {
this->add_raw_touch_position_(touch_id, x, y, pressure);
}
}
}
void ICNT86Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG, "icnt86 Touchscreen:");
LOG_I2C_DEVICE(this);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
}
} // namespace esphome::icnt86
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
namespace esphome::icnt86 {
class ICNT86Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; }
protected:
void update_touches() override;
InternalGPIOPin *interrupt_pin_{};
GPIOPin *reset_pin_{nullptr};
};
} // namespace esphome::icnt86
+40
View File
@@ -0,0 +1,40 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN
from esphome.types import ConfigType
CODEOWNERS = ["@danepowell"]
DEPENDENCIES = ["i2c"]
icnt86_ns = cg.esphome_ns.namespace("icnt86")
ICNT86Touchscreen = icnt86_ns.class_(
"ICNT86Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ICNT86Touchscreen),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema,
}
).extend(i2c.i2c_device_schema(0x48))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN):
cg.add(
var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config))
)
if reset_pin_config := config.get(CONF_RESET_PIN):
cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config)))
+5 -7
View File
@@ -48,14 +48,12 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color
continue; // skip drawing
}
break;
case TRANSPARENCY_ALPHA_CHANNEL: {
auto on = (float) gray / 255.0f;
auto off = 1.0f - on;
// blend color_on and color_off
color = Color(color_on.r * on + color_off.r * off, color_on.g * on + color_off.g * off,
color_on.b * on + color_off.b * off, 0xFF);
case TRANSPARENCY_ALPHA_CHANNEL:
// gray is the alpha: blend from color_off to color_on, drawn opaque
color = Color(Color::blend_channel(color_off.r, color_on.r, gray),
Color::blend_channel(color_off.g, color_on.g, gray),
Color::blend_channel(color_off.b, color_on.b, gray), 0xFF);
break;
}
default:
break;
}
-1
View File
@@ -54,7 +54,6 @@ class Image : public display::BaseImage {
const uint8_t *data_start_;
Transparency transparency_;
size_t bpp_{};
size_t stride_{};
#ifdef USE_LVGL
lv_img_dsc_t dsc_{};
#endif
@@ -208,11 +208,7 @@ void ImprovBLEComponent::set_status_indicator_state_(bool state) {
if (this->status_indicator_state_ == state)
return;
this->status_indicator_state_ = state;
if (state) {
this->status_indicator_->turn_on();
} else {
this->status_indicator_->turn_off();
}
this->status_indicator_->set_state(state);
#endif
}
+2 -2
View File
@@ -855,7 +855,7 @@ void IT8951Display::apply_transform_(int &x, int &y) const {
}
bool IT8951Display::rotate_coordinates_(int &x, int &y) {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return false;
this->apply_transform_(x, y);
if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0)
@@ -929,7 +929,7 @@ void IT8951Display::fill(Color color) {
void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) {
if (this->buffer_ == nullptr)
return;
App.feed_wdt();
this->feed_wdt_per_pixel_();
if (!this->rotate_coordinates_(x, y))
return;
this->write_pixel_native_(static_cast<uint16_t>(x), static_cast<uint16_t>(y), color);
+2
View File
@@ -66,6 +66,8 @@ JsonDocument parse_json(const uint8_t *data, size_t len) {
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape)
}
JsonBuilder::JsonBuilder() = default;
SerializationBuffer<> JsonBuilder::serialize() {
// ===========================================================================================
// CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING
+3
View File
@@ -168,6 +168,9 @@ inline JsonDocument parse_json(const std::string &data) {
/// Builder class for creating JSON documents without lambdas
class JsonBuilder {
public:
// Out of line: inlining the JsonDocument constructor duplicates it at every call site
JsonBuilder();
JsonObject root() {
if (!root_created_) {
root_ = doc_.to<JsonObject>();
@@ -7,7 +7,8 @@ namespace esphome::ld2450 {
class MultiTargetSwitch : public switch_::Switch, public Parented<LD2450Component> {
public:
MultiTargetSwitch() = default;
// User provided, not "= default": `new(p) MultiTargetSwitch()` would zero-fill .bss that is already zero.
MultiTargetSwitch() {}
protected:
void write_state(bool state) override;
+4 -16
View File
@@ -301,14 +301,10 @@ void LD6002BComponent::setup() {
target_display_controlled = true;
// Nothing reports this switch back, so its restored state is the only state
// there is. Restoring through the switch keeps its inversion in the path:
// the restored value is logical, and turn_on()/turn_off() are what turn it
// the restored value is logical, and driving the switch is what turns it
// into the raw command, the published state and the stream flag.
const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true);
if (state) {
this->target_display_switch_->turn_on();
} else {
this->target_display_switch_->turn_off();
}
this->target_display_switch_->control(state);
}
#endif
if (!target_display_controlled) {
@@ -328,11 +324,7 @@ void LD6002BComponent::setup() {
// The switch owns the stream, so it is also what applies the restored state:
// driving it rather than the module keeps the entity's inversion in the path.
const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false);
if (state) {
this->point_cloud_switch_->turn_on();
} else {
this->point_cloud_switch_->turn_off();
}
this->point_cloud_switch_->control(state);
}
#endif
if (!point_cloud_controlled) {
@@ -375,11 +367,7 @@ void LD6002BComponent::setup() {
// Driving the switch applies its inversion; it also marks the restored value
// as reported, so the work mode fallback runs on that until the query lands.
const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false);
if (state) {
this->low_power_switch_->turn_on();
} else {
this->low_power_switch_->turn_off();
}
this->low_power_switch_->control(state);
}
#else
bool want_low_power = false;
@@ -38,14 +38,6 @@ float Mcp4461Wiper::update_state() {
return this->state_;
}
void Mcp4461Wiper::set_state(bool state) {
if (state) {
this->turn_on();
} else {
this->turn_off();
}
}
void Mcp4461Wiper::turn_on() { this->parent_->enable_wiper_(this->wiper_); }
void Mcp4461Wiper::turn_off() { this->parent_->disable_wiper_(this->wiper_); }
@@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parented<Mcp4461Co
/// @brief Set level of wiper
/// @param[in] state - The desired float level in range 0-1.0
void set_level(float state);
/// @brief Enables/Disables current output using bool parameter
/// @param[in] state boolean var representing desired state (true=ON, false=OFF)
void set_state(bool state) override;
/// @brief Enables current output
void turn_on() override;
/// @brief Disables current output
@@ -221,6 +221,10 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
sendspin_service.proto = MDNS_STR(SERVICE_TCP);
sendspin_service.port = []() -> uint16_t { return USE_SENDSPIN_PORT; };
sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}};
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
// Starts disabled; the sendspin hub enables it once its server is running
sendspin_service.enabled = false;
#endif
#endif
#ifdef USE_WEBSERVER
+1 -1
View File
@@ -259,7 +259,7 @@ bool MipiDsi::check_buffer_() {
}
void MipiDsi::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return;
switch (this->rotation_) {
+1 -1
View File
@@ -259,7 +259,7 @@ bool MipiRgb::check_buffer_() {
}
void MipiRgb::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y) || this->is_failed())
if (this->is_point_clipped(x, y) || this->is_failed())
return;
switch (this->rotation_) {
+1 -1
View File
@@ -604,7 +604,7 @@ class MipiSpiBuffer
// Draw a pixel at the given coordinates.
void draw_pixel_at(int x, int y, Color color) override {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return;
if constexpr (not HAS_HARDWARE_ROTATION) {
if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) {
+1 -1
View File
@@ -155,7 +155,7 @@ async def to_code(config: ConfigType) -> None:
{
cv.GenerateID(): cv.use_id(SourceSpeaker),
cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable(
cv.int_range(min=0, max=51)
cv.int_range(min=0, max=255)
),
cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable(
cv.positive_time_period_milliseconds
@@ -385,7 +385,8 @@ void MixerSpeaker::loop() {
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
// Keep a start request that arrived while the task was stopping, otherwise it is lost for good
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS & ~MIXER_TASK_COMMAND_START);
this->all_stopped_since_ms_ = 0;
}
+1 -15
View File
@@ -8,13 +8,7 @@ from esphome import pins
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_DISABLE_CRC,
CONF_FLOW_CONTROL_PIN,
CONF_ID,
)
from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID
from esphome.cpp_generator import MockObj
from esphome.cpp_helpers import gpio_pin_expression
import esphome.final_validate as fv
@@ -285,10 +279,6 @@ CONFIG_SCHEMA = cv.typed_schema(
cv.Optional(
CONF_TURNAROUND_TIME, default="600ms"
): cv.positive_time_period_milliseconds,
# Remove before 2026.10.0
cv.Optional(CONF_DISABLE_CRC): cv.invalid(
"'disable_crc' has been removed. The parser no longer requires it — remove this option."
),
}
)
.extend(cv.COMPONENT_SCHEMA)
@@ -297,10 +287,6 @@ CONFIG_SCHEMA = cv.typed_schema(
{
cv.GenerateID(): cv.declare_id(ModbusServer),
cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema,
# Remove before 2026.10.0
cv.Optional(CONF_DISABLE_CRC): cv.invalid(
"'disable_crc' has been removed. The parser no longer requires it — remove this option."
),
}
)
.extend(cv.COMPONENT_SCHEMA)
-8
View File
@@ -252,14 +252,6 @@ class ModbusClientHub : public Modbus {
void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; }
bool tx_buffer_empty();
bool tx_blocked() override;
ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0")
void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities,
uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) {
this->queue_pdu(address,
helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload,
payload_len),
device);
};
/// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast,
/// address 0, gets only on_sent()). False = refused, and no callback of any kind follows.
/// Neither means anything reached the wire - on_sent() reports that.
@@ -30,53 +30,10 @@ using modbus::ModbusFunctionCode;
using modbus::ModbusRegisterType;
#pragma GCC diagnostic pop
// Remove before 2026.10.0 — these helpers have moved to modbus::helpers
ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0")
inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); }
ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0")
inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) {
return modbus::helpers::modbus_register_read_function(reg_type);
}
ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0")
inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) {
return modbus::helpers::modbus_register_write_function(reg_type);
}
ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0")
inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); }
ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::byte_from_hex_str(value, pos);
}
ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::word_from_hex_str(value, pos);
}
ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::dword_from_hex_str(value, pos);
}
ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0")
inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
return modbus::helpers::qword_from_hex_str(value, pos);
}
template<typename T>
ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0")
T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
return modbus::helpers::get_data<T>(data, buffer_offset);
}
// Span overloads of the deprecated helpers below: read lambdas receive their payload as a
// Span overloads of the former modbus_controller helpers: read lambdas receive their payload as a
// std::span<const uint8_t> (previously a const std::vector<uint8_t> &), and a span does not convert to
// a vector, so existing lambdas calling these by name need an overload that accepts one. These carry
// this release's deprecation window, since the span forms only exist from it.
// the 2026.8.0 deprecation window, since the span forms only exist from it.
// payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers
// type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here
// would only make that call ambiguous.
@@ -99,33 +56,6 @@ inline bool coil_from_vector(int coil, std::span<const uint8_t> data) {
return modbus::helpers::bit_from_packed(coil, data);
}
template<typename N>
ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0")
N mask_and_shift_by_rightbit(N data, uint32_t mask) {
return modbus::helpers::mask_and_shift_by_rightbit(data, mask);
}
ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
inline void number_to_payload(std::vector<uint16_t> &data, int64_t value, SensorValueType value_type) {
modbus::helpers::number_to_payload(data, value, value_type);
}
ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0")
inline int64_t payload_to_number(const std::vector<uint8_t> &data, SensorValueType sensor_value_type, uint8_t offset,
uint32_t bitmask) {
return modbus::helpers::payload_to_number(std::span<const uint8_t>(data), sensor_value_type, offset, bitmask)
.value_or(0);
}
ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0")
inline std::vector<uint16_t> float_to_payload(float value, SensorValueType value_type) {
std::vector<uint16_t> data;
modbus::helpers::float_to_payload(data, value, value_type);
return data;
}
class ModbusController;
/// How an item relates to the register range built just before it (same register type, address order).
/// The numeric order doubles as the comparator tiebreak for items at the same address (see
/// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and
@@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span<const uint8_t> data) {
}
}
ESP_LOGD(TAG, "Number new state : %.02f", result);
// this->sensor_->raw_state = result;
this->publish_state(result);
}
@@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span<const uint8_t> data) {
}
}
ESP_LOGD(TAG, "Sensor new state: %.02f", result);
// this->sensor_->raw_state = result;
this->publish_state(result);
}
@@ -16,11 +16,7 @@ void ModbusSwitch::setup() {
optional<bool> initial_state = Switch::get_initial_state_with_restore_mode();
if (initial_state.has_value()) {
// if it has a value, restore_mode is not "DISABLED", therefore act on the switch:
if (initial_state.value()) {
this->turn_on();
} else {
this->turn_off();
}
this->control(initial_state.value());
}
}
void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); }
@@ -66,6 +66,7 @@ class NextionComponentBase {
#ifdef USE_NEXTION_WAVEFORM
uint8_t get_wave_channel_id() const { return this->wave_chan_id_; }
void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; }
void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; }
const std::vector<uint8_t> &get_wave_buffer() const { return this->wave_buffer_; }
size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); }
@@ -86,12 +87,6 @@ class NextionComponentBase {
virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){};
virtual void send_state_to_nextion(){};
bool get_needs_to_send_update() const { return this->needs_to_send_update_; }
#ifdef USE_NEXTION_WAVEFORM
// Remove before 2026.10.0
ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0")
uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); }
void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; }
#endif // USE_NEXTION_WAVEFORM
protected:
std::string variable_name_;
@@ -6,21 +6,9 @@ namespace esphome::output {
static const char *const TAG = "output.switch";
void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); }
void OutputSwitch::setup() {
bool initial_state = this->get_initial_state_with_restore_mode().value_or(false);
if (initial_state) {
this->turn_on();
} else {
this->turn_off();
}
}
void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); }
void OutputSwitch::write_state(bool state) {
if (state) {
this->output_->turn_on();
} else {
this->output_->turn_off();
}
this->output_->set_state(state);
this->publish_state(state);
}
+1 -1
View File
@@ -120,7 +120,7 @@ void Pixoo::set_pixel_(uint32_t index, Color color) {
}
void HOT Pixoo::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return;
const int side = static_cast<int>(this->model_);
switch (this->rotation_) {
+10 -4
View File
@@ -169,6 +169,12 @@ def request_protocol(name: str) -> None:
cg.add_define(protocol_define(name))
def _request_protocol_if_in_tree(name: str) -> None:
"""Registry names from external components have no source file here and need no define."""
if _protocol_stem(name) in _PROTOCOL_STEMS:
request_protocol(name)
# Only the protocol sources a configuration uses are compiled
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS}
@@ -182,7 +188,7 @@ def register_binary_sensor(
def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable:
async def new_func(var: MockObj, config: ConfigType) -> None:
request_protocol(name)
_request_protocol_if_in_tree(name)
await coroutine(func)(var, config)
return registerer(new_func)
@@ -200,7 +206,7 @@ def register_trigger(name, type, data_type):
def decorator(func):
async def new_func(config):
request_protocol(name)
_request_protocol_if_in_tree(name)
var = cg.new_Pvariable(config[CONF_TRIGGER_ID])
await coroutine(func)(var, config)
await automation.build_automation(var, [(data_type, "x")], config)
@@ -218,7 +224,7 @@ def register_dumper(name, type, schema=None):
def decorator(func):
async def new_func(config, dumper_id):
request_protocol(name)
_request_protocol_if_in_tree(name)
var = cg.new_Pvariable(dumper_id)
await coroutine(func)(var, config)
return var
@@ -259,7 +265,7 @@ def register_action(name, type_, schema):
def decorator(func):
async def new_func(config, action_id, template_arg, args):
request_protocol(name)
_request_protocol_if_in_tree(name)
var = cg.new_Pvariable(action_id, template_arg)
await register_transmittable(var, config)
if CONF_REPEAT in config:
@@ -112,20 +112,21 @@ CONFIG_SCHEMA = remote_base.validate_triggers(
cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema),
cv.Optional(CONF_DUMP, default=[]): remote_base.validate_dumpers,
cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance,
# pulse ring targets hold one 4 byte entry per pulse; 4000b keeps their 1000 pulses
cv.SplitDefault(
CONF_BUFFER_SIZE,
esp32=cv.UNDEFINED,
# the pulse ring needs a size; only RMT targets size themselves in setup()
**{
f"esp32_{variant.removeprefix('ESP32').lower()}": "1000b"
f"esp32_{variant.removeprefix('ESP32').lower()}": "4000b"
for variant in esp32_rmt.VARIANTS_NO_RMT
},
esp8266="1000b",
bk72xx="1000b",
ln882x="1000b",
rtl87xx="1000b",
rp2="1000b",
): cv.All(cv.validate_bytes, cv.int_range(min=64)),
esp8266="4000b",
bk72xx="4000b",
ln882x="4000b",
rtl87xx="4000b",
rp2="4000b",
): cv.All(cv.validate_bytes, cv.int_range(min=64, max=65535)),
cv.Optional(CONF_FILTER, default="50us"): cv.All(
cv.positive_time_period_microseconds,
cv.Range(max=TimePeriod(microseconds=4294967295)),
@@ -14,7 +14,7 @@ static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_
int32_t multiplier = ((int32_t) level << 1) - 1;
uint32_t buffer_write = arg->buffer_write;
arg->buffer[buffer_write++] = (int32_t) delta * multiplier;
if (buffer_write >= arg->buffer_size) {
if (buffer_write >= arg->buffer_entries) {
buffer_write = 0;
}
@@ -65,8 +65,9 @@ void RemoteReceiverComponent::setup() {
this->store_.idle_us = this->idle_us_;
this->store_.filter_us = this->filter_us_;
this->store_.pin = this->pin_->to_isr();
this->store_.buffer = new int32_t[this->buffer_size_];
this->store_.buffer_size = this->buffer_size_;
// rounded up so a size that is not a multiple of four never holds less than requested
this->store_.buffer_entries = (this->buffer_size_ + sizeof(int32_t) - 1) / sizeof(int32_t);
this->store_.buffer = new int32_t[this->store_.buffer_entries];
this->store_.prev_micros = micros();
this->store_.commit_micros = this->store_.prev_micros;
this->store_.prev_level = this->pin_->digital_read();
@@ -79,11 +80,11 @@ void RemoteReceiverComponent::dump_config() {
ESP_LOGCONFIG(
TAG,
"Remote Receiver:\n"
" Buffer Size: %" PRIu32 "\n"
" Buffer Size: %" PRIu32 " bytes (%" PRIu32 " pulses)\n"
" Tolerance: %" PRIu32 "%s\n"
" Filter out pulses shorter than: %" PRIu32 " us\n"
" Signal is done after %" PRIu32 " us of no changes",
this->buffer_size_, this->tolerance_,
this->buffer_size_, this->store_.buffer_entries, this->tolerance_,
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"),
this->filter_us_, this->idle_us_);
LOG_PIN(" Pin: ", this->pin_);
@@ -119,7 +120,7 @@ void RemoteReceiverComponent::loop() {
while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) {
reserve_size++;
temp_read++;
if (temp_read >= s.buffer_size) {
if (temp_read >= s.buffer_entries) {
temp_read = 0;
}
}
@@ -129,7 +130,7 @@ void RemoteReceiverComponent::loop() {
// read the buffer
for (uint32_t i = 0; i < reserve_size + 1; i++) {
this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]);
if (s.buffer_read >= s.buffer_size) {
if (s.buffer_read >= s.buffer_entries) {
s.buffer_read = 0;
}
}
@@ -30,7 +30,7 @@ struct RemoteReceiverComponentStore {
uint32_t buffer_read{0};
volatile uint32_t commit_micros{0};
volatile uint32_t prev_micros{0};
uint32_t buffer_size{1000};
uint32_t buffer_entries{0};
uint32_t filter_us{10};
uint32_t idle_us{10000};
ISRInternalGPIOPin pin;
@@ -83,14 +83,14 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase,
protected:
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void decode_rmt_(rmt_symbol_word_t *item, size_t item_count);
// log the failed RMT call and mark the component failed
void fail_(esp_err_t error, const LogString *reason);
rmt_channel_handle_t channel_{NULL};
uint32_t filter_symbols_{0};
uint32_t receive_symbols_{0};
bool with_dma_{false};
uint32_t carrier_frequency_{0};
uint8_t carrier_duty_percent_{100};
esp_err_t error_code_{ESP_OK};
std::string error_string_;
#endif
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32)
@@ -1,5 +1,6 @@
#include "remote_receiver.h"
#include "esphome/core/log.h"
#include "esphome/core/wake.h"
#ifdef USE_ESP32
#include <soc/soc_caps.h>
@@ -14,25 +15,37 @@ static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4;
static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) {
RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg;
rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + store->buffer_write);
const uint32_t buffer_write = store->buffer_write;
rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + buffer_write);
uint32_t event_size = sizeof(rmt_rx_done_event_data_t);
uint32_t next_write = store->buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t);
uint32_t next_write = buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t);
if (next_write + event_size + store->receive_size > store->buffer_size) {
next_write = 0;
}
if (store->buffer_read - next_write < event_size + store->receive_size) {
next_write = store->buffer_write;
next_write = buffer_write;
store->overflow = true;
}
if (event->num_symbols <= store->filter_symbols) {
next_write = store->buffer_write;
next_write = buffer_write;
}
store->error =
rmt_receive(channel, (uint8_t *) store->buffer + next_write + event_size, store->receive_size, &store->config);
event_buffer->num_symbols = event->num_symbols;
event_buffer->received_symbols = event->received_symbols;
const bool stored = next_write != buffer_write;
store->buffer_write = next_write;
return false;
// a stored frame is decoded, and a failed re-arm reported, on the next loop pass instead of
// waiting out the loop interval; filtered noise and dropped frames leave nothing to read
BaseType_t task_woken = pdFALSE;
if (stored || store->error != ESP_OK)
wake_loop_isrsafe(&task_woken);
return task_woken != pdFALSE;
}
void RemoteReceiverComponent::fail_(esp_err_t error, const LogString *reason) {
ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error));
this->mark_failed(reason);
}
void RemoteReceiverComponent::setup() {
@@ -47,13 +60,8 @@ void RemoteReceiverComponent::setup() {
channel.flags.with_dma = this->with_dma_;
esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
if (error == ESP_ERR_NOT_FOUND) {
this->error_string_ = "out of RMT symbol memory";
} else {
this->error_string_ = "in rmt_new_rx_channel";
}
this->mark_failed();
this->fail_(error,
error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_rx_channel"));
return;
}
if (this->pin_->get_flags() & gpio::FLAG_PULLUP) {
@@ -63,9 +71,7 @@ void RemoteReceiverComponent::setup() {
}
error = rmt_enable(this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_enable";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_enable"));
return;
}
@@ -77,9 +83,7 @@ void RemoteReceiverComponent::setup() {
carrier.flags.polarity_active_low = this->pin_->is_inverted();
error = rmt_apply_carrier(this->channel_, &carrier);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_apply_carrier";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_apply_carrier"));
return;
}
}
@@ -89,9 +93,7 @@ void RemoteReceiverComponent::setup() {
callbacks.on_recv_done = rmt_callback;
error = rmt_rx_register_event_callbacks(this->channel_, &callbacks, &this->store_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_rx_register_event_callbacks";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_rx_register_event_callbacks"));
return;
}
@@ -114,9 +116,7 @@ void RemoteReceiverComponent::setup() {
error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size,
&this->store_.config);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_receive";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_receive"));
return;
}
}
@@ -140,18 +140,11 @@ void RemoteReceiverComponent::dump_config() {
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"),
this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_);
LOG_PIN(" Pin: ", this->pin_);
if (this->is_failed()) {
ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_),
this->error_string_.c_str());
}
}
void RemoteReceiverComponent::loop() {
if (this->store_.error != ESP_OK) {
ESP_LOGE(TAG, "Receive error");
this->error_code_ = this->store_.error;
this->error_string_ = "in rmt_callback";
this->mark_failed();
this->fail_(this->store_.error, LOG_STR("in rmt_callback"));
}
if (this->store_.overflow) {
ESP_LOGW(TAG, "Buffer overflow");
@@ -141,6 +141,8 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
#endif
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
// log the failed RMT call and mark the component failed
void fail_(esp_err_t error, const LogString *reason);
void configure_rmt_();
void wait_for_rmt_();
@@ -156,8 +158,6 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
bool eot_level_{false};
rmt_channel_handle_t channel_{NULL};
rmt_encoder_handle_t encoder_{NULL};
esp_err_t error_code_{ESP_OK};
std::string error_string_;
bool inverted_{false};
bool non_blocking_{false};
#endif
@@ -51,6 +51,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size
}
#endif
void RemoteTransmitterComponent::fail_(esp_err_t error, const LogString *reason) {
ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error));
this->mark_failed(reason);
}
void RemoteTransmitterComponent::setup() {
this->inverted_ = this->pin_->is_inverted();
this->configure_rmt_();
@@ -67,11 +72,6 @@ void RemoteTransmitterComponent::dump_config() {
if (this->current_carrier_frequency_ != 0 && this->carrier_duty_percent_ != 100) {
ESP_LOGCONFIG(TAG, " Carrier Duty: %u%%", this->carrier_duty_percent_);
}
if (this->is_failed()) {
ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_),
this->error_string_.c_str());
}
}
void RemoteTransmitterComponent::digital_write(bool value) {
@@ -129,13 +129,8 @@ void RemoteTransmitterComponent::configure_rmt_() {
#endif
error = rmt_new_tx_channel(&channel, &this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
if (error == ESP_ERR_NOT_FOUND) {
this->error_string_ = "out of RMT symbol memory";
} else {
this->error_string_ = "in rmt_new_tx_channel";
}
this->mark_failed();
this->fail_(error,
error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_tx_channel"));
return;
}
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
@@ -159,9 +154,7 @@ void RemoteTransmitterComponent::configure_rmt_() {
encoder.min_chunk_size = 1;
error = rmt_new_simple_encoder(&encoder, &this->encoder_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_new_simple_encoder";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_new_simple_encoder"));
return;
}
#else
@@ -169,18 +162,14 @@ void RemoteTransmitterComponent::configure_rmt_() {
memset(&encoder, 0, sizeof(encoder));
error = rmt_new_copy_encoder(&encoder, &this->encoder_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_new_copy_encoder";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_new_copy_encoder"));
return;
}
#endif
error = rmt_enable(this->channel_);
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_enable";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_enable"));
return;
}
this->digital_write(open_drain || this->inverted_);
@@ -199,9 +188,7 @@ void RemoteTransmitterComponent::configure_rmt_() {
error = rmt_apply_carrier(this->channel_, &carrier);
}
if (error != ESP_OK) {
this->error_code_ = error;
this->error_string_ = "in rmt_apply_carrier";
this->mark_failed();
this->fail_(error, LOG_STR("in rmt_apply_carrier"));
return;
}
}
+6 -6
View File
@@ -197,20 +197,20 @@ def _parse_platform_version(value: Any) -> str:
# The default/recommended arduino framework version
# - https://github.com/earlephilhower/arduino-pico/releases
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0)
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 0)
# The raspberrypi platform version to use for arduino frameworks
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain
# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0"
# develop-branch commit carrying the arduino-pico 6.1.0 update and the board
# JSON files it adds; replace with a release tag when one is cut
RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1"
def _arduino_check_versions(value: ConfigType) -> ConfigType:
value = value.copy()
lookups = {
"dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(6, 0, 0), None),
"dev": (cv.Version(6, 1, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(6, 1, 0), None),
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
}
+18
View File
@@ -1135,6 +1135,18 @@ RP2_BOARD_PINS = {
"SS": 5,
"TX": 0,
},
"soldered_nula_node_rp2040": {
"MISO": 16,
"MOSI": 19,
"RX": 1,
"SCK": 18,
"SCL": 9,
"SCL1": 11,
"SDA": 8,
"SDA1": 10,
"SS": 17,
"TX": 0,
},
"soldered_nula_rp2350": {
"MISO": 2,
"MOSI": 3,
@@ -2127,6 +2139,12 @@ BOARDS = {
"mcu": "rp2040",
"max_pin": 29,
},
"soldered_nula_node_rp2040": {
"name": "Soldered Electronics NULA Node",
"mcu": "rp2040",
"max_pin": 29,
"wifi": True,
},
"soldered_nula_rp2350": {
"name": "Soldered Electronics NULA RP2350",
"mcu": "rp2350",
+17 -5
View File
@@ -55,8 +55,7 @@ namespace esphome::rp2 {
static const char *const TAG = "rp2.crash";
// Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear().
// The valid field is explicitly cleared in crash_handler_read_and_clear() instead.
// Filled from the watchdog scratch registers on the first read.
static struct CrashData {
bool valid;
uint32_t pc;
@@ -64,11 +63,24 @@ static struct CrashData {
uint32_t sp;
uint32_t backtrace[MAX_BACKTRACE];
uint8_t backtrace_count;
} s_crash_data __attribute__((section(".noinit"))); // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} s_crash_data; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool crash_handler_has_data() { return s_crash_data.valid; }
// Logger::pre_setup() logs the record before App.pre_setup() reaches
// arch_init(), so the first caller reads it and later calls are no-ops.
// The read clears the scratch registers, so it must not run twice, and
// arch_init() keeps its call so the read precedes watchdog_enable(), which
// overwrites scratch[4].
static bool s_crash_data_read = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
bool crash_handler_has_data() {
crash_handler_read_and_clear();
return s_crash_data.valid;
}
void crash_handler_read_and_clear() {
if (s_crash_data_read)
return;
s_crash_data_read = true;
s_crash_data.valid = false;
uint32_t magic = watchdog_hw->scratch[0];
if ((magic & 0xFFFF0000) == CRASH_MAGIC_SENTINEL && (magic & 0xFFFF) == CRASH_DATA_VERSION) {
@@ -97,7 +109,7 @@ void crash_handler_read_and_clear() {
// the device crashes again during boot, and allowing the CLI's process_stacktrace
// to match and decode each address individually.
void crash_handler_log() {
if (!s_crash_data.valid)
if (!crash_handler_has_data())
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
+2 -1
View File
@@ -9,12 +9,13 @@
namespace esphome::rp2 {
/// Read crash data from watchdog scratch registers and clear them.
/// Only the first call reads; later calls are no-ops.
void crash_handler_read_and_clear();
/// Log crash data if a crash was detected on previous boot.
void crash_handler_log();
/// Returns true if crash data was found this boot.
/// Returns true if crash data was found this boot, reading it first if needed.
bool crash_handler_has_data();
} // namespace esphome::rp2
@@ -20,7 +20,7 @@
namespace esphome::rp2040_ble {
namespace {
// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or
// Pinned against arduino-pico 6.1.0's prebuilt archives: a framework bump (or
// a changed ENABLE_* macro) shifting the struct layout must fail the build
// here, not overrun the pool blocks at runtime. Sizes differ per core
// architecture (measured from each archive's own storage symbols). GCC only:
@@ -101,7 +101,7 @@ int RpiDpiRgb::get_height() {
}
void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return; // NOLINT
switch (this->rotation_) {
@@ -124,7 +124,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) {
this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true,
0, 0, 0);
App.feed_wdt();
this->feed_wdt_per_pixel_();
}
void RpiDpiRgb::dump_config() {
+1 -1
View File
@@ -164,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *
}
void Sdl::draw_pixel_at(int x, int y, Color color) {
if (this->texture_ == nullptr || !this->get_clipping().inside(x, y))
if (this->texture_ == nullptr || this->is_point_clipped(x, y))
return;
if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) {
+8 -2
View File
@@ -2,7 +2,7 @@ from dataclasses import dataclass, field
from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, network, psram, socket, wifi
from esphome.components import esp32, mdns, network, psram, socket, wifi
from esphome.components.const import CONF_MANUFACTURER
import esphome.config_validation as cv
from esphome.const import (
@@ -11,6 +11,7 @@ from esphome.const import (
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_MDNS,
CONF_MODEL,
CONF_NAME,
CONF_PROJECT,
@@ -281,10 +282,15 @@ async def to_code(config: ConfigType) -> None:
cg.add(setter(value))
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0")
cg.add_define("USE_SENDSPIN", True) # for MDNS
# Service starts disabled and the hub enables it; always advertised where unsupported
if mdns.request_service_enable_disable():
mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID])
cg.add(var.set_mdns(mdns_var))
data = _get_data()
# The color role is not yet wired up in ESPHome; disable it in the library for now.
@@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) {
// Ignore any commands sent before the media player is setup
return;
}
if (!this->parent_->is_client_running()) {
ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled");
return;
}
auto volume = call.get_volume();
if (volume.has_value()) {
@@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.
// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only)
bool SendspinMediaSource::play_uri(const std::string &uri) {
// The queued request has been delivered, whatever the outcome, so the next stream start may request again
this->pending_start_ = false;
if (!this->is_ready() || this->is_failed() || !this->has_listener()) {
return false;
}
@@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) {
return false;
}
if (!this->parent_->is_client_running()) {
ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str());
return false;
}
if (!uri.starts_with(URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
@@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) {
}
// Tell the orchestrator we're now playing so it routes audio output from us
this->pending_start_ = false;
this->set_state_(media_source::MediaSourceState::PLAYING);
return true;
@@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) {
// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only)
void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) {
if (!this->parent_->is_client_running()) {
if (command == media_source::MediaSourceCommand::STOP) {
// Nothing is playing, so the orchestrator gets its pipeline back straight away
this->on_stream_end();
} else {
ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled");
}
return;
}
switch (command) {
case media_source::MediaSourceCommand::STOP: {
if (!this->pending_start_) {
+51 -10
View File
@@ -62,14 +62,26 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
if (!this->client_->start_server()) {
ESP_LOGE(TAG, "Failed to start Sendspin server");
this->mark_failed();
return;
}
#ifndef USE_SENDSPIN_SWITCH
this->enabled_ = true;
#endif
}
void SendspinHub::loop() { this->client_->loop(); }
void SendspinHub::loop() {
if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() &&
!this->status_has_error()) {
if (!this->enabled_.value()) {
this->client_->stop();
} else if (!this->client_->start()) {
this->status_set_error(LOG_STR("Failed to start Sendspin client"));
}
}
this->client_->loop();
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
this->update_mdns_service_();
#endif
}
void SendspinHub::dump_config() {
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
@@ -96,25 +108,54 @@ void SendspinHub::dump_config() {
#endif
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::set_enabled(bool enabled) {
if (this->status_has_error()) {
ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry",
enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"));
return;
}
this->enabled_ = enabled;
}
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
// THREAD CONTEXT: Main loop
void SendspinHub::update_mdns_service_() {
// Synced from loop() because mdns sets up after this hub and only builds its service list then.
if (!this->mdns_->is_ready()) {
return;
}
bool advertise = this->client_->is_started();
if (advertise == this->mdns_advertised_) {
return;
}
// One attempt per change
this->mdns_advertised_ = advertise;
if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) {
ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"));
}
}
#endif
// --- Delegating methods ---
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::connect_to_server(const std::string &url) {
if (this->is_ready()) {
if (this->is_client_running()) {
this->client_->connect_to(url);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) {
if (this->is_ready()) {
if (this->is_client_running()) {
this->client_->disconnect(reason);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::update_state(sendspin::SendspinClientState state) {
if (this->is_ready()) {
if (this->is_client_running()) {
this->client_->update_state(state);
}
}
@@ -233,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) {
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
std::optional<bool> mute) {
if (this->is_ready()) {
if (this->is_client_running()) {
sendspin::ClientCommandControllerObject obj = {
.command = command,
.volume = volume,
+35 -3
View File
@@ -10,6 +10,10 @@
#include "esphome/core/preferences.h"
#include "esphome/core/version.h"
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
#include "esphome/components/mdns/mdns_component.h"
#endif
#include <sendspin/client.h>
#include <sendspin/config.h>
#include <sendspin/types.h>
@@ -93,7 +97,7 @@ class SendspinHub final : public Component,
/// @brief Connects the underlying client to the given Sendspin server.
///
/// No-op if the hub's client is not ready (e.g. setup() has not completed).
/// No-op if the hub's client is not running (see is_client_running()).
/// Must be called from the main loop thread.
/// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`).
void connect_to_server(const std::string &url);
@@ -101,7 +105,7 @@ class SendspinHub final : public Component,
/// @brief Disconnects the underlying client from the current server.
///
/// Sends a `client/goodbye` message with the given reason before closing the connection.
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// No-op if the hub's client is not running. Must be called from the main loop thread.
/// @param reason Reason reported to the server:
/// - `ANOTHER_SERVER`: client is switching to another server.
/// - `SHUTDOWN`: client is shutting down.
@@ -111,7 +115,7 @@ class SendspinHub final : public Component,
/// @brief Updates the client's reported playback state on the server.
///
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// No-op if the hub's client is not running. Must be called from the main loop thread.
/// @param state New client state:
/// - `SYNCHRONIZED`: client is synchronized and playing from the server.
/// - `ERROR`: client encountered a playback error.
@@ -126,6 +130,17 @@ class SendspinHub final : public Component,
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
/// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or
/// stop.
///
/// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks
/// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has
/// called this once. Must be called from the main loop thread.
void set_enabled(bool enabled);
/// @brief Returns whether the Sendspin client is running.
bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); }
/// @brief Sets the device information reported to the server in the `client/hello` message.
///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
@@ -135,6 +150,10 @@ class SendspinHub final : public Component,
void set_model(const char *model) { this->model_ = model; }
void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; }
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; }
#endif
// --- Sendspin role specific methods ---
#ifdef USE_SENDSPIN_ARTWORK
@@ -204,6 +223,11 @@ class SendspinHub final : public Component,
/// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi).
static const char *get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
/// @brief Keeps the `_sendspin` mDNS service advertised while the client is running.
void update_mdns_service_();
#endif
// --- SendspinClientListener overrides ---
void on_group_update(const sendspin::GroupUpdateObject &group) override;
@@ -282,11 +306,19 @@ class SendspinHub final : public Component,
bool task_stack_in_psram_{false};
// Requested client state, applied from loop(). Empty until the switch restores its state.
std::optional<bool> enabled_;
// Device information sent in the `client/hello` message. Defaults apply when neither the
// sendspin configuration nor the project information supplies a value.
const char *manufacturer_{"ESPHome"};
const char *model_{nullptr}; // nullptr reports the device name instead
const char *firmware_version_{ESPHOME_VERSION};
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
mdns::MDNSComponent *mdns_{nullptr};
bool mdns_advertised_{false}; // Last state requested from mdns
#endif
};
/// @brief Base class for all sendspin subcomponents.
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component)
CONFIG_SCHEMA = cv.All(
switch.switch_schema(
SendspinSwitch,
block_inverted=True,
default_restore_mode="RESTORE_DEFAULT_ON",
entity_category=ENTITY_CATEGORY_CONFIG,
)
.extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
.extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
)
async def to_code(config: ConfigType) -> None:
var = await switch.new_switch(config)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
cg.add_define("USE_SENDSPIN_SWITCH", True)
@@ -0,0 +1,26 @@
#include "sendspin_switch.h"
#ifdef USE_ESP32
#include "esphome/core/log.h"
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.switch";
void SendspinSwitch::setup() {
// The hub waits for this request, so a restore mode without a state still has to answer.
this->control(this->get_initial_state_with_restore_mode().value_or(true));
}
void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); }
// THREAD CONTEXT: Main loop
void SendspinSwitch::write_state(bool state) {
this->parent_->set_enabled(state);
this->publish_state(state);
}
} // namespace esphome::sendspin_
#endif // USE_ESP32
@@ -0,0 +1,24 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/switch/switch.h"
namespace esphome::sendspin_ {
/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()).
class SendspinSwitch final : public switch_::Switch, public SendspinChild {
public:
void setup() override;
void dump_config() override;
protected:
void write_state(bool state) override;
};
} // namespace esphome::sendspin_
#endif // USE_ESP32
+2 -8
View File
@@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) {
return StateClassStrings::get_log_str(static_cast<uint8_t>(state_class), 0);
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
Sensor::Sensor() : state(NAN), raw_state(NAN) {}
#pragma GCC diagnostic pop
Sensor::Sensor() : state(NAN) {}
int8_t Sensor::get_accuracy_decimals() {
if (this->sensor_flags_.has_accuracy_override)
@@ -66,11 +63,8 @@ StateClass Sensor::get_state_class() {
}
void Sensor::publish_state(float state) {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
this->raw_state = state;
#pragma GCC diagnostic pop
#ifdef USE_SENSOR_FILTER
this->raw_state_ = state;
this->raw_callback_.call(state);
#endif
+10 -14
View File
@@ -96,18 +96,20 @@ class Sensor : public EntityBase {
/// Getter-syntax for .state.
float get_state() const { return this->state; }
/// Getter-syntax for .raw_state
/// Get the last state received by publish_state(), before any filters were applied.
float get_raw_state() const {
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
return this->raw_state;
#pragma GCC diagnostic pop
#ifdef USE_SENSOR_FILTER
return this->raw_state_;
#else
return this->state; // No filters compiled in, raw == filtered
#endif
}
/** Publish a new state to the front-end.
*
* First, the new state will be assigned to the raw_value. Then it's passed through all filters
* until it finally lands in the .value member variable and a callback is issued.
* The value is passed through the filter chain (when filters are compiled in) before landing in
* the `state` member and triggering the state callback. The pre-filter value is available via
* get_raw_state().
*
* @param state The state as a floating point number.
*/
@@ -137,17 +139,11 @@ class Sensor : public EntityBase {
*/
float state;
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
/// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0.
ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0")
float raw_state;
#pragma GCC diagnostic pop
void internal_send_state_to_frontend(float state);
protected:
#ifdef USE_SENSOR_FILTER
float raw_state_{NAN}; ///< The last state passed to publish_state(), before filters.
LazyCallbackManager<void(float)> raw_callback_; ///< Storage for raw state callbacks.
#endif
LazyCallbackManager<void(float)> callback_; ///< Storage for filtered state callbacks.
@@ -1,7 +1,5 @@
"""Speaker Media Player Setup."""
import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import (
@@ -33,9 +31,6 @@ from esphome.const import (
CONF_TASK_STACK_IN_PSRAM,
)
_LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["audio"]
DEPENDENCIES = ["network"]
@@ -44,7 +39,7 @@ DOMAIN = "media_player"
CONF_ANNOUNCEMENT = "announcement"
CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline"
CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0
CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2027.4.0
CONF_ENQUEUE = "enqueue"
CONF_MEDIA_FILE = "media_file"
CONF_MEDIA_PIPELINE = "media_pipeline"
@@ -103,15 +98,6 @@ def _validate_repeated_speaker(config):
def _final_validate(config):
# Remove before 2026.10.0
if CONF_CODEC_SUPPORT_ENABLED in config:
_LOGGER.warning(
"'%s' is deprecated and will be removed in 2026.10.0. "
"Codec support is now automatically determined from the pipeline "
"'format' setting. Set format to 'NONE' to enable all codecs.",
CONF_CODEC_SUPPORT_ENABLED,
)
# Request codecs based on pipeline formats. Codecs needed by local files are
# already requested during CONFIG_SCHEMA validation (via audio_files_schema).
media_player.request_codecs_for_format_configs(
@@ -151,8 +137,12 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(
min=4000, max=4000000
),
# Remove before 2026.10.0
cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string),
# Removed in 2026.10.0 - kept to provide helpful error message
cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.invalid(
"The 'codec_support_enabled' option has been removed in ESPHome 2026.10.0.\n"
"Codec support is now determined from the pipeline 'format' setting.\n"
"Set 'format: NONE' on the pipeline to enable all codecs."
),
cv.Optional(CONF_FILES): audio_file.audio_files_schema(),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage,
+4 -20
View File
@@ -546,11 +546,7 @@ void Sprinkler::set_auto_advance(const bool auto_advance) {
if (this->auto_adv_sw_->state == auto_advance) {
return;
}
if (auto_advance) {
this->auto_adv_sw_->turn_on();
} else {
this->auto_adv_sw_->turn_off();
}
this->auto_adv_sw_->control(auto_advance);
}
void Sprinkler::set_repeat(optional<uint32_t> repeat) {
@@ -573,11 +569,7 @@ void Sprinkler::set_queue_enable(bool queue_enable) {
if (this->queue_enable_sw_->state == queue_enable) {
return;
}
if (queue_enable) {
this->queue_enable_sw_->turn_on();
} else {
this->queue_enable_sw_->turn_off();
}
this->queue_enable_sw_->control(queue_enable);
}
void Sprinkler::set_reverse(const bool reverse) {
@@ -587,11 +579,7 @@ void Sprinkler::set_reverse(const bool reverse) {
if (this->reverse_sw_->state == reverse) {
return;
}
if (reverse) {
this->reverse_sw_->turn_on();
} else {
this->reverse_sw_->turn_off();
}
this->reverse_sw_->control(reverse);
}
void Sprinkler::set_standby(const bool standby) {
@@ -601,11 +589,7 @@ void Sprinkler::set_standby(const bool standby) {
if (this->standby_sw_->state == standby) {
return;
}
if (standby) {
this->standby_sw_->turn_on();
} else {
this->standby_sw_->turn_off();
}
this->standby_sw_->control(standby);
}
uint32_t Sprinkler::valve_run_duration(const size_t valve_number) {
+2 -2
View File
@@ -84,7 +84,7 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8
}
void ST7701S::draw_pixel_at(int x, int y, Color color) {
if (!this->get_clipping().inside(x, y))
if (this->is_point_clipped(x, y))
return; // NOLINT
switch (this->rotation_) {
@@ -107,7 +107,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) {
this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true,
0, 0, 0);
App.feed_wdt();
this->feed_wdt_per_pixel_();
}
void ST7701S::write_command_(uint8_t value) {
-1
View File
@@ -10,7 +10,6 @@ static const char *const TAG = "switch";
Switch::Switch() : state(false) {}
void Switch::control(bool target_state) {
ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state));
if (target_state) {
this->turn_on();
} else {
@@ -65,9 +65,6 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl
bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; }
bool get_all_sensors_ready() { return this->sensors_ready_; };
void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
// Remove before 2026.10.0
ESPDEPRECATED("bypass_before_arming() is deprecated and will be removed in 2026.10.0", "2026.4.0")
void bypass_before_arming() { this->auto_bypass_sensors_(); }
#ifdef USE_BINARY_SENSOR
/** Initialize the sensors vector with the specified capacity.
@@ -42,11 +42,7 @@ void TemplateSwitch::setup() {
if (initial_state.has_value()) {
ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value()));
// if it has a value, restore_mode is not "DISABLED", therefore act on the switch:
if (initial_state.value()) {
this->turn_on();
} else {
this->turn_off();
}
this->control(initial_state.value());
}
}
void TemplateSwitch::dump_config() {
+39 -29
View File
@@ -66,9 +66,12 @@ static const char *const TAG = "web_server";
// GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only)
// POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only)
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) {
// Every path returns this one object so it is built in place; fields are only set once the URL is known valid
UrlMatch match{};
// URL must start with '/' and have content after it
if (url_len < 2 || url_ptr[0] != '/')
return UrlMatch{};
return match;
const char *p = url_ptr + 1;
const char *end = url_ptr + url_len;
@@ -90,15 +93,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain,
// Must have domain with trailing slash
if (!s2)
return UrlMatch{};
UrlMatch match{};
match.domain = make_ref(s1, s2);
match.valid = true;
if (only_domain || s2 >= end)
return match;
if (only_domain || s2 >= end) {
match.domain = make_ref(s1, s2);
match.valid = true;
return match;
}
// Parse remaining segments only when needed
const char *s3 = next_segment(s2);
const char *s4 = s3 ? next_segment(s3) : nullptr;
@@ -109,7 +111,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain,
// Reject empty segments
if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty()))
return UrlMatch{};
return match;
// Interpret based on segment count
if (!s3) {
@@ -121,28 +123,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain,
if (is_post) {
match.id = seg2;
match.method = seg3;
return match;
}
} else {
#ifdef USE_DEVICES
match.device_name = seg2;
match.id = seg3;
match.device_name = seg2;
match.id = seg3;
#else
return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES
return match; // 3-segment GET not supported without USE_DEVICES
#endif
}
} else {
// 3 segments after domain: /{domain}/{device}/{entity}/{action}
#ifdef USE_DEVICES
if (!is_post) {
return UrlMatch{}; // 4-segment GET not supported (action requires POST)
return match; // 4-segment GET not supported (action requires POST)
}
match.device_name = seg2;
match.id = seg3;
match.method = seg4;
#else
return UrlMatch{}; // Not supported without USE_DEVICES
// Not supported without USE_DEVICES
return match;
#endif
}
match.domain = make_ref(s1, s2);
match.valid = true;
return match;
}
@@ -336,6 +341,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou
WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {}
// Kept out of the callers so the 64 bit division is emitted once
__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast<uint32_t>(millis_64() / 1000); }
json::SerializationBuffer<> WebServer::get_config_json() {
json::JsonBuilder builder;
JsonObject root = builder.root();
@@ -343,7 +351,7 @@ json::SerializationBuffer<> WebServer::get_config_json() {
root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str();
char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX];
App.get_comment_string(comment_buffer);
root[ESPHOME_F("comment")] = comment_buffer;
root[ESPHOME_F("comment")] = static_cast<const char *>(comment_buffer);
#if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA)
root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal
#else
@@ -351,7 +359,7 @@ json::SerializationBuffer<> WebServer::get_config_json() {
#endif
root[ESPHOME_F("log")] = this->expose_log_;
root[ESPHOME_F("lang")] = "en";
root[ESPHOME_F("uptime")] = static_cast<uint32_t>(millis_64() / 1000);
root[ESPHOME_F("uptime")] = uptime_seconds();
return builder.serialize();
}
@@ -382,7 +390,7 @@ void WebServer::setup() {
if (this->events_.empty())
return;
char buf[32];
auto uptime = static_cast<uint32_t>(millis_64() / 1000);
auto uptime = uptime_seconds();
size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime);
this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000);
});
@@ -467,7 +475,10 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const
const size_t scheme_sep = origin.find("://");
if (scheme_sep != std::string::npos) {
const std::string host = get_request_header(request, "Host");
if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0)
// Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here
const size_t authority = scheme_sep + 3;
if (!host.empty() && origin.size() - authority == host.size() &&
memcmp(origin.data() + authority, host.data(), host.size()) == 0)
return true;
}
@@ -534,7 +545,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) {
// Helper functions to reduce code size by avoiding macro expansion
// Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name}
// Uses names (not object_id) to avoid UTF-8 collision issues
static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) {
static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) {
const StringRef &name = obj->get_name();
size_t prefix_len = strlen(prefix);
size_t name_len = name.size();
@@ -569,7 +580,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J
#endif
memcpy(p, name.c_str(), name_len);
p[name_len] = '\0';
root[ESPHOME_F("id")] = id_buf;
root[ESPHOME_F("id")] = static_cast<const char *>(id_buf);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("domain")] = prefix;
@@ -594,14 +605,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J
// Keep as separate function even though only used once: reduces code size by ~48 bytes
// by allowing compiler to share code between template instantiations (bool, float, etc.)
template<typename T>
static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value,
JsonDetail start_config) {
static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) {
set_json_id(root, obj, prefix, start_config);
root[ESPHOME_F("value")] = value;
}
template<typename S, typename T>
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value,
static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value,
JsonDetail start_config) {
set_json_value(root, obj, prefix, value, start_config);
root[ESPHOME_F("state")] = state;
@@ -1230,7 +1240,7 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso
// Format: YYYY-MM-DD (max 10 chars + null)
char value[12];
buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day);
set_json_icon_state_value(root, obj, "date", value, value, start_config);
set_json_icon_state_value<const char *, const char *>(root, obj, "date", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1290,7 +1300,7 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso
// Format: HH:MM:SS (8 chars + null)
char value[12];
buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second);
set_json_icon_state_value(root, obj, "time", value, value, start_config);
set_json_icon_state_value<const char *, const char *>(root, obj, "time", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -1351,7 +1361,7 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity *
char value[24];
buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour,
obj->minute, obj->second);
set_json_icon_state_value(root, obj, "datetime", value, value, start_config);
set_json_icon_state_value<const char *, const char *>(root, obj, "datetime", value, value, start_config);
if (start_config == DETAIL_ALL) {
this->add_sorting_info_(root, obj);
}
@@ -2295,7 +2305,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J
JsonObject root = builder.root();
set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)),
obj->update_info.latest_version, start_config);
obj->update_info.latest_version.c_str(), start_config);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("current_version")] = obj->update_info.current_version;
root[ESPHOME_F("title")] = obj->update_info.title;
+1 -1
View File
@@ -593,7 +593,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
web_server_base::WebServerBase *base_;
#ifdef USE_ESP32
AsyncEventSource events_{"/events", this};
AsyncEventSource events_{StringRef::from_lit("/events"), this};
#elif USE_ARDUINO
DeferredUpdateEventSourceList events_;
#endif
@@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler {
using connect_handler_t = std::function<void(AsyncEventSourceClient *)>;
public:
AsyncEventSource(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {}
AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {}
~AsyncEventSource() override;
// NOLINTNEXTLINE(readability-identifier-naming)
@@ -352,7 +352,7 @@ class AsyncEventSource : public AsyncWebHandler {
// Cold path: move sessions from pending_sessions_ into sessions_ and greet each one.
void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_();
std::string url_;
StringRef url_; // Must outlive this object (string literal)
// Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set.
std::vector<AsyncEventSourceResponse *> sessions_;
// Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.
-1
View File
@@ -352,7 +352,6 @@ CONF_DIRECTION = "direction"
CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic"
CONF_DIRECTION_OUTPUT = "direction_output"
CONF_DIRECTION_STATE_TOPIC = "direction_state_topic"
CONF_DISABLE_CRC = "disable_crc"
CONF_DISABLED = "disabled"
CONF_DISABLED_BY_DEFAULT = "disabled_by_default"
CONF_DISCONNECT_DELAY = "disconnect_delay"
+32 -2
View File
@@ -11,6 +11,19 @@
#include <esp_chip_info.h>
#include <esp_ota_ops.h>
#include <esp_bootloader_desc.h>
#include <esp_flash.h>
#if __has_include(<esp_flash_chips/spi_flash_chip_generic.h>)
#include <esp_flash_chips/spi_flash_chip_generic.h> // ESP-IDF 6
#include <esp_flash_chips/spi_flash_chip_mxic.h>
#else
#include <spi_flash_chip_generic.h>
#include <spi_flash_chip_mxic.h>
#endif
// Vendor flash drivers linked next to the generic one; sdkconfig defines each as 1 or not at all
#define ESPHOME_FLASH_VENDOR_DRIVERS \
(CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP + CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP + CONFIG_SPI_FLASH_SUPPORT_GD_CHIP + \
CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP + CONFIG_SPI_FLASH_SUPPORT_TH_CHIP + \
CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP)
#endif
#include "esphome/core/version.h"
#include "esphome/core/hal.h"
@@ -157,8 +170,25 @@ void Application::process_dump_config_() {
esp_chip_info(&chip_info);
ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100,
chip_info.revision % 100, chip_info.cores);
#if defined(USE_ESP32_VARIANT_ESP32) && (!defined(USE_ESP32_MIN_CHIP_REVISION_SET) || !defined(USE_ESP32_SRAM1_AS_IRAM))
static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced";
[[maybe_unused]] static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced";
#if ESPHOME_FLASH_VENDOR_DRIVERS > 0
{
// Only the driver in use earns its IRAM; with several linked at least one is idle
const spi_flash_chip_t *flash_driver = esp_flash_default_chip->chip_drv;
#if ESPHOME_FLASH_VENDOR_DRIVERS > 1
constexpr bool idle_driver = true;
#else
const bool idle_driver = flash_driver == &esp_flash_chip_generic;
#endif
if (idle_driver) {
const char *value = flash_driver->name;
#ifdef CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP
if (flash_driver == &esp_flash_chip_mxic_opi)
value = "mxic_opi";
#endif
ESP_LOGW(TAG, "Set flash_chip: %s %s to save IRAM", value, ESP32_ADVANCED_PATH);
}
}
#endif
#if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET)
{
+2 -2
View File
@@ -67,7 +67,7 @@ static constexpr uint32_t TEARDOWN_TIMEOUT_REBOOT_MS = 1000; // 1 second for qu
class Application {
public:
#ifdef ESPHOME_NAME_ADD_MAC_SUFFIX
// Called before Logger::pre_setup() — must not log (global_logger is not yet set).
// Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there.
/// Pre-setup with MAC suffix: overwrites placeholder in mutable static buffers with actual MAC.
void pre_setup(char *name, size_t name_len, char *friendly_name, size_t friendly_name_len) {
arch_init();
@@ -87,7 +87,7 @@ class Application {
this->friendly_name_ = StringRef(friendly_name, friendly_name_len);
}
#else
// Called before Logger::pre_setup() — must not log (global_logger is not yet set).
// Runs after Logger::pre_setup() (emitted at EARLY_INIT priority), so the app name is not set yet there.
/// Pre-setup without MAC suffix: StringRef points directly at const string literals in flash.
void pre_setup(const char *name, size_t name_len, const char *friendly_name, size_t friendly_name_len) {
arch_init();
+5 -10
View File
@@ -6,18 +6,13 @@ namespace esphome {
constinit const Color Color::BLACK(0, 0, 0, 0);
constinit const Color Color::WHITE(255, 255, 255, 255);
Color Color::gradient(const Color &to_color, uint8_t amnt) {
uint8_t inv = 255 - amnt;
Color new_color;
new_color.r = (uint16_t(this->r) * inv + uint16_t(to_color.r) * amnt) / 255;
new_color.g = (uint16_t(this->g) * inv + uint16_t(to_color.g) * amnt) / 255;
new_color.b = (uint16_t(this->b) * inv + uint16_t(to_color.b) * amnt) / 255;
new_color.w = (uint16_t(this->w) * inv + uint16_t(to_color.w) * amnt) / 255;
return new_color;
Color Color::gradient(const Color &to_color, uint8_t amnt) const {
return Color(blend_channel(this->r, to_color.r, amnt), blend_channel(this->g, to_color.g, amnt),
blend_channel(this->b, to_color.b, amnt), blend_channel(this->w, to_color.w, amnt));
}
Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); }
Color Color::fade_to_white(uint8_t amnt) const { return this->gradient(Color::WHITE, amnt); }
Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); }
Color Color::fade_to_black(uint8_t amnt) const { return this->gradient(Color::BLACK, amnt); }
} // namespace esphome
+9 -3
View File
@@ -174,9 +174,15 @@ struct Color {
uint8_t((uint16_t(b) * 255U / max_rgb)), w);
}
Color gradient(const Color &to_color, uint8_t amnt);
Color fade_to_white(uint8_t amnt);
Color fade_to_black(uint8_t amnt);
/// One channel of gradient(): from at amnt 0 to to at amnt 255. Inline so a
/// per pixel loop can blend without a call; gradient() itself stays out of
/// line so the light effects and fade_to_*() share one copy.
static inline uint8_t blend_channel(uint8_t from, uint8_t to, uint8_t amnt) ESPHOME_ALWAYS_INLINE {
return (uint16_t(from) * (255 - amnt) + uint16_t(to) * amnt) / 255;
}
Color gradient(const Color &to_color, uint8_t amnt) const;
Color fade_to_white(uint8_t amnt) const;
Color fade_to_black(uint8_t amnt) const;
Color lighten(uint8_t delta) { return *this + delta; }
Color darken(uint8_t delta) { return *this - delta; }
+2 -1
View File
@@ -400,6 +400,7 @@
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_SWITCH
#define USE_SENDSPIN_VISUALIZER
#define USE_SENDSPIN_PORT 8928 // NOLINT
#define USE_SOCKET_IMPL_BSD_SOCKETS
@@ -536,7 +537,7 @@
// rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias
// for external custom components that may still test for it.
#ifdef USE_RP2
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0)
#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 0)
#define USE_RP2_CRASH_HANDLER
#define USE_HTTP_REQUEST_RESPONSE
#define USE_I2C
+83 -18
View File
@@ -9,16 +9,19 @@ byte-identical to PlatformIO's output:
Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes)
The format matches ``script/ci_memory_impact_extract.py`` so CI memory
analysis works unchanged on native ESP-IDF builds. RAM total is the
DRAM region size from the linker map; Flash total is taken from
analysis works unchanged on native ESP-IDF builds. RAM usage comes from
the DRAM (or unified DIRAM) region of the linker map. Flash used is the
exact image size matching the ``Total image size`` line: json2
``total_size`` when present, otherwise derived from the ELF (see
``_image_size_from_elf``). Flash total is taken from
``partitions.csv`` using PlatformIO's rule (first app partition whose
subtype is ``factory`` or ``ota_0``; see
``platform-espressif32/builder/main.py::_update_max_upload_size``).
Structured size data is produced at link time by a CMake POST_BUILD
custom command (see ``build_gen/espidf.py``) which writes
``esp_idf_size.json`` next to the ELF. We read that file here rather
than re-running ``esp_idf_size`` from Python.
``esp_idf_size.json`` (``--format=json2``, a per-memory-type summary)
next to the ELF; we read that rather than re-running ``esp_idf_size``.
"""
from __future__ import annotations
@@ -27,6 +30,7 @@ import csv
import json
import logging
from pathlib import Path
import struct
from esphome.build_helpers.size_summary import print_size_line
@@ -69,11 +73,43 @@ def _find_app_partition_size(partitions_csv: Path) -> int:
raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}")
def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
def _image_size_from_elf(elf: Path) -> int:
"""Sum the allocated PROGBITS section sizes from an ELF32 file.
Matches ``esp_idf_size.ng.memorymap._get_image_size`` byte for byte;
esptool's ``ELFFile`` filters sections differently and would not.
Raises ``ValueError`` for anything but a well-formed ELF32 LE file.
"""
with elf.open("rb") as f:
header = f.read(52) # ELF32 header
if len(header) < 52 or header[:6] != b"\x7fELF\x01\x01":
raise ValueError(f"{elf} is not a 32-bit little-endian ELF")
(e_shoff,) = struct.unpack_from("<I", header, 0x20) # e_shoff
e_shentsize, e_shnum = struct.unpack_from("<HH", header, 0x2E)
if e_shentsize < 40: # sizeof(Elf32_Shdr)
raise ValueError(f"{elf} has an invalid section header size")
f.seek(e_shoff)
table = f.read(e_shnum * e_shentsize)
if len(table) < e_shnum * e_shentsize:
raise ValueError(f"{elf} has a truncated section header table")
total = 0
for off in range(0, e_shnum * e_shentsize, e_shentsize):
sh_type, sh_flags = struct.unpack_from("<II", table, off + 4)
(sh_size,) = struct.unpack_from("<I", table, off + 20)
if sh_type == 1 and sh_flags & 0x2: # SHT_PROGBITS with SHF_ALLOC
total += sh_size
if total == 0:
# A used-0-bytes Flash line would read as a real measurement
raise ValueError(f"{elf} has no allocated PROGBITS sections")
return total
def print_summary(size_json: Path, partitions_csv: Path, firmware_elf: Path) -> None:
"""Print PlatformIO-shaped RAM and Flash one-liners.
Failures are non-fatal: the build has already succeeded, we just couldn't
summarize. Logs the cause at debug level.
summarize. Anomalies (missing region, unreadable ELF) warn; expected
optional inputs (no size json, no partitions.csv) log at debug.
"""
if not size_json.is_file():
_LOGGER.debug("Skipping size summary: %s not found", size_json)
@@ -83,20 +119,49 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
except (OSError, json.JSONDecodeError) as e:
_LOGGER.debug("Skipping size summary: %s", e)
return
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_size_line("RAM", ram_used, ram_total)
image_size = data.get("image_size")
if image_size is None or partitions_csv is None:
if not isinstance(data, dict):
_LOGGER.warning("Skipping size summary: unexpected json shape in %s", size_json)
return
layout = data.get("layout")
regions = {
entry.get("name"): entry
for entry in (layout if isinstance(layout, list) else [])
if isinstance(entry, dict)
}
# Every chip has a DRAM or DIRAM region, so a warning here usually
# means the esp_idf_size json schema changed
ram_region = regions.get("DRAM") or regions.get("DIRAM")
if ram_region is None:
_LOGGER.warning("Skipping RAM summary: no DRAM/DIRAM region in %s", size_json)
elif (
isinstance(ram_total := ram_region.get("total"), int)
and ram_total > 0
and isinstance(ram_used := ram_region.get("used"), int)
):
print_size_line("RAM", ram_used, ram_total)
else:
_LOGGER.warning(
"Skipping RAM summary: unusable region %s in %s", ram_region, size_json
)
# esp-idf-size >= 2.1 (IDF >= 6.0) reports the exact image size in
# json2; older 1.x omits it, so derive the same figure from the ELF.
flash_used = data.get("total_size")
if not (isinstance(flash_used, int) and flash_used > 0):
_LOGGER.debug("No total_size in %s, deriving from %s", size_json, firmware_elf)
try:
flash_used = _image_size_from_elf(firmware_elf)
except (OSError, ValueError) as e:
# The ELF must be present and well formed after a successful build
_LOGGER.warning("Skipping Flash summary: %s", e)
return
try:
app_size = _find_app_partition_size(partitions_csv)
except ValueError as e:
except (OSError, ValueError) as e:
_LOGGER.debug("Skipping Flash summary: %s", e)
return
print_size_line("Flash", image_size, app_size)
if app_size <= 0:
_LOGGER.debug("Skipping Flash summary: app partition size is 0")
return
print_size_line("Flash", flash_used, app_size)
+12 -3
View File
@@ -542,7 +542,7 @@ def run_compile(config, verbose: bool) -> int:
if rc == 0:
size_json = CORE.relative_build_path("build", "esp_idf_size.json")
partitions = CORE.relative_build_path("partitions.csv")
print_summary(size_json, partitions if partitions.is_file() else None)
print_summary(size_json, partitions, get_built_elf_path())
return rc
@@ -579,6 +579,16 @@ def get_ota_firmware_path() -> Path:
return build_dir / "firmware.ota.bin"
def get_built_elf_path() -> Path:
"""Path to the ELF idf.py writes directly, ``<build>/<name>.elf``.
Exists as soon as the build finishes, unlike the ``firmware.elf``
copy that ``create_elf_copy`` makes later.
"""
build_dir = CORE.relative_build_path("build")
return build_dir / f"{CORE.name}.elf"
def get_elf_path() -> Path:
"""Get the path to the firmware ELF file.
@@ -706,8 +716,7 @@ def create_elf_copy() -> bool:
"download ELF" link requests the literal filename ``firmware.elf``
(PlatformIO convention), so copy it to that name.
"""
build_dir = CORE.relative_build_path("build")
src_elf = build_dir / f"{CORE.name}.elf"
src_elf = get_built_elf_path()
dst_elf = get_elf_path()
if not src_elf.is_file():

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