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/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 8007f4a9d69..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): @@ -2623,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 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/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)