diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 8b0c8215031..1d071b38662 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -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 diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml index 5599691ed19..73a3f7dbaa9 100644 --- a/.github/workflows/sync-dependency-versions.yml +++ b/.github/workflows/sync-dependency-versions.yml @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 9f29062e093..0282011f504 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 \ diff --git a/esphome/__main__.py b/esphome/__main__.py index 6c984050f42..905339d2bcc 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -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: diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2648b5d0d88..0d2ac36937c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -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 diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4d927b28857..b7f0bda0ed7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -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 diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 66295b3d535..8e8fa99833d 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -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]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 67a5d9792e5..646a8f4211e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -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(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) { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 966ba8f2bf5..196a4249cc6 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -114,15 +114,26 @@ template 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 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 diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index ec270ab4111..986017a138d 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -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(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(); } } diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 948b9445f22..7d5a566b550 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -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 { diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 5f2a90d5472..0fdda5067ea 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -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(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); diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 91a29d8d533..6a4a3a4d531 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -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(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 diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index 34e9b99ce84..608ebac4d6a 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -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( diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index df72c590a33..c825deaefd9 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -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 diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index e4d8dfc4928..d0d870c2894 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -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, diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index de0a0c5d750..09b369435e9 100644 --- a/esphome/components/tas58xx/model_tas5805m.cpp +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -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"); } diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp index 3aa74c675e5..cf5861cb4a4 100644 --- a/esphome/components/tas58xx/model_tas5825m.cpp +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -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"); } diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 579da9a315e..79d7068838a 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -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) diff --git a/esphome/core/application.h b/esphome/core/application.h index 2f399cbdcd5..b91bc187e6f 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -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); diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 2042c438044..8e68594a030 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -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(); } diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 4ba2606d76c..69798fd8eb4 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -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); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index 3aa8669e445..836d13d8f77 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -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 diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h index 167a422c614..16afa38fda7 100644 --- a/esphome/core/wake/wake_freertos.h +++ b/esphome/core/wake/wake_freertos.h @@ -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 diff --git a/requirements.txt b/requirements.txt index fe3d3ab64e9..8deafbad86f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f644859d013..701765bb419 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -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 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 """ diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f4fe331df45..25c4cc4d36c 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -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 diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 2e6b38edb99..033c8b627e5 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -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) { diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 3ba3564608a..57ee25bfc16 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -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 |- diff --git a/tests/components/combination/validate-deprecated.esp32-idf.yaml b/tests/components/combination/validate-deprecated.esp32-idf.yaml new file mode 100644 index 00000000000..829d76445d5 --- /dev/null +++ b/tests/components/combination/validate-deprecated.esp32-idf.yaml @@ -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 diff --git a/tests/components/mcp2515/common.yaml b/tests/components/mcp2515/common.yaml index 15639df5fe9..2e88f3e5c64 100644 --- a/tests/components/mcp2515/common.yaml +++ b/tests/components/mcp2515/common.yaml @@ -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(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}; diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index a41ba346aa1..bdb5f2eea25 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -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: diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 2e4eb67a241..5e29b06ef07 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -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) diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index e7f443c2d23..1092c168ad2 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -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 diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 230a8e1f9ef..742f07c8dec 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -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: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4edf6d5fc31..37d8e7486f4 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -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)