Merge remote-tracking branch 'origin/dev' into store-yaml-helpers-core

This commit is contained in:
J. Nick Koston
2026-10-07 22:22:14 -10:00
36 changed files with 453 additions and 105 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ module.exports = async ({ github, context }) => {
});
const currentLabels = currentLabelsData.map(label => label.name);
const managedLabels = currentLabels.filter(label =>
label.startsWith('component: ') || MANAGED_LABELS.includes(label)
label.startsWith('component: ') || label.startsWith('platform: ') || MANAGED_LABELS.includes(label)
);
// Check for mega-PR early - if present, skip most automatic labeling
@@ -15,6 +15,8 @@ on:
# base branch checkout, so fork code never runs with the token.
pull_request_target:
types: [opened, synchronize, reopened]
branches:
- dev
paths:
- requirements_dev.txt
- requirements_test.txt
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.22.0
RUN \
platformio settings set enable_telemetry No \
+31 -5
View File
@@ -7,6 +7,7 @@ import importlib
import logging
import os
from pathlib import Path
import platform
import re
import sys
import time
@@ -54,7 +55,7 @@ from esphome.const import (
)
from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType
@@ -1311,9 +1312,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
# platform's final-validate hook merges duplicates anyway.
available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
platform = ota_item.get(CONF_PLATFORM)
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[platform] = None
ota_platform = ota_item.get(CONF_PLATFORM)
if ota_platform in (CONF_ESPHOME, CONF_WEB_SERVER):
available[ota_platform] = None
if not available:
raise EsphomeError(
@@ -1945,7 +1946,10 @@ def run_multiple_configs(
safe_print()
cmd = command_builder(f)
rc = run_external_process(*cmd)
# The parent already logged the Intel macOS warning; children skip it.
rc = run_external_process(
*cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"}
)
if rc == 0:
print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}")
@@ -2614,6 +2618,27 @@ def _warn_if_source_tree_mismatch() -> None:
)
_INTEL_MACOS_REMOVAL = "2027.6.0"
_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED"
def _warn_if_intel_macos() -> None:
"""Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL."""
if (
not IS_MACOS
or platform.machine() != "x86_64"
or _INTEL_MACOS_WARNED_ENV in os.environ
):
return
_LOGGER.warning(
"Support for Intel Macs will end in ESPHome %s or earlier. The Python "
"packages ESPHome depends on have stopped publishing Intel macOS builds, "
"so future releases will not install on this machine. On an Apple "
"Silicon Mac, switch to a native arm64 Python.",
_INTEL_MACOS_REMOVAL,
)
def run_esphome(argv):
from esphome.address_cache import AddressCache
@@ -2633,6 +2658,7 @@ def run_esphome(argv):
setup_log(log_level=args.log_level)
_warn_if_source_tree_mismatch()
_warn_if_intel_macos()
if args.command in PRE_CONFIG_ACTIONS:
try:
+1 -3
View File
@@ -468,11 +468,9 @@ message DeviceWizardResponse {
option (id) = 157;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_API_WIZARD";
// Not logged: the data is in flash, which ESP8266 cannot read for a dump
option (log) = false;
// The data is in flash, and the device sends it from there
bytes data = 1 [(pointer_to_buffer) = true];
bytes data = 1 [(pointer_to_buffer) = true, (progmem) = true];
}
// Sets the entity id of a wizard input (see WizardInputField.key). Clients send
-3
View File
@@ -423,9 +423,6 @@ class APIConnection final : public APIServerConnectionBase {
bool send_ping_response_();
bool send_device_info_response_();
bool send_device_capabilities_response_();
#ifdef USE_API_WIZARD
bool send_device_wizard_response_();
#endif
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
+5
View File
@@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions {
// to true when the field arrives, so an all-default submessage can be told apart
// from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
optional bool track_presence = 50020 [default=false];
// progmem: The pointer_to_buffer bytes field points into flash (PROGMEM).
// The generated encode and dump copy it with progmem_memcpy, as ESP8266
// cannot read flash with ordinary loads.
optional bool progmem = 50021 [default=false];
}
+1 -1
View File
@@ -352,7 +352,7 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) {
#ifdef USE_API_WIZARD
uint8_t *DeviceWizardResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const DeviceWizardResponse *>(self);
pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len);
pos = ProtoEncode::encode_progmem_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len);
return pos;
}
uint32_t DeviceWizardResponse::calc_size_msg(const void *self) {
+14 -3
View File
@@ -114,15 +114,26 @@ template<typename T> static void dump_field(DumpBuffer &out, const char *field_n
out.append("\n");
}
// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer
static constexpr size_t DUMP_BYTES_MAX = 160;
// Helper for bytes fields - uses stack buffer to avoid heap allocation
// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer
// field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms)
static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) {
char hex_buf[format_hex_pretty_size(160)];
char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)];
append_field_prefix(out, field_name, indent);
format_hex_pretty_to(hex_buf, data, len);
out.append(hex_buf).append("\n");
}
// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first
static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len,
int indent = 2) {
uint8_t data_buf[DUMP_BYTES_MAX];
len = std::min(len, sizeof(data_buf));
progmem_memcpy(data_buf, data, len);
dump_bytes_field(out, field_name, data_buf, len, indent);
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
@@ -1119,7 +1130,7 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_API_WIZARD
const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse"));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
dump_progmem_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
#endif
+2 -19
View File
@@ -13,17 +13,6 @@ namespace esphome::api {
static const char *const TAG = "api.wizard";
uint8_t *wizard_encode_response(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
const auto &msg = *static_cast<const DeviceWizardResponse *>(self);
if (msg.data_len == 0)
return pos;
pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited
pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len);
PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len);
progmem_memcpy(pos, msg.data, msg.data_len);
return pos + msg.data_len;
}
#ifdef USE_API_WIZARD_INPUTS
static bool wizard_entity_id_valid(const char *entity_id, size_t length) {
return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr;
@@ -49,17 +38,11 @@ const char *wizard_set_input(const WizardInputSetRequest &msg) {
}
#endif // USE_API_WIZARD_INPUTS
bool APIConnection::send_device_wizard_response_() {
void APIConnection::on_device_wizard_request() {
DeviceWizardResponse resp;
resp.data = API_WIZARD_DATA;
resp.data_len = API_WIZARD_DATA_SIZE;
// Not send_message: the data is in flash, so wizard_encode_response copies it out
return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE,
&wizard_encode_response, &resp);
}
void APIConnection::on_device_wizard_request() {
if (!this->send_device_wizard_response_()) {
if (!this->send_message(resp)) {
this->on_fatal_error();
}
}
-4
View File
@@ -22,10 +22,6 @@ static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256;
/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long.
extern const uint8_t API_WIZARD_DATA[] PROGMEM;
/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read
/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere.
uint8_t *wizard_encode_response(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
#ifdef USE_API_WIZARD_INPUTS
/// Where the entity id of an input is kept, found by the key the client uses for it.
struct WizardInputEntry {
+16
View File
@@ -3,6 +3,7 @@
#include "api_pb2_defines.h"
#include "api_buffer.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -465,6 +466,21 @@ class ProtoEncode {
uint32_t field_id, const uint8_t *data, size_t len) {
return encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, reinterpret_cast<const char *>(data), len);
}
/// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy.
[[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const uint8_t *data, size_t len) {
if (len == 0)
return pos;
return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len);
}
[[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, const uint8_t *data, size_t len) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited
pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len);
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
progmem_memcpy(pos, data, len);
return pos + len;
}
[[nodiscard]] static inline uint8_t *encode_uint32_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, uint32_t value) {
pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 0);
+7 -1
View File
@@ -259,11 +259,17 @@ void CC1101Component::dump_config() {
}
void CC1101Component::begin_tx() {
// Read the mode before the PKTCTRL0 write below overwrites it in state_
const bool packet_mode = this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO);
// Ensure Packet Format is 3 (Async Serial)
this->write_(Register::PKTCTRL0, 0x32);
ESP_LOGV(TAG, "Beginning TX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->detach_interrupt();
// setup() only attaches the interrupt in packet mode. In async serial mode the pin may be
// shared with remote_receiver, and detaching here would remove the receiver's interrupt.
if (packet_mode) {
this->gdo0_pin_->detach_interrupt();
}
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
+7 -25
View File
@@ -1,5 +1,3 @@
import logging
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
@@ -18,8 +16,6 @@ from esphome.const import (
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@Cat-Ion", "@kahrendt"]
combination_ns = cg.esphome_ns.namespace("combination")
@@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema(
)
def _migrate_coeffecient(config: ConfigType) -> ConfigType:
"""Migrate deprecated 'coeffecient' spelling to 'coefficient'."""
if CONF_COEFFECIENT in config:
if CONF_COEFFICIENT in config:
raise cv.Invalid(
f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'"
)
_LOGGER.warning(
"'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0",
CONF_COEFFECIENT,
CONF_COEFFICIENT,
)
config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT)
elif CONF_COEFFICIENT not in config:
raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option")
return config
LINEAR_SOURCE_SCHEMA = cv.All(
cv.rename_key(
CONF_COEFFECIENT,
CONF_COEFFICIENT,
removed_in="2026.12.0",
component="combination",
),
cv.Schema(
{
cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor),
cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_),
cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_),
cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_),
}
),
_migrate_coeffecient,
)
SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema(
+4
View File
@@ -3074,6 +3074,10 @@ async def to_code(config):
# Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms
add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000)
# Main loop wakes use notification index 1; index 0 stays free for ESP-IDF waits
# such as pthread_join(), which a stray main-loop wake would otherwise end early.
add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES", 2)
# Place non-ISR FreeRTOS functions into flash instead of IRAM
# This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM.
# In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH
+30 -1
View File
@@ -114,9 +114,37 @@ _validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO)
# Known boards keyed by spelling with case and "-" / "_" folded away
_FOLDED_BOARDS = {board.lower().replace("-", "_"): board for board in BOARDS}
def _resolve_board(config: ConfigType) -> ConfigType:
"""Fix a misspelled board, else build an unknown one with PlatformIO."""
board = config[CONF_BOARD]
if board in BOARDS:
return config
if canonical := _FOLDED_BOARDS.get(board.lower().replace("-", "_")):
_LOGGER.warning(
"Board '%s' is not a known ESP8266 board; using '%s'", board, canonical
)
return {**config, CONF_BOARD: canonical}
if CORE.toolchain is None and CONF_TOOLCHAIN not in config:
return {**config, CONF_TOOLCHAIN: Toolchain.PLATFORMIO}
return config
def _warn_platformio_toolchain(config: ConfigType) -> ConfigType:
# Remove before 2027.4.0
if CORE.using_toolchain_platformio:
if not CORE.using_toolchain_platformio:
return config
if config[CONF_BOARD] not in BOARDS:
_LOGGER.warning(
"Board '%s' is not supported by the native 'arduino' toolchain, so it "
"builds with the deprecated 'platformio' toolchain, which will be "
"removed in ESPHome 2027.4.0",
config[CONF_BOARD],
)
else:
_LOGGER.warning(
"The 'platformio' toolchain for ESP8266 is deprecated and will be "
"removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the "
@@ -329,6 +357,7 @@ CONFIG_SCHEMA = cv.All(
): _validate_toolchain,
}
),
_resolve_board,
_resolve_toolchain,
_warn_platformio_toolchain,
_validate_native_toolchain,
@@ -15,9 +15,12 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = {
{0x60, 0x01}, // ADR_PIN_CTRL: output
{0x7D, 0x11},
{0x7E, 0xFF},
{0x00, 0x01},
{0x00, 0x01}, // Page 1
{0x51, 0x05},
{0x00, 0x00},
{0x00, 0x00}, // Page 0
// Additional register configuration
{0x53, 0x60}, // ANA_CTRL: for high audio performance use 175kHz bandwidth with Fsw=768kHz
};
static const LogString *model_name() { return LOG_STR("TAS5805M"); }
+13 -14
View File
@@ -4,13 +4,11 @@
namespace esphome::tas58xx {
// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page.
// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console.
// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet.
// clang-format off
// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page.
// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k".
// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet.
static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = {
{0x03, 0x00}, // Deep Sleep
{0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from
{0x7D, 0x11},
{0x7E, 0xFF},
{0x00, 0x01}, // Page 1
@@ -20,15 +18,16 @@ static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = {
{0x19, 0x80},
{0x00, 0x00}, // Page 0
{0x46, 0x11},
{0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE
{0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase
{0x54, 0x00}, // AGAIN 0dB
{0x03, 0x02}, // Hi-Z
{0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output
{0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output
{0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults
// PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML
{0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz
{0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z
// Additional register configuration
// GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ
{0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ
{0x60, 0x02}, // GPIO_CTRL: GPIO1 is output
{0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults
};
// clang-format on
static const LogString *model_name() { return LOG_STR("TAS5825M"); }
+5
View File
@@ -2872,9 +2872,14 @@ def rename_key(
When ``removed_in`` is set, a deprecation warning is logged if the old key is
present. Pass ``component`` (the platform/component name) alongside
``removed_in`` so the warning identifies where it originates.
Input that is not a dictionary is returned unchanged, so the schema that
follows reports it as a normal configuration error.
"""
def validator(config: dict) -> dict:
if not isinstance(config, dict):
return config
config = config.copy()
if old_key in config:
has_at_most_one_key(old_key, new_key)(config)
+2 -2
View File
@@ -673,8 +673,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS)
// Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so
// subsequent Socket::ready() checks in this iter observe the published state
// without a per-call memw. Wake is independent (xTaskNotifyGive/
// ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g.
// without a per-call memw. Wake is independent (esphome_main_task_notify/
// esphome_main_task_wait), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g.
// BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data()
// instead.
std::atomic_thread_fence(std::memory_order_acquire);
+5 -5
View File
@@ -99,10 +99,10 @@
// ESP32-C3/C6/H2) cross-core visibility is not an issue.
//
// FreeRTOS task notification value:
// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks
// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake).
// Written by TCP/IP thread (esphome_main_task_notify in callback) and background tasks
// (esphome_main_task_notify in wake_main_loop). Read by main loop (esphome_main_task_wait).
// Safe: FreeRTOS notification APIs are thread-safe by design (use internal
// critical sections). Multiple concurrent xTaskNotifyGive calls are safe —
// critical sections). Multiple concurrent notify calls are safe —
// the notification count simply increments.
// USE_LWIP_FAST_SELECT is set via -D build flag (not cg.add_define) so it is
@@ -178,7 +178,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt
// s_original_callback is always valid here: hook_socket() sets it before swapping
// the callback pointer, so this wrapper cannot run until it's initialized.
s_original_callback(conn, evt, len);
// Wake the main loop task if sleeping in ulTaskNotifyTake().
// Wake the main loop task if sleeping in esphome_main_task_wait().
// Only notify on receive events to avoid spurious wakeups from send-ready events.
// NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS
// (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions
@@ -186,7 +186,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt
if (evt == NETCONN_EVT_RCVPLUS) {
#ifdef USE_OTA_PLATFORM_ESPHOME
// Mark OTA pending-enable only for events on its listen socket. MUST happen
// before xTaskNotifyGive so the flags are visible when the main task wakes.
// before the main task is notified so the flags are visible when it wakes.
if (conn == s_ota_listener_conn) {
esphome_wake_ota_component_any_context();
}
+4 -4
View File
@@ -33,9 +33,9 @@ struct lwip_sock *esphome_lwip_get_sock(int fd);
/// per-call `memw` that volatile would emit on Xtensa under default
/// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped
/// and the volatile load provides ordering on its own.
/// Stale reads are harmless either way: the hooked event_callback
/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and
/// ulTaskNotifyTake never loses a wake.
/// Stale reads are harmless either way: the hooked event_callback calls
/// esphome_main_task_notify() on RCVPLUS, so the next iteration re-snapshots and
/// esphome_main_task_wait() never loses a wake.
/// The offset and size are verified at compile time in lwip_fast_select.c.
static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) {
#ifdef ESPHOME_THREAD_MULTI_ATOMICS
@@ -46,7 +46,7 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) {
}
/// Hook a socket's netconn callback to notify the main loop task on receive events.
/// Wraps the original event_callback with one that also calls xTaskNotifyGive().
/// Wraps the original event_callback with one that also calls esphome_main_task_notify().
/// Must be called from the main loop after socket creation.
/// The sock pointer must have been obtained from esphome_lwip_get_sock().
void esphome_lwip_hook_socket(struct lwip_sock *sock);
+26
View File
@@ -19,12 +19,25 @@ extern "C" {
extern TaskHandle_t esphome_main_task_handle;
#ifdef USE_ESP32
/// ESP-IDF waits on index 0 from the main task (e.g. pthread_join() uses an unfiltered
/// xTaskNotifyWait), so main-loop wakes on index 0 would end those waits early.
enum { ESPHOME_MAIN_TASK_NOTIFY_INDEX = 1 };
#if configTASK_NOTIFICATION_ARRAY_ENTRIES < 2
#error "CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES must be at least 2"
#endif
#endif
/// Wake the main loop task from another FreeRTOS task. NOT ISR-safe.
/// always_inline so callers placed in IRAM do not reference a flash-resident copy.
__attribute__((always_inline)) static inline void esphome_main_task_notify() {
TaskHandle_t task = esphome_main_task_handle;
if (task != NULL) {
#ifdef USE_ESP32
xTaskNotifyGiveIndexed(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX);
#else
xTaskNotifyGive(task);
#endif
}
}
@@ -33,10 +46,23 @@ __attribute__((always_inline)) static inline void esphome_main_task_notify_from_
BaseType_t *px_higher_priority_task_woken) {
TaskHandle_t task = esphome_main_task_handle;
if (task != NULL) {
#ifdef USE_ESP32
vTaskNotifyGiveIndexedFromISR(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX, px_higher_priority_task_woken);
#else
vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken);
#endif
}
}
/// Block the main loop task until it is woken or the timeout expires.
__attribute__((always_inline)) static inline void esphome_main_task_wait(TickType_t ticks) {
#ifdef USE_ESP32
ulTaskNotifyTakeIndexed(ESPHOME_MAIN_TASK_NOTIFY_INDEX, pdTRUE, ticks);
#else
ulTaskNotifyTake(pdTRUE, ticks);
#endif
}
#ifdef __cplusplus
}
#endif
+4 -4
View File
@@ -43,15 +43,15 @@ namespace internal {
inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) {
// Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip
// event_callback wrapper (see lwip_fast_select.c) are the single source of truth for
// socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification
// that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns
// immediately) or wakes a blocked Take directly. Additional wake sources:
// socket wake-ups. Every NETCONN_EVT_RCVPLUS calls esphome_main_task_notify(), so any notification
// that lands between wakes keeps the counter non-zero (next esphome_main_task_wait() returns
// immediately) or wakes a blocked wait directly. Additional wake sources:
// wake_loop_threadsafe() from background tasks, and the ms timeout.
if (ms == 0) [[unlikely]] {
yield();
return;
}
ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms));
esphome_main_task_wait(pdMS_TO_TICKS(ms));
}
} // namespace internal
+4 -1
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.4.0
click==8.5.0
aioesphomeapi==46.6.0
aioesphomeapi==46.8.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -26,6 +26,9 @@ freetype-py==2.5.1
jinja2==3.1.6
bleak==3.0.2
smpclient==7.3.0
# smp 4.2+ needs cbor2 6+, which ships no Intel macOS wheels (arm64 only).
smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.3
platformdirs==4.12.3 # native esp-idf toolchain global cache dir
+43 -3
View File
@@ -536,6 +536,16 @@ def create_field_type_info(
f"track_presence on field '{field.name}' has no effect; it requires "
"a non-repeated message field in a message that is decoded"
)
if get_field_opt(field, pb.progmem, False) and (
field.label == FieldDescriptorProto.LABEL_REPEATED
or field.type != 12
or not get_field_opt(field, pb.pointer_to_buffer, False)
or get_field_opt(field, pb.fixed_array_size) is not None
):
raise ValueError(
f"progmem on field '{field.name}' requires a non-repeated bytes field "
"with pointer_to_buffer"
)
if field.label == FieldDescriptorProto.LABEL_REPEATED:
# Check if this is a packed_buffer field (zero-copy packed repeated)
if get_field_opt(field, pb.packed_buffer, False):
@@ -1158,6 +1168,11 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
reference_type = "const uint8_t*"
const_reference_type = "const uint8_t*"
@property
def progmem(self) -> bool:
"""Whether the data is in flash, so encode and dump copy it with progmem_memcpy."""
return get_field_opt(self._field, pb.progmem, False)
@property
def public_content(self) -> list[str]:
# Use uint16_t for length - max packet size is well below 65535
@@ -1168,6 +1183,14 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
@property
def encode_content(self) -> str:
if self.progmem:
return _encode_call(
"encode_progmem_bytes",
str(self.number),
f"this->{self.field_name}",
f"this->{self.field_name}_len",
force=self.force,
)
if result := self._encode_bytes_with_precomputed_tag(
f"this->{self.field_name}", f"this->{self.field_name}_len"
):
@@ -1194,8 +1217,9 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
@property
def dump_content(self) -> str:
dump_fn = "dump_progmem_bytes_field" if self.progmem else "dump_bytes_field"
return (
f'dump_bytes_field(out, ESPHOME_PSTR("{self.name}"), '
f'{dump_fn}(out, ESPHOME_PSTR("{self.name}"), '
f"this->{self.field_name}, this->{self.field_name}_len);"
)
@@ -2609,6 +2633,11 @@ def build_message_type(
):
fixed_vector_fields.append((field.name, field.number))
if needs_decode and get_field_opt(field, pb.progmem, False):
raise ValueError(
f"progmem on field '{field.name}' of {desc.name} requires a message "
"that is only encoded; received data is never in flash"
)
ti = create_field_type_info(field, needs_decode, needs_encode)
# Skip field declarations for fields that are in the base class
@@ -3319,15 +3348,26 @@ template<typename T> static void dump_field(DumpBuffer &out, const char *field_n
out.append("\\n");
}
// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer
static constexpr size_t DUMP_BYTES_MAX = 160;
// Helper for bytes fields - uses stack buffer to avoid heap allocation
// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer
// field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms)
static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) {
char hex_buf[format_hex_pretty_size(160)];
char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)];
append_field_prefix(out, field_name, indent);
format_hex_pretty_to(hex_buf, data, len);
out.append(hex_buf).append("\\n");
}
// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first
static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len,
int indent = 2) {
uint8_t data_buf[DUMP_BYTES_MAX];
len = std::min(len, sizeof(data_buf));
progmem_memcpy(data_buf, data, len);
dump_bytes_field(out, field_name, data_buf, len, indent);
}
#pragma GCC diagnostic pop
"""
+1
View File
@@ -13,6 +13,7 @@ CONFIG_LIBC_NEWLIB=y
# esp32
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
CONFIG_FREERTOS_HZ=1000
CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2
CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
+3 -3
View File
@@ -68,7 +68,7 @@ TEST(DeviceWizard, ResponseSendsTheDataUnchanged) {
Bytes expected{0x0a, 0xc8, 0x01};
expected.insert(expected.end(), API_WIZARD_DATA, API_WIZARD_DATA + API_WIZARD_DATA_SIZE);
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected);
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected);
}
TEST(DeviceWizard, ShortDataHasAOneByteLength) {
@@ -77,13 +77,13 @@ TEST(DeviceWizard, ShortDataHasAOneByteLength) {
resp.data_len = 3;
Bytes expected{0x0a, 0x03, API_WIZARD_DATA[0], API_WIZARD_DATA[1], API_WIZARD_DATA[2]};
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response), expected);
EXPECT_EQ(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg), expected);
}
TEST(DeviceWizard, NoDataEncodesNothing) {
DeviceWizardResponse resp;
EXPECT_EQ(resp.calculate_size(), 0u);
EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &wizard_encode_response).empty());
EXPECT_TRUE(encode(resp, &DeviceWizardResponse::calc_size_msg, &DeviceWizardResponse::encode_msg).empty());
}
TEST(DeviceWizard, CapabilitiesAnnounceTheWizard) {
+4
View File
@@ -18,6 +18,7 @@ canbus:
lambda: "return x[0] == 0x11;"
then:
logger.log: Truth
- canbus.send: !lambda return x;
- can_id: 0b00000000000000000000001000000
can_id_mask: 0b11111000000000011111111000000
use_extended_id: true
@@ -59,6 +60,9 @@ button:
data: [0, 1, 2]
- canbus.send: "abc"
- canbus.send: [0, 1, 2]
# Identical on purpose: both sends share one constant payload
- canbus.send: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]
- canbus.send: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]
- canbus.send: !lambda return {0, 1, 2};
# Test canbus.send with lambda that references a component (function pointer)
- canbus.send: !lambda |-
@@ -0,0 +1,15 @@
sensor:
- platform: template
id: template_temperature1
lambda: return 0.6;
- platform: template
id: template_temperature2
lambda: return 0.8;
- platform: combination
type: linear
name: Linearly combined temperatures
sources:
- source: template_temperature1
coeffecient: 0.5
- source: template_temperature2
coefficient: 1.5
+24
View File
@@ -10,6 +10,11 @@ canbus:
- lambda: |-
std::string b(x.begin(), x.end());
ESP_LOGD("can_id 500", "%s", b.c_str());
- canbus.send:
canbus_id: mcp2515_can
can_id: 501
data: !lambda |-
return {static_cast<uint8_t>(can_id), x.empty() ? uint8_t(0) : x[0]};
- can_id: 23
then:
- if:
@@ -36,3 +41,22 @@ canbus:
break;
// to be continued...
}
button:
- platform: template
name: MCP2515 Send
on_press:
# Identical on purpose: both sends share one constant payload
- canbus.send:
canbus_id: mcp2515_can
data: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]
- canbus.send:
canbus_id: mcp2515_can
data: [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]
- canbus.send:
canbus_id: mcp2515_can
can_id: 0x123
data: "abc"
- canbus.send:
canbus_id: mcp2515_can
data: !lambda return {0x01, 0x02};
@@ -5,6 +5,13 @@ number:
min_value: 0
max_value: 255
step: 1
on_value:
- remote_transmitter.transmit_abbwelcome:
source_address: 0x1234
destination_address: 0x5678
message_type: 0x01
data: !lambda |-
return {(uint8_t) x, 0x20};
button:
- platform: template
@@ -260,6 +267,22 @@ button:
destination_address: 0x5678
message_type: 0x01
data: [0x10, 0x20, 0x30]
- platform: template
name: ABBWelcome static same data
on_press:
remote_transmitter.transmit_abbwelcome:
source_address: 0x1234
destination_address: 0x5678
message_type: 0x02
data: [0x10, 0x20, 0x30]
- platform: template
name: ABBWelcome static empty data
on_press:
remote_transmitter.transmit_abbwelcome:
source_address: 0x1234
destination_address: 0x5678
message_type: 0x03
data: []
- platform: template
name: ABBWelcome lambda
on_press:
@@ -360,3 +360,56 @@ def test_repeated_strings_generate_in_every_direction(source: int) -> None:
assert "nullptr" not in header
if source != SOURCE_SERVER:
assert "case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED):" in cpp
def _progmem_field(
*, pointer_to_buffer: bool = True, force: bool = False, repeated: bool = False
) -> descriptor_pb2.FieldDescriptorProto:
field = _field(12, force=force, repeated=repeated)
field.options.Extensions[pb.progmem] = True
if pointer_to_buffer:
field.options.Extensions[pb.pointer_to_buffer] = True
return field
@pytest.mark.parametrize(
("force", "encode_fn"),
[(False, "encode_progmem_bytes("), (True, "encode_progmem_bytes_force(")],
)
def test_progmem_bytes_field_copies_from_flash(force: bool, encode_fn: str) -> None:
"""A (progmem) field encodes and dumps through the progmem_memcpy helpers."""
ti = create_field_type_info(_progmem_field(force=force), needs_decode=False)
assert encode_fn in ti.encode_content
assert "encode_bytes" not in ti.encode_content
assert "dump_progmem_bytes_field(" in ti.dump_content
@pytest.mark.parametrize(
"field",
[
pytest.param(_progmem_field(pointer_to_buffer=False), id="no_pointer"),
pytest.param(_progmem_field(repeated=True), id="repeated"),
],
)
def test_progmem_rejected_where_it_cannot_apply(
field: descriptor_pb2.FieldDescriptorProto,
) -> None:
"""(progmem) on a field that would not copy from flash fails instead of silently using memcpy."""
with pytest.raises(ValueError, match="progmem on field 'value'"):
create_field_type_info(field, needs_decode=False)
def test_progmem_rejected_in_a_decoded_message() -> None:
"""Received data is never in flash, so a decoded message cannot use (progmem)."""
desc = descriptor_pb2.DescriptorProto(name="Received")
desc.field.add().CopyFrom(_progmem_field())
with pytest.raises(ValueError, match="progmem on field 'value' of Received"):
build_message_type(desc, {}, {"Received": SOURCE_CLIENT})
def test_progmem_rejected_on_non_bytes_field() -> None:
"""(progmem) only applies to bytes fields."""
field = _field(9)
field.options.Extensions[pb.progmem] = True
with pytest.raises(ValueError, match="progmem on field 'value'"):
create_field_type_info(field, needs_decode=False)
@@ -12,10 +12,12 @@ import pytest
from esphome.components import esp8266
from esphome.components.esp8266 import (
ARDUINO_FRAMEWORK_SCHEMA,
_resolve_board,
_resolve_toolchain,
_validate_native_toolchain,
_warn_platformio_toolchain,
)
from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266
import esphome.config_validation as cv
from esphome.const import (
CONF_BOARD,
@@ -118,6 +120,57 @@ def test_unsupported_board_rejected() -> None:
_validate_native_toolchain(_config(board="not_a_board"))
def test_known_board_passes_unchanged() -> None:
config = {CONF_BOARD: "esp01_1m"}
assert _resolve_board(config) is config
@pytest.mark.parametrize(
("board", "expected"),
[("ESP01-1M", "esp01_1m"), ("SPARKFUNBLYNK", "sparkfunBlynk")],
)
def test_misspelled_board_normalized(
board: str, expected: str, caplog: pytest.LogCaptureFixture
) -> None:
assert _resolve_board({CONF_BOARD: board})[CONF_BOARD] == expected
assert f"using '{expected}'" in caplog.text
@pytest.mark.parametrize(
("board", "expected_board", "expected_toolchain"),
[
("esp01-1m", "esp01_1m", Toolchain.ARDUINO),
("my_custom_board", "my_custom_board", Toolchain.PLATFORMIO),
],
)
def test_config_schema_resolves_board_before_toolchain(
board: str,
expected_board: str,
expected_toolchain: Toolchain,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The full chain normalizes or falls back, and warns once."""
CORE.toolchain = None
config = esp8266.CONFIG_SCHEMA({CONF_BOARD: board})
assert config[CONF_BOARD] == expected_board
assert CORE.data[KEY_ESP8266][KEY_BOARD] == expected_board
assert CORE.toolchain == expected_toolchain
assert len(caplog.records) == 1
@pytest.mark.parametrize(
("cli_toolchain", "yaml"),
[(Toolchain.ARDUINO, {}), (None, {CONF_TOOLCHAIN: Toolchain.ARDUINO})],
)
def test_unknown_board_keeps_explicit_toolchain(
cli_toolchain: Toolchain | None, yaml: ConfigType
) -> None:
"""A CLI or YAML toolchain is not overridden."""
CORE.toolchain = cli_toolchain
config = {CONF_BOARD: "my_custom_board", **yaml}
assert _resolve_board(config) is config
def test_yaml_toolchain_key_resolves() -> None:
"""The documented `toolchain: arduino` YAML key selects the native path."""
CORE.toolchain = None
@@ -3120,6 +3120,11 @@ def test_rename_key_absent() -> None:
assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5}
@pytest.mark.parametrize("value", ["an_id", 1, None, ["old"]])
def test_rename_key_non_dict_passes_through(value: object) -> None:
assert cv.rename_key("old", "new")(value) == value
def test_rename_key_no_removed_in_is_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
+39
View File
@@ -6705,6 +6705,45 @@ def test_check_permissions_unreadable_port() -> None:
check_permissions("/dev/ttyUSB99")
@pytest.mark.parametrize(
("is_macos", "machine", "warned", "warns"),
[
pytest.param(True, "x86_64", False, True, id="intel_mac"),
pytest.param(True, "x86_64", True, False, id="intel_mac_child"),
pytest.param(True, "arm64", False, False, id="apple_silicon"),
pytest.param(False, "x86_64", False, False, id="linux_x86_64"),
],
)
def test_warn_if_intel_macos(
is_macos: bool,
machine: str,
warned: bool,
warns: bool,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Only Intel Python on macOS gets the warning, and only once per invocation."""
monkeypatch.setattr(main, "IS_MACOS", is_macos)
monkeypatch.setattr(main.platform, "machine", lambda: machine)
if warned:
monkeypatch.setenv(main._INTEL_MACOS_WARNED_ENV, "1")
else:
monkeypatch.delenv(main._INTEL_MACOS_WARNED_ENV, raising=False)
with caplog.at_level(logging.WARNING):
main._warn_if_intel_macos()
assert (main._INTEL_MACOS_REMOVAL in caplog.text) is warns
def test_run_multiple_configs_marks_children_warned(tmp_path: Path) -> None:
"""Multi-config children get the env marker so they skip the Intel warning."""
with patch.object(main, "run_external_process", return_value=0) as mock_run:
main.run_multiple_configs([tmp_path / "a.yaml"], lambda f: ["esphome", str(f)])
assert mock_run.call_args.kwargs["env"][main._INTEL_MACOS_WARNED_ENV] == "1"
def _make_checkout(root: Path) -> Path:
"""Create a directory that looks like an esphome checkout."""
(root / "esphome").mkdir(parents=True)