From 479232b6c0e42b0742eb68202da18057dc1c4870 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:31:24 -1000 Subject: [PATCH 01/27] Bump aioesphomeapi from 46.6.0 to 46.7.0 (#20317) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 91a36ecbf7..5eff3a7d09 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.7.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 4d42352ec57a87225dd743fc79f06b814192a7ae Mon Sep 17 00:00:00 2001 From: mrtoy-me <118446898+mrtoy-me@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:57:46 +1000 Subject: [PATCH 02/27] [tas58xx] Improve TAS5805M audio performance and use GPIO1 for TAS5825M faults (#20319) --- esphome/components/tas58xx/model_tas5805m.cpp | 7 +++-- esphome/components/tas58xx/model_tas5825m.cpp | 27 +++++++++---------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp index de0a0c5d75..09b369435e 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 3aa74c675e..cf5861cb4a 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"); } From 5bf8ceac66a2a2587549f9a4822450d5896fd16d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:17:23 +0000 Subject: [PATCH 03/27] Bump aioesphomeapi from 46.7.0 to 46.8.0 (#20321) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5eff3a7d09..87821677e3 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.7.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 From 96a66f3ddd359da6fff9ae29540dd8633773cf7f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 7 Oct 2026 21:17:16 -0500 Subject: [PATCH 04/27] [core] Wake the main loop on its own task notification index on ESP32 (#20326) Co-authored-by: Claude Opus 5.5 (1M context) --- esphome/components/esp32/__init__.py | 4 ++++ esphome/core/application.h | 4 ++-- esphome/core/lwip_fast_select.c | 10 +++++----- esphome/core/lwip_fast_select.h | 8 ++++---- esphome/core/main_task.h | 26 ++++++++++++++++++++++++++ esphome/core/wake/wake_freertos.h | 8 ++++---- sdkconfig.defaults | 1 + 7 files changed, 46 insertions(+), 15 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index df72c590a3..c825deaefd 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/core/application.h b/esphome/core/application.h index 2f399cbdcd..b91bc187e6 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 2042c43804..8e68594a03 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 4ba2606d76..69798fd8eb 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 3aa8669e44..836d13d8f7 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 167a422c61..16afa38fda 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/sdkconfig.defaults b/sdkconfig.defaults index f4fe331df4..25c4cc4d36 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 From 10f30ddb5900db80fb7c57e62b235ae35d57a703 Mon Sep 17 00:00:00 2001 From: David Buezas Date: Thu, 8 Oct 2026 04:53:12 +0200 Subject: [PATCH 05/27] [cc1101] Keep the remote_receiver interrupt on a shared GDO0 pin in begin_tx (#20296) --- esphome/components/cc1101/cc1101.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index f7b90b91cf..a71e6d2fa8 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -253,11 +253,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 From 23f6dedb423ab3ce59b5e60eae62087bc10832a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:14:40 -1000 Subject: [PATCH 06/27] Pin smp to 4.1.0 on Intel macOS so cbor2 installs from a wheel (#20302) --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 87821677e3..6e1dfa1ba5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 From 2586e78127b3557afd8876dd1d2d50d896722e88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:23:01 -1000 Subject: [PATCH 07/27] [core] Warn on every run that Intel Mac support ends by 2027.6.0 (#20303) --- esphome/__main__.py | 36 +++++++++++++++++++++++++++----- tests/unit_tests/test_main.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6c984050f4..905339d2bc 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/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 4edf6d5fc3..37d8e7486f 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) From e7ba9a572654d6a120f1613598ee099b6a43aa59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 17:24:27 -1000 Subject: [PATCH 08/27] [api] Add a progmem option for bytes fields kept in flash (#20318) --- esphome/components/api/api.proto | 4 +- esphome/components/api/api_connection.h | 3 -- esphome/components/api/api_options.proto | 5 ++ esphome/components/api/api_pb2.cpp | 2 +- esphome/components/api/api_pb2_dump.cpp | 17 ++++-- esphome/components/api/api_wizard.cpp | 21 +------- esphome/components/api/api_wizard.h | 4 -- esphome/components/api/proto.h | 16 ++++++ script/api_protobuf/api_protobuf.py | 46 ++++++++++++++-- tests/components/api/test_wizard.cpp | 6 +-- .../api/test_api_protobuf_generator.py | 53 +++++++++++++++++++ 11 files changed, 138 insertions(+), 39 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 2648b5d0d8..0d2ac36937 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 4d927b2885..b7f0bda0ed 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 66295b3d53..8e8fa99833 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 67a5d9792e..646a8f4211 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 966ba8f2bf..196a4249cc 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 ec270ab411..986017a138 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 948b9445f2..7d5a566b55 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 5f2a90d547..0fdda5067e 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/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f644859d01..701765bb41 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/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 2e6b38edb9..033c8b627e 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/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 2e4eb67a24..5e29b06ef0 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) From b28e7a13572c6450c3afe5f95dc6dade1149d909 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Oct 2026 18:05:18 -1000 Subject: [PATCH 09/27] [esp8266] Normalize misspelled boards and fall back to PlatformIO for unknown ones (#20322) --- esphome/components/esp8266/__init__.py | 31 ++++++++++- .../esp8266/test_toolchain_validation.py | 53 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index e4d8dfc492..d0d870c289 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/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index e7f443c2d2..1092c168ad 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 From adc6255fd41b68b1a48ed85d07250a73ecf7f448 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:29:56 +1300 Subject: [PATCH 10/27] [core] Let cv.rename_key pass non-dict input through (#20331) --- esphome/config_validation.py | 5 +++++ tests/unit_tests/test_config_validation.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 579da9a315..79d7068838 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/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 230a8e1f9e..742f07c8de 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: From 63b4a5958823e136b4f1133a28e6e8bb075f3334 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:03:30 -1000 Subject: [PATCH 11/27] Bump bundled esphome-device-builder to 1.22.0 (#20334) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9f29062e09..0282011f50 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 \ From b903c54be4d2adc1403934e7b319ffdb4d922829 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:15:43 +1300 Subject: [PATCH 12/27] [combination] Use cv.rename_key for the deprecated coeffecient key (#20159) Co-authored-by: J. Nick Koston --- esphome/components/combination/sensor.py | 32 ++++--------------- .../validate-deprecated.esp32-idf.yaml | 15 +++++++++ 2 files changed, 22 insertions(+), 25 deletions(-) create mode 100644 tests/components/combination/validate-deprecated.esp32-idf.yaml diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index 34e9b99ce8..608ebac4d6 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/tests/components/combination/validate-deprecated.esp32-idf.yaml b/tests/components/combination/validate-deprecated.esp32-idf.yaml new file mode 100644 index 0000000000..829d76445d --- /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 From 9d6b50320d09ecd275181abe37c6cb6f6357060e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 00:48:08 -1000 Subject: [PATCH 13/27] [noise] Bump noise-c to 0.1.31 and libsodium to 1.10021.12 (#20345) --- esphome/components/noise/__init__.py | 4 ++-- esphome/idf_component.yml | 4 ++-- platformio.ini | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index d9880fe6f9..66043236b1 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -13,8 +13,8 @@ CODEOWNERS = ["@esphome/core"] # Keep in sync with platformio.ini and esphome/idf_component.yml. # LIBSODIUM_VERSION must match the version noise-c pins in its manifests. -NOISE_C_VERSION = "0.1.30" -LIBSODIUM_VERSION = "1.10021.11" +NOISE_C_VERSION = "0.1.31" +LIBSODIUM_VERSION = "1.10021.12" noise_ns = cg.esphome_ns.namespace("noise") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f532e26249..6bae823acb 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -113,11 +113,11 @@ dependencies: # noise (api, ota). Arduino below IDF 6.0 bundles espressif/libsodium, which # clashes with esphome/libsodium, so those builds use the PlatformIO libraries. esphome/noise-c: - version: 0.1.30 + version: 0.1.31 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" # Declared alongside noise-c so the library converter skips wireguard's copy esphome/libsodium: - version: 1.10021.11 + version: 1.10021.12 rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0 || idf_version >= 6.0.0" diff --git a/platformio.ini b/platformio.ini index 82463b8953..56a12d8b31 100644 --- a/platformio.ini +++ b/platformio.ini @@ -79,7 +79,7 @@ lib_deps = ; noise (api, ota) -- on the ESP-IDF framework these come from the ; component registry instead (see esphome/idf_component.yml), so they ; are not in [common]. - esphome/noise-c@0.1.30 ; noise (api, ota) + esphome/noise-c@0.1.31 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) heman/AsyncMqttClient-esphome@1.0.0 ; mqtt @@ -247,7 +247,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base esphome/wireguard@0.4.8 ; wireguard - esphome/noise-c@0.1.30 ; noise (api, ota) + esphome/noise-c@0.1.31 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -645,7 +645,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.30 ; used by noise (api, ota) + esphome/noise-c@0.1.31 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} From f5c15f66a583548b5423baa4cba5e00c4b7907bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:06:45 -1000 Subject: [PATCH 14/27] [esp8266] Force-include the throw stubs into the core and libraries on the native toolchain (#20336) --- esphome/build_gen/arduino8266.py | 67 ++++++++++++------- esphome/build_helpers/ninja_gen.py | 6 +- esphome/components/esp8266/__init__.py | 9 ++- esphome/components/esp8266/const.py | 2 + esphome/components/esp8266/throw_stubs.h | 3 +- .../unit_tests/build_gen/test_arduino8266.py | 45 +++++++++++-- 6 files changed, 93 insertions(+), 39 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 5b14af5a1d..44369136a0 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -54,6 +54,7 @@ from esphome.components.esp8266.const import ( KEY_ESP8266, KEY_FLASH_SIZE, KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, ) from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_project_cxx_compile_flags @@ -1087,32 +1088,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: core_exclude |= _CORE_EXCLUDE_WAVEFORM - archives = [] - # variant_dir existence was already enforced with the include dirs - variant_sources = collect_sources(variant_dir) - if variant_sources: - objs = compile_edges(lines, variant_sources, variant_dir, "variant") - lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") - archives.append("libFrameworkArduinoVariant.a") - - core_objs = compile_edges( - lines, collect_sources(core_dir, core_exclude), core_dir, "core" - ) - if not core_objs: - # An empty archive would link into a wall of undefined references - # (app_entry, the exception vectors) far from the cause - raise EsphomeError( - f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" - ) - lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") - archives.append("libFrameworkArduino.a") - - lib_archives, direct_objs = library_edges(lines, libraries) - archives += lib_archives - - # One source of truth with the PlatformIO path: esp8266/__init__ pins - # build_src_flags (the throw_stubs force-include); -include paths - # resolve against the source root + # esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on + # the PlatformIO path; -include paths resolve against the source root src_other: list[str] = [] src_includes: list[str] = [] src_it = iter( @@ -1131,7 +1108,45 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: src_includes.append(tok[len("-include") :]) else: src_other.append(_shell_token(tok)) + # The throw stubs abort: none anywhere with exceptions on, else the core and + # libraries get them too (src keeps them after its pch include) + if config.exceptions: + src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER] include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes] + framework_flags = "" + if THROW_STUBS_HEADER in src_includes: + stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)] + lines.append(f"frameworkflags = {stubs_flag}") + framework_flags = "$frameworkflags" + archives = [] + # variant_dir existence was already enforced with the include dirs + variant_sources = collect_sources(variant_dir) + if variant_sources: + objs = compile_edges( + lines, variant_sources, variant_dir, "variant", flags=framework_flags + ) + lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") + archives.append("libFrameworkArduinoVariant.a") + + core_objs = compile_edges( + lines, + collect_sources(core_dir, core_exclude), + core_dir, + "core", + flags=framework_flags, + ) + if not core_objs: + # An empty archive would link into a wall of undefined references + # (app_entry, the exception vectors) far from the cause + raise EsphomeError( + f"{_INCOMPLETE_INSTALL}: no core sources in {core_dir}; {_CLEAN_HINT}" + ) + lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}") + archives.append("libFrameworkArduino.a") + + lib_archives, direct_objs = library_edges(lines, libraries, framework_flags) + archives += lib_archives + # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) lines.append(f"srcflags = {' '.join(src_other + include_flags)}") diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index 8e72b36b3f..a2ee244a29 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -207,10 +207,12 @@ def compile_edges( def library_edges( - lines: list[str], libraries: list[ArduinoLibrary] + lines: list[str], libraries: list[ArduinoLibrary], extra_flags: str = "" ) -> tuple[list[str], list[str]]: """Emit every library's compile and archive edges. + ``extra_flags`` (raw ninja text) precede each library's own flags. + Returns the archive names and the objects that link directly. A library's own include dirs lead its compile lines, as PlatformIO searched them first: the include path is one global list, so another library's @@ -232,7 +234,7 @@ def library_edges( lib.sources, common_parent(lib.sources), f"lib/{lib.name}", - flags=" ".join(_shell_token(f) for f in lib.flags), + flags=" ".join(filter(None, [extra_flags, *map(_shell_token, lib.flags)])), own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs), ) if not lib.lib_archive: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d0d870c289..57fac2dc98 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,6 +53,7 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + THROW_STUBS_HEADER, enable_serial, enable_serial1, esp8266_ns, @@ -477,11 +478,9 @@ async def to_code(config: ConfigType) -> None: # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h. Unconditional: the native build generator reads - # the same option, keeping one source of truth. - cg.add_platformio_option( - "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" - ) + # See throw_stubs.h. The native build generator reads this option, also + # passes it to the core and libraries, and drops it with exceptions on. + cg.add_platformio_option("build_src_flags", f"-include {THROW_STUBS_HEADER}") # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index bf4dd1a358..fb50b6a5ca 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -17,6 +17,8 @@ KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator KEY_SCANF_FLOAT = "scanf_float" +# Force-included std::__throw_* overrides, relative to the src dir +THROW_STUBS_HEADER = "esphome/components/esp8266/throw_stubs.h" # Per-board flash-layout override consumed by board_ld_script() KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h index a650935a5e..c18814d4d8 100644 --- a/esphome/components/esp8266/throw_stubs.h +++ b/esphome/components/esp8266/throw_stubs.h @@ -15,7 +15,8 @@ * unused, dead-strip the load, and drop the string entirely -- no LTO needed. * Behavior is identical to today: a bare abort() (the message was never * printed). This header MUST be force-included before , so it is - * wired up via build_src_flags "-include ..." in this component's __init__.py. + * wired up via build_src_flags "-include ..." in this component's __init__.py; + * the native toolchain also force-includes it into the core and libraries. * * Note: this defines functions in namespace std (technically UB). It is safe * here because the definitions match the existing abort() behavior exactly. diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 066746d36d..2ea4ffabe6 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -34,7 +34,12 @@ from esphome.components.esp8266.boards import ( board_ld_script, ) from esphome.components.esp8266.build_surgery import RATETABLE_RULE -from esphome.components.esp8266.const import KEY_BOARD, KEY_ESP8266, KEY_SCANF_FLOAT +from esphome.components.esp8266.const import ( + KEY_BOARD, + KEY_ESP8266, + KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, +) import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -360,18 +365,22 @@ def test_write_project_link_line_and_exclusions(tmp_path: Path) -> None: # Assembly and C sources compile through their own rules assert "cont.S.o: aspp" in content assert "abi.c.o: c" in content - # throw_stubs is force-included for ESPHome sources only, via one shared - # srcflags variable rather than a copy of the flags line per edge + # throw_stubs reaches src through srcflags (after the pch include) and the + # core through frameworkflags: one shared variable each, not a copy per edge src_lines = [line for line in content.splitlines() if "obj/src/" in line] assert any("main.cpp.o: cxx" in line for line in src_lines) - assert content.count("throw_stubs.h") == 1 + assert content.count("throw_stubs.h") == 2 assert "srcflags = -include" in content flags_lines = [ line for line in content.splitlines() if line.startswith(" flags = ") ] assert flags_lines # C++ src edges consume the precompiled header; C/assembly keep srcflags - assert set(flags_lines) == {" flags = $srcflags", " flags = $srccxxflags"} + assert set(flags_lines) == { + " flags = $srcflags", + " flags = $srccxxflags", + " flags = $frameworkflags", + } def test_write_project_pch(tmp_path: Path) -> None: @@ -715,6 +724,8 @@ def test_write_project_libraries_and_variant( assert "libHeadersOnly.a" not in content assert "Library HeadersOnly has no source files" in caplog.text assert " flags = -DMYLIB=1" in content + # With exceptions on, nothing gets the stubs + assert "throw_stubs.h" not in content # A library's own include dirs lead its compile lines assert " own_includes = -I" in content assert "$own_includes $cxxflags $flags" in content @@ -729,6 +740,30 @@ def test_write_project_libraries_and_variant( assert f"ccache = {_shq('/cc/ccache')}" in content +def test_write_project_throw_stubs_reach_core_and_libraries(tmp_path: Path) -> None: + """Without exceptions the core and libraries take the throw stubs too.""" + paths = _make_framework(tmp_path) + lib_dir = tmp_path / "libsrc" + lib_dir.mkdir() + (lib_dir / "lib.cpp").write_text("") + library = ArduinoLibrary( + name="MyLib", + sources=[lib_dir / "lib.cpp"], + include_dirs=[lib_dir], + flags=["-DMYLIB=1"], + ) + content = _write_ninja(paths, libraries=[library]) + stubs = CORE.relative_src_path() / THROW_STUBS_HEADER + assert f"frameworkflags = -include {_shq(str(stubs))}" in content + # Before the library's own flags; core edges take the variable alone + assert " flags = $frameworkflags -DMYLIB=1" in content + lines = content.splitlines() + core_edge = next( + i for i, line in enumerate(lines) if "core_esp8266_main.cpp.o: cxx" in line + ) + assert lines[core_edge + 1] == " flags = $frameworkflags" + + def test_get_flash_ld_path(tmp_path: Path) -> None: paths = InstalledPaths( From 686252f151359a38711401366949e6e80a044ff1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 06:09:39 -1000 Subject: [PATCH 15/27] [esp8266] Fix Arduino core regressions that keep data in RAM and link the unused unwinder (#20337) --- esphome/arduino8266/framework.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py index b731b74675..48fd72d1da 100644 --- a/esphome/arduino8266/framework.py +++ b/esphome/arduino8266/framework.py @@ -49,9 +49,9 @@ class FrameworkRelease(NamedTuple): # Arduino core version -> its build in esphome-libs/arduino-esp8266 FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = { Version(3, 1, 2): FrameworkRelease( - "3.1.2-esphome.1", - "e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582", - 37189311, + "3.1.2-esphome.2", + "9377d63f569c524823199b1897ed7fde2063e50e2676dd519a0d758ab73678f7", + 37192128, ), } From 1d86cc39e99722153c58e87cbb0af230401e00b6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 12:33:46 -0400 Subject: [PATCH 16/27] [sendspin] Update to sendspin-cpp v0.9.3 (#20347) --- esphome/components/sendspin/__init__.py | 16 +- esphome/components/sendspin/image/__init__.py | 16 +- .../sendspin/image/sendspin_image.cpp | 4 +- .../sendspin/image/sendspin_image.h | 4 +- .../media_player/sendspin_media_player.cpp | 2 +- .../media_player/sendspin_media_player.h | 2 +- .../sendspin/media_source/__init__.py | 7 + .../media_source/sendspin_media_source.cpp | 14 +- .../media_source/sendspin_media_source.h | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 232 +++++++++++++----- esphome/components/sendspin/sendspin_hub.h | 63 ++--- .../sendspin/sensor/sendspin_sensor.cpp | 2 +- .../sendspin/sensor/sendspin_sensor.h | 2 +- .../sendspin/switch/sendspin_switch.cpp | 4 +- .../sendspin/switch/sendspin_switch.h | 4 +- .../text_sensor/sendspin_text_sensor.cpp | 2 +- .../text_sensor/sendspin_text_sensor.h | 2 +- esphome/idf_component.yml | 5 +- .../sendspin/test_framework.py | 27 ++ tests/component_tests/sendspin/test_image.py | 8 + .../sendspin/test_media_source.py | 10 + 21 files changed, 303 insertions(+), 125 deletions(-) create mode 100644 tests/component_tests/sendspin/test_framework.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 42dae464ed..2fb8d4a17e 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -76,7 +76,6 @@ OPUS_SAMPLE_RATE = 48000 SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") -IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") @@ -211,6 +210,8 @@ CONFIG_SCHEMA = cv.All( } ), cv.only_on_esp32, + # sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 cannot use. + cv.only_with_framework("esp-idf"), _request_high_performance_networking, ) @@ -265,7 +266,9 @@ async def to_code(config: ConfigType) -> None: cg.add(setter(value)) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") + # esp_websocket_client links esp_tls even for ws:// connections. + esp32.request_tls() cg.add_define("USE_SENDSPIN", True) # for MDNS @@ -276,8 +279,9 @@ async def to_code(config: ConfigType) -> None: data = _get_data() - # The color role is not yet wired up in ESPHome; disable it in the library for now. + # The color and source roles are not yet wired up in ESPHome; disable them in the library for now. esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False) + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False) # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). @@ -351,7 +355,8 @@ async def to_code(config: ConfigType) -> None: ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), - ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), + # The released YAML key name is kept for compatibility. + ("initial_output_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), ("psram_stack", psram_stack), ] if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None: @@ -366,6 +371,9 @@ async def to_code(config: ConfigType) -> None: else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) + if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False) + if data.visualizer_support: cg.add_define("USE_SENDSPIN_VISUALIZER", True) else: diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index e26e8204aa..a5336d5e05 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -1,5 +1,7 @@ """Sendspin image platform.""" +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image @@ -21,7 +23,6 @@ from esphome.types import ConfigType from .. import ( CONF_DISPLAY_OFFSET, CONF_SENDSPIN_ID, - IMAGE_FORMAT_BMP, IMAGE_FORMAT_JPEG, IMAGE_FORMAT_PNG, IMAGE_SOURCE_ALBUM, @@ -57,9 +58,16 @@ _FORMAT_TO_SENDSPIN_ENUM = { "JPEG": IMAGE_FORMAT_JPEG, "JPG": IMAGE_FORMAT_JPEG, "PNG": IMAGE_FORMAT_PNG, - "BMP": IMAGE_FORMAT_BMP, } + +# Remove before 2027.4.0 +def _reject_bmp(value: Any) -> Any: + if isinstance(value, str) and value.upper() == "BMP": + raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.") + return value + + # The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it # would never receive artwork while still paying for two frame buffers, so it is not offered here. IMAGE_SOURCES = { @@ -121,7 +129,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), # Narrow runtime_image's format list to what the library can request, so the # accepted set and the enum map below cannot drift apart. - cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_FORMAT): cv.All( + _reject_bmp, cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True) + ), cv.Required(CONF_RESIZE): cv.dimensions, cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 558a292d5b..8605048f33 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -1,6 +1,6 @@ #include "sendspin_image.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/core/log.h" @@ -125,7 +125,7 @@ bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const } // A decode that asked for other dimensions had the buffer taken away from it, so it painted - // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // nothing (or stopped partway). JPEG reports that as an error above; PNG carries on // regardless, so the frame is dropped here. return this->decode_sink_.decoded_into(target); } diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h index 2f6f4e8a4d..43dd4ed66b 100644 --- a/esphome/components/sendspin/image/sendspin_image.h +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/components/image/image.h" #include "esphome/components/runtime_image/runtime_image.h" @@ -29,7 +29,7 @@ class ArtworkDecodeSink : public runtime_image::RuntimeImage { /// @brief True when the decode ended with the given buffer still in place. /// /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions - /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// leaves the sink holding nothing. The JPEG decoder reports that as a decode error, but /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } }; diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index 59ead1bb53..ec5d5b2cbc 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_player.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/core/application.h" #include "esphome/core/log.h" diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index ff76473189..bc07254efe 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/components/media_player/media_player.h" #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 743d0bb62c..31c454fb71 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -11,7 +11,9 @@ from esphome.const import ( from esphome.types import ConfigType from .. import ( + CODEC_FLAC, CODEC_OPUS, + CODEC_PCM, CODECS, CONF_CODECS, CONF_DECODE_MEMORY, @@ -58,6 +60,11 @@ def _resolve_codecs(config: ConfigType) -> ConfigType: f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", path=[CONF_CODECS], ) + if CODEC_FLAC not in codecs and CODEC_PCM not in codecs: + raise cv.Invalid( + f"Codecs must include '{CODEC_FLAC}' or '{CODEC_PCM}'", + path=[CONF_CODECS], + ) return config diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index c3fb1fe1cb..f1706a260b 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_source.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/audio/audio.h" #include "esphome/core/log.h" @@ -24,7 +24,7 @@ void SendspinMediaSource::setup() { // Push cached states to player role. They may have been set before setup() ran. this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f)); this->player_role_->update_muted(this->cached_muted_); - this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_); + this->player_role_->set_output_delay_adjustable(this->static_delay_adjustable_); } void SendspinMediaSource::dump_config() { @@ -35,7 +35,7 @@ void SendspinMediaSource::dump_config() { void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) { this->static_delay_adjustable_ = adjustable; if (this->player_role_) { - this->player_role_->set_static_delay_adjustable(adjustable); + this->player_role_->set_output_delay_adjustable(adjustable); } } @@ -102,8 +102,8 @@ void SendspinMediaSource::handle_command(media_source::MediaSourceCommand comman if (!this->pending_start_) { // Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before // play_uri - ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE"); - this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE); + ESP_LOGD(TAG, "Received STOP command, leaving the Sendspin group"); + this->parent_->leave_group(); } break; } @@ -185,8 +185,6 @@ size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_ // THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) void SendspinMediaSource::on_stream_start() { - this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - if (!this->pending_start_) { // Dedup rapid on_stream_start() calls this->pending_start_ = true; @@ -211,4 +209,4 @@ void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(mute } // namespace esphome::sendspin_ -#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER +#endif // USE_ESP_IDF && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 1c5cb625bf..f0800dbcb1 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 15d358d50f..0348850225 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -1,6 +1,6 @@ #include "sendspin_hub.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/components/network/util.h" #ifdef USE_ETHERNET @@ -17,26 +17,98 @@ #include +#include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +namespace { + +// Zeroes through a volatile pointer so dead-store elimination cannot drop it. +void secure_wipe(void *data, size_t len) { + volatile auto *p = static_cast(data); + for (size_t i = 0; i < len; ++i) { + p[i] = 0; + } +} + +// Call once per key, from setup(). +ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) { + return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name)); +} + +// Loads straight into the returned buffer, which the library wipes after use. A failed load can +// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers +// this buffer: the preference backend keeps its own copy of a pending write until it syncs. +std::optional> read_blob(ESPPreferenceObject &pref, size_t size) { + std::vector out(size); + if (!pref.load(out.data(), out.size())) { + secure_wipe(out.data(), out.size()); + return std::nullopt; + } + return out; +} + +// The library always writes a key's fixed size; any other length is misuse and is not stored. +bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) { + if (len != size) { + ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size); + return false; + } + if (!pref.save(data, len)) { + ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len); + return false; + } + return true; +} + +// "rec_" to n; nullopt for any other key or a slot with no storage here. +std::optional parse_record_slot(const std::string &key) { + const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX; + const size_t prefix_len = std::strlen(prefix); + if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) { + return std::nullopt; + } + size_t slot = 0; + for (size_t i = prefix_len; i < key.size(); i++) { + const char c = key[i]; + if (c < '0' || c > '9') { + return std::nullopt; + } + slot = slot * 10 + static_cast(c - '0'); + if (slot >= SENDSPIN_RECORD_SLOTS) { + return std::nullopt; + } + } + return slot; +} + +} // namespace + #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; -static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"}; #endif void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); - // Set up persistence (preferences must be initialized before providers are added to the client) - this->last_played_server_pref_ = - global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); -#ifdef USE_SENDSPIN_PLAYER - this->static_delay_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_static_delay")); -#endif + // Set up persistence (preferences must be initialized before providers are added to the client). These key names + // are frozen; see PREFERENCE KEYS in sendspin_hub.h. + namespace keys = sendspin::persistence_keys; + this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE); + this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE); + this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE); + // Released firmware's name, so a calibrated delay survives. + this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE); + for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) { + this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE); + } + this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS); // Wire providers and client listener this->client_->set_listener(this); @@ -62,6 +134,9 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif + // Any server may play without pairing, as before rc1. + this->client_->set_unpaired_access_enabled(true); + #ifndef USE_SENDSPIN_SWITCH this->enabled_ = true; #endif @@ -83,17 +158,35 @@ void SendspinHub::loop() { #endif } +// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up +// before this hub, so it shuts down after it and the goodbyes can still go out. +void SendspinHub::on_shutdown() { + // Keeps loop() from starting the client again. + this->enabled_ = false; + if (this->is_client_running()) { + this->client_->stop(); + } +} + void SendspinHub::dump_config() { + // client_id exists only once start() has run. + const char *client_id = "(unavailable)"; + if (this->client_ != nullptr && !this->client_->client_id().empty()) { + client_id = this->client_->client_id().c_str(); + } char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " MAC address: %s\n" " Manufacturer: %s\n" " Model: %s\n" " Firmware version: %s\n" - " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), - this->firmware_version_, YESNO(this->task_stack_in_psram_)); + " Task stack in PSRAM: %s\n" + " Unpaired access: %s", + client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -154,22 +247,33 @@ void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) } // THREAD CONTEXT: Main loop (invoked from Sendspin components) -void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_client_running()) { - this->client_->update_state(state); +void SendspinHub::leave_group() { + if (this->is_client_running() && + this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) { + this->client_->leave(); } } -const char *SendspinHub::get_client_id_into_buffer(std::span buf) { - // The server matches client_id against the L2 source MAC of the device's multicast traffic. +const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { + // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the // eFuse base MAC when ethernet is the active interface. #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) { - return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + } else { + get_mac_address_pretty_into_buffer(buf); } +#else + get_mac_address_pretty_into_buffer(buf); #endif - return get_mac_address_pretty_into_buffer(buf); + // The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase. + for (char &c : buf) { + if (c >= 'A' && c <= 'F') { + c += 'a' - 'A'; + } + } + return buf.data(); } const char *SendspinHub::get_product_name_() const { @@ -180,12 +284,14 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); + config.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf); config.name = App.get_friendly_name(); config.product_name = this->get_product_name_(); config.manufacturer = this->manufacturer_; config.software_version = this->firmware_version_; config.httpd_psram_stack = this->task_stack_in_psram_; + config.protocol_task_psram_stack = this->task_stack_in_psram_; + config.max_pairing_records = SENDSPIN_RECORD_SLOTS; return config; } @@ -217,33 +323,56 @@ void SendspinHub::on_release_high_performance() { // --- SendspinNetworkProvider override --- -// THREAD CONTEXT: Main loop (polled by client_->loop()) +// THREAD CONTEXT: Main loop (polled by start() and client_->loop()) bool SendspinHub::is_network_ready() { return network::is_connected(); } // --- SendspinPersistenceProvider overrides --- +// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop()) -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -bool SendspinHub::save_last_server_hash(uint32_t hash) { - LastPlayedServerPref pref{.server_id_hash = hash}; - bool ok = this->last_played_server_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); - } else { - ESP_LOGW(TAG, "Failed to persist last played server hash"); +std::pair SendspinHub::pref_for_key_(const std::string &key) { + namespace keys = sendspin::persistence_keys; + if (auto slot = parse_record_slot(key); slot.has_value()) { + return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE}; } - return ok; + if (key == keys::RECORD_ORDER) { + return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS}; + } + if (key == keys::KEYPAIR) { + return {&this->keypair_pref_, keys::KEYPAIR_SIZE}; + } + if (key == keys::PAIRING_PSK) { + return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE}; + } + if (key == keys::LAST_PLAYED) { + return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE}; + } + if (key == keys::OUTPUT_DELAY) { + return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE}; + } + return {nullptr, 0}; } -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -std::optional SendspinHub::load_last_server_hash() { - LastPlayedServerPref pref{}; - if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); - return pref.server_id_hash; +std::optional> SendspinHub::load_blob(const std::string &key) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str()); + return std::nullopt; } - return std::nullopt; + return read_blob(*pref, size); } +bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str()); + return false; + } + return write_blob(*pref, key.c_str(), size, data, len); +} + +// Writes every component's queued preferences, so a failure may belong to another component. +bool SendspinHub::commit() { return global_preferences->sync(); } + // --- Sendspin role specific methods/overrides --- #ifdef USE_SENDSPIN_ARTWORK @@ -285,11 +414,7 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman } // THREAD CONTEXT: Main loop (invoked from the sendspin.switch action) -void SendspinHub::switch_client() { - // Clear any EXTERNAL_SOURCE state so the switch command is followed - this->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); -} +void SendspinHub::switch_client() { this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); } // THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { @@ -332,31 +457,8 @@ sendspin::PlayerRole *SendspinHub::get_player_role() { } return nullptr; } - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -bool SendspinHub::save_static_delay(uint16_t delay_ms) { - StaticDelayPref pref{.delay_ms = delay_ms}; - bool ok = this->static_delay_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms); - } else { - ESP_LOGW(TAG, "Failed to persist static delay"); - } - return ok; -} - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -std::optional SendspinHub::load_static_delay() { - StaticDelayPref pref{}; - if (this->static_delay_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms); - return pref.delay_ms; - } - return std::nullopt; -} - #endif } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index ea09dafe68..43c56feca7 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/core/automation.h" #include "esphome/core/component.h" @@ -31,9 +31,15 @@ #include #endif +#include +#include +#include #include #include #include +#include +#include +#include namespace esphome::sendspin_ { @@ -44,22 +50,16 @@ namespace esphome::sendspin_ { /// one step later than hub so they can assume hub's setup() has already completed. namespace sendspin_priority { // AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active -// interface's MAC for client_id. +// interface's MAC address for the device info. inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI; inline constexpr float CHILD = HUB - 1.0f; } // namespace sendspin_priority -/// @brief Persistent storage structure for last played server hash. -struct LastPlayedServerPref { - uint32_t server_id_hash; -}; +// PREFERENCE KEYS: each library persistence key has its own preference, hashed from "sendspin_" + name. Renaming +// one erases it. -#ifdef USE_SENDSPIN_PLAYER -/// @brief Persistent storage structure for player static delay. -struct StaticDelayPref { - uint16_t delay_ms; -}; -#endif +/// Pairing-record slots, handed to the client as max_pairing_records. +inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; /// @brief Thin adapter over sendspin::SendspinClient. /// @@ -93,6 +93,7 @@ class SendspinHub final : public Component, float get_setup_priority() const override { return sendspin_priority::HUB; } void setup() override; void loop() override; + void on_shutdown() override; void dump_config() override; /// @brief Connects the underlying client to the given Sendspin server. @@ -113,14 +114,11 @@ class SendspinHub final : public Component, /// - `USER_REQUEST`: user explicitly requested disconnect. void disconnect_from_server(sendspin::SendspinGoodbyeReason reason); - /// @brief Updates the client's reported playback state on the server. + /// @brief Leaves the current group so another source can use the speaker. /// + /// Sends `client/leave` while the group is playing. A stopped group is left alone, so the device stays grouped. /// No-op if the hub's client is not running. Must be called from the main loop thread. - /// @param state New client state: - /// - `SYNCHRONIZED`: client is synchronized and playing from the server. - /// - `ERROR`: client encountered a playback error. - /// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source. - void update_state(sendspin::SendspinClientState state); + void leave_group(); // --- Configuration setters (called from codegen) --- @@ -219,12 +217,16 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// The preference a library key is stored in and its blob size, or {nullptr, 0} for a key with + /// no storage here. + std::pair pref_for_key_(const std::string &key); + /// @brief Returns the product name reported to the server: the configured model, or the device name. const char *get_product_name_() const; - /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. + /// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). - static const char *get_client_id_into_buffer(std::span buf); + static const char *get_mac_address_into_buffer(std::span buf); #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. @@ -242,8 +244,10 @@ class SendspinHub final : public Component, bool is_network_ready() override; // --- SendspinPersistenceProvider overrides --- - bool save_last_server_hash(uint32_t hash) override; - std::optional load_last_server_hash() override; + // The library calls commit() after writing pairing secrets, so they reach flash right away. + std::optional> load_blob(const std::string &key) override; + bool save_blob(const std::string &key, const uint8_t *data, size_t len) override; + bool commit() override; // --- Sendspin role specific methods/overrides/member variables --- @@ -291,16 +295,17 @@ class SendspinHub final : public Component, #ifdef USE_SENDSPIN_PLAYER sendspin::PlayerRoleListener *player_listener_{nullptr}; sendspin::PlayerRoleConfig player_config_{}; - - // Part of SendspinPersistenceProvider overrides - ESPPreferenceObject static_delay_pref_; - std::optional load_static_delay() override; - bool save_static_delay(uint16_t delay_ms) override; #endif // --- Core member variables --- - ESPPreferenceObject last_played_server_pref_; + // Built once in setup(): make_preference() allocates a backend that is never freed. + ESPPreferenceObject keypair_pref_; + ESPPreferenceObject pairing_psk_pref_; + ESPPreferenceObject last_played_pref_; + ESPPreferenceObject output_delay_pref_; + std::array record_slot_prefs_; + ESPPreferenceObject record_order_pref_; std::unique_ptr client_; @@ -349,4 +354,4 @@ class SendspinPollingChild : public PollingComponent, public Parented diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index 5b29fff55f..77bb835598 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/sensor/sensor.h" diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index 0bf029d4c7..af4d053854 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,6 +1,6 @@ #include "sendspin_switch.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/core/log.h" @@ -23,4 +23,4 @@ void SendspinSwitch::write_state(bool state) { } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index 253d952b22..f9173c362d 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -21,4 +21,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 554e01cf88..69dd32e785 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -1,6 +1,6 @@ #include "sendspin_text_sensor.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) #include diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index d38f360d94..9fe1c9381e 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6bae823acb..ed1202fdb9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -101,8 +101,11 @@ dependencies: - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 + # sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below) sendspin/sendspin-cpp: - version: 0.8.0 + version: 0.9.3 + rules: + - if: "$ESPHOME_ARDUINO_COMPONENT == 0" lvgl/lvgl: version: 9.5.0 fastled/FastLED: diff --git a/tests/component_tests/sendspin/test_framework.py b/tests/component_tests/sendspin/test_framework.py new file mode 100644 index 0000000000..0add0d0f0a --- /dev/null +++ b/tests/component_tests/sendspin/test_framework.py @@ -0,0 +1,27 @@ +"""Validation tests for the frameworks the sendspin hub builds on. + +sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 +cannot use, so the hub is ESP-IDF only. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +HUB_CONFIG = {"id": "sendspin_hub_id"} + + +def test_arduino_rejected(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_ARDUINO) + + with pytest.raises(cv.Invalid, match="esp-idf"): + CONFIG_SCHEMA(HUB_CONFIG) + + +def test_esp_idf_accepted(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + assert CONFIG_SCHEMA(HUB_CONFIG)["id"].id == "sendspin_hub_id" diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py index be3b7d6684..dc9e4978c6 100644 --- a/tests/component_tests/sendspin/test_image.py +++ b/tests/component_tests/sendspin/test_image.py @@ -53,6 +53,14 @@ def test_jpeg_alias_maps_to_one_enum( assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG +def test_bmp_rejected(set_core_config: SetCoreConfigCallable) -> None: + """The Sendspin protocol only allows JPEG and PNG artwork.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="BMP artwork is no longer supported"): + CONFIG_SCHEMA(_slot_config(format="BMP")) + + def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" set_core_config(PlatformFramework.ESP32_IDF) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py index 6c2f79198d..db576b37af 100644 --- a/tests/component_tests/sendspin/test_media_source.py +++ b/tests/component_tests/sendspin/test_media_source.py @@ -88,3 +88,13 @@ def test_opus_at_wrong_sample_rate_rejected( with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) + + +def test_opus_only_codec_list_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Every server supports FLAC or PCM, so sendspin-cpp requires one of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="must include 'flac' or 'pcm'"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"])) From eea11834f85e8f776ba1a1aa6999b1e14de00eb1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 13:19:14 -0400 Subject: [PATCH 17/27] [sendspin] Add the static pairing code, pairing window and unpaired access option (#20348) --- esphome/components/sendspin/__init__.py | 119 ++++++++++++++++-- esphome/components/sendspin/sendspin_hub.cpp | 56 ++++++++- esphome/components/sendspin/sendspin_hub.h | 50 ++++++++ .../component_tests/sendspin/test_pairing.py | 77 ++++++++++++ .../sendspin/test_unpaired_access.py | 75 +++++++++++ tests/components/sendspin/common-hub.yaml | 24 ++++ tests/components/sendspin/common-pairing.yaml | 14 +++ .../sendspin/test-pairing.esp32-idf.yaml | 2 + 8 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/sendspin/test_pairing.py create mode 100644 tests/component_tests/sendspin/test_unpaired_access.py create mode 100644 tests/components/sendspin/common-pairing.yaml create mode 100644 tests/components/sendspin/test-pairing.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 2fb8d4a17e..fcc53dde4b 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,6 @@ from dataclasses import dataclass, field +import logging +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,6 +27,8 @@ from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + # mdns for autodiscovery AUTO_LOAD = ["mdns"] CODEOWNERS = ["@kahrendt"] @@ -46,6 +50,31 @@ CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" CONF_CODECS = "codecs" +CONF_STATIC_PAIRING_CODE = "static_pairing_code" +CONF_UNPAIRED_ACCESS = "unpaired_access" +CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" +CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" +CONF_ON_PAIRING_FAILED = "on_pairing_failed" + +# A static pairing code is exactly 8 decimal digits. +STATIC_PAIRING_CODE_DIGITS = 8 + + +def _validate_static_pairing_code(value: Any) -> str: + # string_strict so leading zeros survive and `!secret` works. + value = cv.string_strict(value) + if len(value) != STATIC_PAIRING_CODE_DIGITS or not ( + value.isascii() and value.isdigit() + ): + raise cv.Invalid( + f"{CONF_STATIC_PAIRING_CODE} must be exactly " + f"{STATIC_PAIRING_CODE_DIGITS} decimal digits " + '(quote the value so leading zeros are preserved, e.g. "01234567")' + ) + return value + + # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -108,6 +137,26 @@ SendspinHub = sendspin_ns.class_( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN_PAIRING_WINDOW, "add_on_open_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_SUCCEEDED, + "add_on_pairing_succeeded_callback", + [(cg.std_string, "server_id")], + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_FAILED, + "add_on_pairing_failed_callback", + [(cg.std_string, "server_id"), (cg.StringRef, "reason")], + ), +) + + @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -207,6 +256,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, + cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( + _validate_static_pairing_code + ), + cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, + cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), + cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } ), cv.only_on_esp32, @@ -216,21 +277,42 @@ CONFIG_SCHEMA = cv.All( ) +def _has_pairing_method(config: ConfigType) -> bool: + """Whether the config gives a server any way to pair with the device.""" + return CONF_STATIC_PAIRING_CODE in config + + +def _final_validate(config: ConfigType) -> ConfigType: + if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): + _LOGGER.warning( + "'%s' is off but nothing lets a server pair (%s), so no server can play on this " + "device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _request_controller_role(config: ConfigType) -> ConfigType: """Request the controller role for the sendspin.switch action.""" request_controller_support() return config +# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. +SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) +) + SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( - automation.maybe_simple_id( - cv.Schema( - { - cv.GenerateID(): cv.use_id(SendspinHub), - } - ) - ), - _request_controller_role, + SENDSPIN_HUB_ACTION_SCHEMA, _request_controller_role ) @@ -241,6 +323,19 @@ automation.register_apply_action( ) +automation.register_apply_action( + "sendspin.confirm_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("confirm_pairing_window()"), +) + +automation.register_apply_action( + "sendspin.cancel_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("cancel_pairing_window()"), +) + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -265,6 +360,14 @@ async def to_code(config: ConfigType) -> None: if value: cg.add(setter(value)) + if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: + cg.add(var.set_static_pairing_code(code)) + + if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: + cg.add(var.set_default_unpaired_access(unpaired_access)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") # esp_websocket_client links esp_tls even for ws:// connections. diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 0348850225..927e0e49ad 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -26,6 +26,26 @@ static const char *const TAG = "sendspin.hub"; namespace { +// The reason passed to on_pairing_failed automations, in the protocol's own spelling. +StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) { + using sendspin::SendspinPairAbortReason; + switch (reason) { + case SendspinPairAbortReason::ATTEMPT_TIMEOUT: + return StringRef::from_lit("attempt_timeout"); + case SendspinPairAbortReason::CONCURRENT_ATTEMPT: + return StringRef::from_lit("concurrent_attempt"); + case SendspinPairAbortReason::METHOD_NOT_SUPPORTED: + return StringRef::from_lit("method_not_supported"); + case SendspinPairAbortReason::PAIRING_CODE_MISMATCH: + return StringRef::from_lit("pairing_code_mismatch"); + case SendspinPairAbortReason::USER_CANCELLED: + return StringRef::from_lit("user_cancelled"); + case SendspinPairAbortReason::UNKNOWN: + break; + } + return StringRef::from_lit("unknown"); +} + // Zeroes through a volatile pointer so dead-store elimination cannot drop it. void secure_wipe(void *data, size_t len) { volatile auto *p = static_cast(data); @@ -134,8 +154,7 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - // Any server may play without pairing, as before rc1. - this->client_->set_unpaired_access_enabled(true); + this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); #ifndef USE_SENDSPIN_SWITCH this->enabled_ = true; @@ -183,9 +202,11 @@ void SendspinHub::dump_config() { " Model: %s\n" " Firmware version: %s\n" " Task stack in PSRAM: %s\n" + " Static pairing code: %s\n" " Unpaired access: %s", client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->static_pairing_code_ != nullptr), YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); #ifdef USE_SENDSPIN_ARTWORK @@ -254,6 +275,20 @@ void SendspinHub::leave_group() { } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.confirm_pairing_window action) +void SendspinHub::confirm_pairing_window() { + if (this->is_client_running()) { + this->client_->confirm_pairing_window(); + } +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.cancel_pairing_window action) +void SendspinHub::cancel_pairing_window() { + if (this->is_client_running()) { + this->client_->cancel_pairing_window(); + } +} + const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the @@ -293,6 +328,11 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { config.protocol_task_psram_stack = this->task_stack_in_psram_; config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + if (this->static_pairing_code_ != nullptr) { + config.static_pairing_code_locations = {"operator"}; + config.static_pairing_code = this->static_pairing_code_; + } + return config; } @@ -321,6 +361,18 @@ void SendspinHub::on_release_high_performance() { #endif } +void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks_.call(); } + +void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } + +void SendspinHub::on_pairing_succeeded(const std::string &server_id) { + this->pairing_succeeded_callbacks_.call(server_id); +} + +void SendspinHub::on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) { + this->pairing_failed_callbacks_.call(server_id, pair_abort_reason_to_string(reason)); +} + // --- SendspinNetworkProvider override --- // THREAD CONTEXT: Main loop (polled by start() and client_->loop()) diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 43c56feca7..7c3708c374 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "esphome/core/version.h" #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE @@ -120,12 +121,39 @@ class SendspinHub final : public Component, /// No-op if the hub's client is not running. Must be called from the main loop thread. void leave_group(); + /// @brief Confirms a pairing attempt on the device. With no attempt waiting, it opens the pairing window for the + /// next one. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void confirm_pairing_window(); + + /// @brief Closes an open pairing window, so a waiting pairing attempt is not confirmed. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void cancel_pairing_window(); + // --- Configuration setters (called from codegen) --- template void add_group_update_callback(F &&callback) { this->group_update_callbacks_.add(std::forward(callback)); } + template void add_on_open_pairing_window_callback(F &&callback) { + this->open_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_close_pairing_window_callback(F &&callback) { + this->close_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_succeeded_callback(F &&callback) { + this->pairing_succeeded_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_failed_callback(F &&callback) { + this->pairing_failed_callbacks_.add(std::forward(callback)); + } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or @@ -136,6 +164,9 @@ class SendspinHub final : public Component, /// called this once. Must be called from the main loop thread. void set_enabled(bool enabled); + /// Turns unpaired (Sentinel) access on or off from setup(); see SendspinClient::set_unpaired_access_enabled(). + void set_default_unpaired_access(bool enabled) { this->default_unpaired_access_ = enabled; } + /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -152,6 +183,9 @@ class SendspinHub final : public Component, void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } #endif + /// The static pairing code, used on every boot. + void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -240,6 +274,14 @@ class SendspinHub final : public Component, void on_release_high_performance() override; + void on_open_pairing_window() override; + + void on_close_pairing_window() override; + + void on_pairing_succeeded(const std::string &server_id) override; + + void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; + // --- SendspinNetworkProvider override --- bool is_network_ready() override; @@ -312,6 +354,14 @@ class SendspinHub final : public Component, // Callback fan-out to child components CallbackManager group_update_callbacks_{}; + // Lazy: each pairing callback is fed by an optional YAML surface. + LazyCallbackManager open_pairing_window_callbacks_{}; + LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager pairing_succeeded_callbacks_{}; + LazyCallbackManager pairing_failed_callbacks_{}; + + const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE bool mdns_advertised_{false}; // Last state requested from mdns diff --git a/tests/component_tests/sendspin/test_pairing.py b/tests/component_tests/sendspin/test_pairing.py new file mode 100644 index 0000000000..dcfce8cece --- /dev/null +++ b/tests/component_tests/sendspin/test_pairing.py @@ -0,0 +1,77 @@ +"""Validation tests for the sendspin hub's pairing options. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_STATIC_PAIRING_CODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _hub_config(**overrides: Any) -> ConfigType: + """Build a minimal valid hub config, allowing field overrides.""" + config: ConfigType = {"id": "sendspin_hub_id"} + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_hub_config()) + + assert CONF_STATIC_PAIRING_CODE not in config + + +@pytest.mark.parametrize("code", ["01234567", "00000000", "99999999"]) +def test_static_pairing_code_accepted( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Eight decimal digits, leading zeros included, pass through unchanged.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert ( + CONFIG_SCHEMA(_hub_config(static_pairing_code=code))[CONF_STATIC_PAIRING_CODE] + == code + ) + + +@pytest.mark.parametrize( + "code", + [ + "0123456", # too short + "012345678", # too long + "0123456a", # not all decimal digits + "0123 567", # whitespace is not a digit + "01234567", # full-width digits are not ASCII + "", + ], +) +def test_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Anything that is not exactly eight decimal digits is refused here, since the + library would otherwise refuse to start the client.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="exactly 8 decimal digits"): + CONFIG_SCHEMA(_hub_config(static_pairing_code=code)) + + +def test_unquoted_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """An unquoted YAML code arrives as an int, having already lost its leading + zeros, so it is refused rather than silently pairing with the wrong value.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567)) diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py new file mode 100644 index 0000000000..a66de471ab --- /dev/null +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -0,0 +1,75 @@ +"""Validation tests for the hub's unpaired access policy.""" + +import logging + +import pytest + +from esphome.components.sendspin import ( + CONF_UNPAIRED_ACCESS, + CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, +) +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "sendspin_hub_id" +NO_PAIRING_WARNING = "nothing lets a server pair" + + +def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None: + """Codegen leaves the hub's built-in default of on in place when the key is not set.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + +@pytest.mark.parametrize("value", [True, False]) +def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: value}) + + assert config[CONF_UNPAIRED_ACCESS] is value + + +def test_off_without_pairing_method_warns( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """With unpaired access off and no way to pair, no server could ever play.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +@pytest.mark.parametrize("method", ["static_pairing_code"]) +def test_off_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + method: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False} + if method == "static_pairing_code": + hub_config["static_pairing_code"] = "01234567" + config = HUB_CONFIG_SCHEMA(hub_config) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_on_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index bd6747ee07..147227b2f1 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -7,3 +7,27 @@ sendspin: manufacturer: Test Manufacturer model: Test Model firmware_version: 1.2.3 + on_open_pairing_window: + then: + - logger.log: "Pairing window requested" + - sendspin.confirm_pairing_window: + # Calling cancel_pairing_window here only compiles the action; a real config cancels from a user input. + on_close_pairing_window: + then: + - logger.log: "Pairing window prompt dismissed" + - sendspin.cancel_pairing_window: + on_pairing_succeeded: + then: + - logger.log: + format: "Paired with server %s" + args: ["server_id.c_str()"] + on_pairing_failed: + then: + - logger.log: + format: "Pairing with server %s failed: %s" + args: ["server_id.c_str()", "reason.c_str()"] + - if: + condition: + lambda: 'return reason == "user_cancelled";' + then: + - logger.log: "Pairing was cancelled" diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml new file mode 100644 index 0000000000..cd9cd82bec --- /dev/null +++ b/tests/components/sendspin/common-pairing.yaml @@ -0,0 +1,14 @@ +# The hub on its own, with no other sendspin platforms, to build the pairing options. +# +# Deliberately does not include common.yaml: that file pulls in the shared hub package, and +# the point here is a separate hub configuration. sendspin depends on network, so the interface +# has to be declared locally instead. +wifi: + ap: + +# Also the static-pairing-code build. +sendspin: + id: sendspin_hub_id + static_pairing_code: "01234567" + # Covers the hub's unpaired access option. + unpaired_access: false diff --git a/tests/components/sendspin/test-pairing.esp32-idf.yaml b/tests/components/sendspin/test-pairing.esp32-idf.yaml new file mode 100644 index 0000000000..5862e841df --- /dev/null +++ b/tests/components/sendspin/test-pairing.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-pairing.yaml From baf1682635dafd5a33372f6a414c40f6fcf94e44 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 13:36:12 -0400 Subject: [PATCH 18/27] [sendspin] Add the dynamic pairing code (#20349) --- esphome/components/sendspin/__init__.py | 46 +++++++++++++- esphome/components/sendspin/sendspin_hub.cpp | 33 ++++++++-- esphome/components/sendspin/sendspin_hub.h | 21 +++++++ .../sendspin/text_sensor/__init__.py | 43 ++++++++++--- .../text_sensor/sendspin_text_sensor.cpp | 15 ++++- .../text_sensor/sendspin_text_sensor.h | 13 +++- .../component_tests/sendspin/test_pairing.py | 20 ++++++- .../sendspin/test_text_sensor.py | 60 +++++++++++++++++++ .../sendspin/test_unpaired_access.py | 5 +- tests/components/sendspin/common-hub.yaml | 11 ++++ .../sendspin/common-text_sensor-pairing.yaml | 17 ++++++ .../sendspin/common-text_sensor.yaml | 4 ++ .../test-text_sensor-pairing.esp32-idf.yaml | 2 + 13 files changed, 271 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/sendspin/test_text_sensor.py create mode 100644 tests/components/sendspin/common-text_sensor-pairing.yaml create mode 100644 tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index fcc53dde4b..5e104fe58a 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -54,6 +54,8 @@ CONF_STATIC_PAIRING_CODE = "static_pairing_code" CONF_UNPAIRED_ACCESS = "unpaired_access" CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_DISPLAY_PAIRING_CODE = "on_display_pairing_code" +CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code" CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" CONF_ON_PAIRING_FAILED = "on_pairing_failed" @@ -144,6 +146,14 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" ), + automation.CallbackAutomation( + CONF_ON_DISPLAY_PAIRING_CODE, + "add_on_display_pairing_code_callback", + [(cg.std_string, "code")], + ), + automation.CallbackAutomation( + CONF_ON_CLEAR_PAIRING_CODE, "add_on_clear_pairing_code_callback" + ), automation.CallbackAutomation( CONF_ON_PAIRING_SUCCEEDED, "add_on_pairing_succeeded_callback", @@ -164,6 +174,7 @@ class SendspinConfiguration: metadata_support: bool = False player_support: bool = False visualizer_support: bool = False + pairing_code_display_support: bool = False artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -200,6 +211,11 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def request_pairing_code_display_support() -> None: + """Mark that the device can emit a dynamic pairing code (e.g. a pairing_code text sensor).""" + _get_data().pairing_code_display_support = True + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -266,6 +282,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( {} ), + cv.Optional(CONF_ON_DISPLAY_PAIRING_CODE): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}), cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } @@ -277,16 +297,33 @@ CONFIG_SCHEMA = cv.All( ) +def _offers_dynamic_pairing_code(config: ConfigType) -> bool: + """Whether the device can show a dynamic pairing code, so dynamic_pairing_code is advertised.""" + return bool( + config.get(CONF_ON_DISPLAY_PAIRING_CODE) + or _get_data().pairing_code_display_support + ) + + def _has_pairing_method(config: ConfigType) -> bool: """Whether the config gives a server any way to pair with the device.""" - return CONF_STATIC_PAIRING_CODE in config + return CONF_STATIC_PAIRING_CODE in config or _offers_dynamic_pairing_code(config) def _final_validate(config: ConfigType) -> ConfigType: + dynamic_code = _offers_dynamic_pairing_code(config) + # The protocol allows only one pairing code method. + if dynamic_code and CONF_STATIC_PAIRING_CODE in config: + raise cv.Invalid( + f"'{CONF_STATIC_PAIRING_CODE}' cannot be used with a dynamic pairing code " + f"({CONF_ON_DISPLAY_PAIRING_CODE} or a pairing_code text sensor), since only " + "one pairing code method can be offered", + path=[CONF_STATIC_PAIRING_CODE], + ) if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): _LOGGER.warning( - "'%s' is off but nothing lets a server pair (%s), so no server can play on this " - "device", + "'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no " + "server can play on this device", CONF_UNPAIRED_ACCESS, CONF_STATIC_PAIRING_CODE, ) @@ -366,6 +403,9 @@ async def to_code(config: ConfigType) -> None: if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: cg.add(var.set_default_unpaired_access(unpaired_access)) + if _offers_dynamic_pairing_code(config): + cg.add(var.set_pairing_code_display_supported(True)) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) # sendspin-cpp library diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 927e0e49ad..3f8fcd7af4 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -202,12 +202,12 @@ void SendspinHub::dump_config() { " Model: %s\n" " Firmware version: %s\n" " Task stack in PSRAM: %s\n" - " Static pairing code: %s\n" - " Unpaired access: %s", + " Unpaired access: %s\n" + " Pairing code method: %s", client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), this->firmware_version_, YESNO(this->task_stack_in_psram_), - YESNO(this->static_pairing_code_ != nullptr), - YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); + YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()), + this->pairing_code_method_()); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -311,6 +311,17 @@ const char *SendspinHub::get_mac_address_into_buffer(std::spanpairing_code_display_supported_) { + return LOG_STR_LITERAL("dynamic"); + } + if (this->static_pairing_code_ != nullptr) { + return LOG_STR_LITERAL("static"); + } + return LOG_STR_LITERAL("none"); +} + const char *SendspinHub::get_product_name_() const { return this->model_ != nullptr ? this->model_ : App.get_name().c_str(); } @@ -328,6 +339,13 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { config.protocol_task_psram_stack = this->task_stack_in_psram_; config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + // The dynamic code needs a channel and a format. Only digits, since automations get the bare string and could not + // tell a QR code token apart. + if (this->pairing_code_display_supported_) { + config.pairing_code_out_channels = {sendspin::SendspinPairingCodeChannel::DISPLAY}; + config.pairing_code_formats = {sendspin::SendspinPairingCodeFormat::DIGITS}; + } + if (this->static_pairing_code_ != nullptr) { config.static_pairing_code_locations = {"operator"}; config.static_pairing_code = this->static_pairing_code_; @@ -365,6 +383,13 @@ void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } +// Only digits are offered, and the library refuses an activation in any other format. +void SendspinHub::on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat /*format*/) { + this->display_pairing_code_callbacks_.call(code); +} + +void SendspinHub::on_clear_pairing_code() { this->clear_pairing_code_callbacks_.call(); } + void SendspinHub::on_pairing_succeeded(const std::string &server_id) { this->pairing_succeeded_callbacks_.call(server_id); } diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 7c3708c374..d33269048e 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -146,6 +146,14 @@ class SendspinHub final : public Component, this->close_pairing_window_callbacks_.add(std::forward(callback)); } + template void add_on_display_pairing_code_callback(F &&callback) { + this->display_pairing_code_callbacks_.add(std::forward(callback)); + } + + template void add_on_clear_pairing_code_callback(F &&callback) { + this->clear_pairing_code_callbacks_.add(std::forward(callback)); + } + template void add_on_pairing_succeeded_callback(F &&callback) { this->pairing_succeeded_callbacks_.add(std::forward(callback)); } @@ -186,6 +194,10 @@ class SendspinHub final : public Component, /// The static pairing code, used on every boot. void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + /// Set when on_display_pairing_code or a pairing_code text sensor is configured, so the device offers the dynamic + /// pairing code. + void set_pairing_code_display_supported(bool supported) { this->pairing_code_display_supported_ = supported; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -258,6 +270,8 @@ class SendspinHub final : public Component, /// @brief Returns the product name reported to the server: the configured model, or the device name. const char *get_product_name_() const; + const char *pairing_code_method_() const; + /// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_mac_address_into_buffer(std::span buf); @@ -278,6 +292,10 @@ class SendspinHub final : public Component, void on_close_pairing_window() override; + void on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat format) override; + + void on_clear_pairing_code() override; + void on_pairing_succeeded(const std::string &server_id) override; void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; @@ -357,10 +375,13 @@ class SendspinHub final : public Component, // Lazy: each pairing callback is fed by an optional YAML surface. LazyCallbackManager open_pairing_window_callbacks_{}; LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager display_pairing_code_callbacks_{}; + LazyCallbackManager clear_pairing_code_callbacks_{}; LazyCallbackManager pairing_succeeded_callbacks_{}; LazyCallbackManager pairing_failed_callbacks_{}; const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + bool pairing_code_display_supported_{false}; bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index 87f6c9b936..24aa2d5734 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -4,16 +4,29 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TYPE from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + SendspinHub, + request_metadata_support, + request_pairing_code_display_support, + sendspin_ns, +) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] +CONF_PAIRING_CODE = "pairing_code" + SendspinTextSensor = sendspin_ns.class_( "SendspinTextSensor", text_sensor.TextSensor, cg.Component, ) +SendspinPairingCodeTextSensor = sendspin_ns.class_( + "SendspinPairingCodeTextSensor", + text_sensor.TextSensor, + cg.Component, +) SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) SENDSPIN_TEXT_METADATA_TYPES = { @@ -26,18 +39,29 @@ SENDSPIN_TEXT_METADATA_TYPES = { def _request_roles(config: ConfigType) -> ConfigType: """Request the necessary Sendspin roles for the text sensor.""" - request_metadata_support() + if config[CONF_TYPE] == CONF_PAIRING_CODE: + # A display surface for the dynamic code, so advertise dynamic_pairing_code. + request_pairing_code_display_support() + else: + request_metadata_support() return config +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) +_METADATA_SCHEMA = text_sensor.text_sensor_schema(SendspinTextSensor).extend( + _HUB_ID_SCHEMA +) + CONFIG_SCHEMA = cv.All( - text_sensor.text_sensor_schema().extend( + cv.typed_schema( { - cv.GenerateID(): cv.declare_id(SendspinTextSensor), - cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), - } + **dict.fromkeys(SENDSPIN_TEXT_METADATA_TYPES, _METADATA_SCHEMA), + CONF_PAIRING_CODE: text_sensor.text_sensor_schema( + SendspinPairingCodeTextSensor + ).extend(_HUB_ID_SCHEMA), + }, + key=CONF_TYPE, ), cv.only_on_esp32, _request_roles, @@ -50,4 +74,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_parented(var, config[CONF_SENDSPIN_ID]) await text_sensor.register_text_sensor(var, config) - cg.add(var.set_metadata_type(config[CONF_TYPE])) + if ( + metadata_type := SENDSPIN_TEXT_METADATA_TYPES.get(config[CONF_TYPE]) + ) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 69dd32e785..f975fb3e47 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -1,8 +1,10 @@ #include "sendspin_text_sensor.h" -#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) +#ifdef USE_SENDSPIN_METADATA #include +#endif #include @@ -10,6 +12,7 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.text_sensor"; +#ifdef USE_SENDSPIN_METADATA void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } // A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to @@ -50,6 +53,16 @@ void SendspinTextSensor::publish_if_changed_(const char *value) { this->publish_state(value); } } +#endif // USE_SENDSPIN_METADATA + +void SendspinPairingCodeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Pairing Code", this); } + +// THREAD CONTEXT: Main loop. The registered callbacks also fire on the main loop (SendspinHub dispatches them from +// its loop()). +void SendspinPairingCodeTextSensor::setup() { + this->parent_->add_on_display_pairing_code_callback([this](const std::string &code) { this->publish_state(code); }); + this->parent_->add_on_clear_pairing_code_callback([this]() { this->publish_state(""); }); +} } // namespace esphome::sendspin_ diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index 9fe1c9381e..cc25ab93c7 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -2,15 +2,19 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" +#ifdef USE_SENDSPIN_METADATA #include +#endif namespace esphome::sendspin_ { +#ifdef USE_SENDSPIN_METADATA + enum class SendspinTextMetadataTypes { TITLE, ARTIST, @@ -31,6 +35,13 @@ class SendspinTextSensor final : public SendspinChild, public text_sensor::TextS SendspinTextMetadataTypes metadata_type_; }; +#endif // USE_SENDSPIN_METADATA + +class SendspinPairingCodeTextSensor final : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; +}; } // namespace esphome::sendspin_ #endif diff --git a/tests/component_tests/sendspin/test_pairing.py b/tests/component_tests/sendspin/test_pairing.py index dcfce8cece..79612ccff5 100644 --- a/tests/component_tests/sendspin/test_pairing.py +++ b/tests/component_tests/sendspin/test_pairing.py @@ -9,7 +9,12 @@ from typing import Any import pytest from esphome import config_validation as cv -from esphome.components.sendspin import CONF_STATIC_PAIRING_CODE, CONFIG_SCHEMA +from esphome.components.sendspin import ( + CONF_STATIC_PAIRING_CODE, + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + request_pairing_code_display_support, +) from esphome.const import PlatformFramework from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -75,3 +80,16 @@ def test_unquoted_static_pairing_code_rejected( with pytest.raises(cv.Invalid): CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567)) + + +def test_static_code_with_dynamic_code_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Only one pairing code method can be offered, so a static code next to the dynamic + code would be silently ignored.""" + set_core_config(PlatformFramework.ESP32_IDF) + request_pairing_code_display_support() + config = CONFIG_SCHEMA(_hub_config(static_pairing_code="01234567")) + + with pytest.raises(cv.Invalid, match="cannot be used with a dynamic pairing code"): + FINAL_VALIDATE_SCHEMA(config) diff --git a/tests/component_tests/sendspin/test_text_sensor.py b/tests/component_tests/sendspin/test_text_sensor.py new file mode 100644 index 0000000000..6dc073a59c --- /dev/null +++ b/tests/component_tests/sendspin/test_text_sensor.py @@ -0,0 +1,60 @@ +"""Validation tests for the sendspin text sensor platform. + +These cover behavior a compile test cannot observe: which roles a sensor type requests. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import _get_data +from esphome.components.sendspin.text_sensor import ( + CONF_PAIRING_CODE, + CONFIG_SCHEMA, + SENDSPIN_TEXT_METADATA_TYPES, +) +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _sensor_config(**overrides: Any) -> ConfigType: + """Build a minimal valid text sensor config, allowing field overrides.""" + config: ConfigType = {"name": "Sendspin Text", "type": "title"} + config.update(overrides) + return config + + +@pytest.mark.parametrize("sensor_type", sorted(SENDSPIN_TEXT_METADATA_TYPES)) +def test_metadata_types_request_metadata_role( + set_core_config: SetCoreConfigCallable, sensor_type: str +) -> None: + """Metadata sensors need the metadata role, and say nothing about pairing codes.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_sensor_config(type=sensor_type)) + + assert _get_data().metadata_support is True + assert _get_data().pairing_code_display_support is False + + +def test_pairing_code_requests_code_display( + set_core_config: SetCoreConfigCallable, +) -> None: + """A pairing_code sensor is a way to show the dynamic code, so it alone makes the + hub offer it. It needs no role, so a pairing-only config builds without metadata.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_sensor_config(type=CONF_PAIRING_CODE)) + + assert _get_data().pairing_code_display_support is True + assert _get_data().metadata_support is False + + +def test_unknown_type_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A misspelled type must fail rather than fall through to a default.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_sensor_config(type="pairing_secret")) diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py index a66de471ab..80bf5a2417 100644 --- a/tests/component_tests/sendspin/test_unpaired_access.py +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -8,6 +8,7 @@ from esphome.components.sendspin import ( CONF_UNPAIRED_ACCESS, CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, + request_pairing_code_display_support, ) from esphome.const import PlatformFramework from tests.component_tests.types import SetCoreConfigCallable @@ -45,7 +46,7 @@ def test_off_without_pairing_method_warns( assert NO_PAIRING_WARNING in caplog.text -@pytest.mark.parametrize("method", ["static_pairing_code"]) +@pytest.mark.parametrize("method", ["static_pairing_code", "pairing_code"]) def test_off_with_pairing_method_does_not_warn( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture, @@ -55,6 +56,8 @@ def test_off_with_pairing_method_does_not_warn( hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False} if method == "static_pairing_code": hub_config["static_pairing_code"] = "01234567" + else: + request_pairing_code_display_support() config = HUB_CONFIG_SCHEMA(hub_config) with caplog.at_level(logging.WARNING): diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index 147227b2f1..d89d16856d 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -7,6 +7,9 @@ sendspin: manufacturer: Test Manufacturer model: Test Model firmware_version: 1.2.3 + # No static_pairing_code here: the on_display_pairing_code automation below makes + # this a dynamic-pairing-code device, and validation rejects both together. + # common-pairing.yaml covers the static-code build. on_open_pairing_window: then: - logger.log: "Pairing window requested" @@ -16,6 +19,14 @@ sendspin: then: - logger.log: "Pairing window prompt dismissed" - sendspin.cancel_pairing_window: + on_display_pairing_code: + then: + - logger.log: + format: "Pairing code: %s" + args: ["code.c_str()"] + on_clear_pairing_code: + then: + - logger.log: "Pairing code cleared" on_pairing_succeeded: then: - logger.log: diff --git a/tests/components/sendspin/common-text_sensor-pairing.yaml b/tests/components/sendspin/common-text_sensor-pairing.yaml new file mode 100644 index 0000000000..c2d92eb453 --- /dev/null +++ b/tests/components/sendspin/common-text_sensor-pairing.yaml @@ -0,0 +1,17 @@ +# The pairing code text sensor on its own, with no metadata text sensor to pull in the +# metadata role. This is the build where USE_SENDSPIN_METADATA is undefined while the text +# sensor platform is still compiled, which is guarded separately in sendspin_text_sensor.{h,cpp}. +# +# Deliberately does not include common.yaml: that file pulls in the shared hub package, and +# the point here is the narrower configuration. sendspin depends on network, so the interface +# has to be declared locally instead. +wifi: + ap: + +sendspin: + id: sendspin_hub_id + +text_sensor: + - platform: sendspin + name: "Pairing Code" + type: pairing_code diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml index 23111e8d37..087bbdb6b0 100644 --- a/tests/components/sendspin/common-text_sensor.yaml +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -14,3 +14,7 @@ text_sensor: - platform: sendspin name: "Album Artist" type: album_artist + - platform: sendspin + name: "Pairing Code" + type: pairing_code + entity_category: diagnostic diff --git a/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml new file mode 100644 index 0000000000..2336f87740 --- /dev/null +++ b/tests/components/sendspin/test-text_sensor-pairing.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-text_sensor-pairing.yaml From ab66b2094459c840488e554d22b34216a24b1abf Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 14:13:31 -0400 Subject: [PATCH 19/27] [sendspin] Add an unpaired access switch (#20350) --- esphome/components/sendspin/__init__.py | 21 +++- esphome/components/sendspin/sendspin_hub.cpp | 24 ++-- esphome/components/sendspin/sendspin_hub.h | 13 +- .../components/sendspin/switch/__init__.py | 88 ++++++++++++-- .../sendspin/switch/sendspin_switch.cpp | 33 ++++- .../sendspin/switch/sendspin_switch.h | 18 ++- .../sendspin/test_unpaired_access.py | 115 +++++++++++++++++- tests/components/sendspin/common-pairing.yaml | 2 +- tests/components/sendspin/common-switch.yaml | 3 + 9 files changed, 274 insertions(+), 43 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 5e104fe58a..ad9b22e367 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -5,7 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32, mdns, network, psram, socket, wifi -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -175,6 +175,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False pairing_code_display_support: bool = False + switch_types: set[str] = field(default_factory=set) artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -216,6 +217,11 @@ def request_pairing_code_display_support() -> None: _get_data().pairing_code_display_support = True +def request_switch(switch_type: str) -> None: + """Mark that a sendspin switch of this type drives the matching hub setting.""" + _get_data().switch_types.add(switch_type) + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -275,6 +281,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( _validate_static_pairing_code ), + # No default: the unpaired access switch rejects this key when it is set. cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( {} @@ -322,8 +329,8 @@ def _final_validate(config: ConfigType) -> ConfigType: ) if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): _LOGGER.warning( - "'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no " - "server can play on this device", + "'%s' is off but there is no pairing method (%s or a dynamic pairing code), so no " + "new server can pair with this device", CONF_UNPAIRED_ACCESS, CONF_STATIC_PAIRING_CODE, ) @@ -400,8 +407,12 @@ async def to_code(config: ConfigType) -> None: if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: cg.add(var.set_static_pairing_code(code)) - if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: - cg.add(var.set_default_unpaired_access(unpaired_access)) + # The client starts once both are set: here, or by the switch that drives the setting. + switch_types = _get_data().switch_types + if CONF_ENABLED not in switch_types: + cg.add(var.set_enabled(True)) + if CONF_UNPAIRED_ACCESS not in switch_types: + cg.add(var.set_unpaired_access_enabled(config.get(CONF_UNPAIRED_ACCESS, True))) if _offers_dynamic_pairing_code(config): cg.add(var.set_pairing_code_display_supported(True)) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 3f8fcd7af4..cfbd91dfcf 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -154,16 +154,15 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); - -#ifndef USE_SENDSPIN_SWITCH - this->enabled_ = true; -#endif + // Set before setup() by codegen; an unpaired access switch sets it later, from its own setup(). + if (this->unpaired_access_.has_value()) { + this->client_->set_unpaired_access_enabled(*this->unpaired_access_); + } } void SendspinHub::loop() { - if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && - !this->status_has_error()) { + if (this->enabled_.has_value() && this->unpaired_access_.has_value() && + this->enabled_.value() != this->client_->is_started() && !this->status_has_error()) { if (!this->enabled_.value()) { this->client_->stop(); } else if (!this->client_->start()) { @@ -222,7 +221,7 @@ void SendspinHub::dump_config() { #endif } -// THREAD CONTEXT: Main loop (invoked from Sendspin components) +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_enabled(bool enabled) { if (this->status_has_error()) { ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", @@ -232,6 +231,15 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) +void SendspinHub::set_unpaired_access_enabled(bool enabled) { + this->unpaired_access_ = enabled; + // Before setup() there is no client yet; setup() applies the stored value. + if (this->client_ != nullptr) { + this->client_->set_unpaired_access_enabled(enabled); + } +} + #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE // THREAD CONTEXT: Main loop void SendspinHub::update_mdns_service_() { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index d33269048e..33356f21f6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -168,12 +168,13 @@ class SendspinHub final : public Component, /// stop. /// /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks - /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has - /// called this once. Must be called from the main loop thread. + /// fire from inside that call. The client stays stopped until this has been called once, by codegen or by the + /// enabled switch. Must be called from the main loop thread. void set_enabled(bool enabled); - /// Turns unpaired (Sentinel) access on or off from setup(); see SendspinClient::set_unpaired_access_enabled(). - void set_default_unpaired_access(bool enabled) { this->default_unpaired_access_ = enabled; } + /// Turns unpaired (Sentinel) access on or off. The client waits for this call, by codegen or by the unpaired + /// access switch, before its first start. May be called before setup(). Main loop only. + void set_unpaired_access_enabled(bool enabled); /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -382,14 +383,14 @@ class SendspinHub final : public Component, const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured bool pairing_code_display_supported_{false}; - bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE bool mdns_advertised_{false}; // Last state requested from mdns #endif - // Requested client state, applied from loop(). Empty until the switch restores its state. + // Requested client state, applied from loop(). The client does not start until both are set. std::optional enabled_; + std::optional unpaired_access_; // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 1f896640bf..ae640e70a4 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,35 +1,101 @@ +import logging + import esphome.codegen as cg from esphome.components import switch from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_PLATFORM, CONF_SWITCH, CONF_TYPE, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + CONF_STATIC_PAIRING_CODE, + CONF_UNPAIRED_ACCESS, + DOMAIN, + SendspinHub, + _has_pairing_method, + request_switch, + sendspin_ns, +) + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] -SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) +SendspinEnabledSwitch = sendspin_ns.class_( + "SendspinEnabledSwitch", switch.Switch, cg.Component +) +SendspinUnpairedAccessSwitch = sendspin_ns.class_( + "SendspinUnpairedAccessSwitch", switch.Switch, cg.Component +) + + +def _switch_schema(class_: cg.MockObjClass) -> cv.Schema: + return ( + switch.switch_schema( + class_, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +def _request_switch(config: ConfigType) -> ConfigType: + """Tell the hub to wait for this switch before the client's first start.""" + request_switch(config[CONF_TYPE]) + return config + CONFIG_SCHEMA = cv.All( cv.typed_schema( { - CONF_ENABLED: switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + CONF_ENABLED: _switch_schema(SendspinEnabledSwitch), + CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch), }, key=CONF_TYPE, ), cv.only_on_esp32, + _request_switch, ) +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + switch_type = config[CONF_TYPE] + same_type = [ + conf + for conf in full_config.get(CONF_SWITCH, []) + if conf.get(CONF_PLATFORM) == DOMAIN and conf.get(CONF_TYPE) == switch_type + ] + # Two switches of one type would each drive the same hub setting. + if len(same_type) > 1: + raise cv.Invalid(f"Only one sendspin '{switch_type}' switch is allowed") + if switch_type != CONF_UNPAIRED_ACCESS: + return config + hub_config = full_config.get(DOMAIN, {}) + if CONF_UNPAIRED_ACCESS in hub_config: + raise cv.Invalid( + f"'{DOMAIN}: {CONF_UNPAIRED_ACCESS}' cannot be set together with an " + f"{CONF_UNPAIRED_ACCESS} switch; set the switch's restore_mode instead" + ) + if not _has_pairing_method(hub_config): + _LOGGER.warning( + "The %s switch has no pairing method (%s or a dynamic pairing code), so " + "while it is off no new server can pair with this device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index af4d053854..70703ea3c5 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,26 +1,49 @@ #include "sendspin_switch.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/core/log.h" +#include "esphome/core/preferences.h" namespace esphome::sendspin_ { static const char *const TAG = "sendspin.switch"; -void SendspinSwitch::setup() { +// --- SendspinEnabledSwitch --- + +void SendspinEnabledSwitch::setup() { // The hub waits for this request, so a restore mode without a state still has to answer. this->control(this->get_initial_state_with_restore_mode().value_or(true)); } -void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } +void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); } // THREAD CONTEXT: Main loop -void SendspinSwitch::write_state(bool state) { +void SendspinEnabledSwitch::write_state(bool state) { this->parent_->set_enabled(state); this->publish_state(state); } +// --- SendspinUnpairedAccessSwitch --- + +void SendspinUnpairedAccessSwitch::setup() { + // The hub holds the client's first start for this, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinUnpairedAccessSwitch::write_state(bool state) { + const bool turning_off = !state && this->has_state() && this->state; + this->parent_->set_unpaired_access_enabled(state); + this->publish_state(state); + // Write the change to flash now: losing it in a power cut would reopen unpaired access. + if (turning_off) { + global_preferences->sync(); + } +} + } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index f9173c362d..4b04c95857 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -10,7 +10,19 @@ namespace esphome::sendspin_ { /// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). -class SendspinSwitch final : public switch_::Switch, public SendspinChild { +class SendspinEnabledSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +/// @brief Switch that turns unpaired (Sentinel) access on and off through the hub (see +/// SendspinHub::set_unpaired_access_enabled()). The library does not persist the setting, so the switch's restore +/// mode does. +class SendspinUnpairedAccessSwitch final : public switch_::Switch, public SendspinChild { public: void setup() override; void dump_config() override; @@ -21,4 +33,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py index 80bf5a2417..8660ba9f7c 100644 --- a/tests/component_tests/sendspin/test_unpaired_access.py +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -1,24 +1,42 @@ -"""Validation tests for the hub's unpaired access policy.""" +"""Validation tests for the unpaired access policy. + +The hub's `unpaired_access` key and an `unpaired_access` switch both set the +policy, so the switch platform rejects a config that has both. +""" import logging import pytest +from esphome import config_validation as cv from esphome.components.sendspin import ( CONF_UNPAIRED_ACCESS, CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + DOMAIN, FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, + _get_data, request_pairing_code_display_support, ) +from esphome.components.sendspin.switch import ( + CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as SWITCH_FINAL_VALIDATE_SCHEMA, +) from esphome.const import PlatformFramework +from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "sendspin_hub_id" -NO_PAIRING_WARNING = "nothing lets a server pair" +NO_PAIRING_WARNING = "no new server can pair with this device" + + +def _switch_config(switch_type: str) -> ConfigType: + return SWITCH_CONFIG_SCHEMA( + {"name": "Sendspin Switch", "type": switch_type, "sendspin_id": HUB_ID} + ) def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None: - """Codegen leaves the hub's built-in default of on in place when the key is not set.""" + """With no default in the schema, the switch can tell whether the key was set.""" set_core_config(PlatformFramework.ESP32_IDF) assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) @@ -36,7 +54,7 @@ def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) - def test_off_without_pairing_method_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture ) -> None: - """With unpaired access off and no way to pair, no server could ever play.""" + """With unpaired access off and no way to pair, no new server could pair.""" set_core_config(PlatformFramework.ESP32_IDF) config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) @@ -76,3 +94,92 @@ def test_on_does_not_warn( HUB_FINAL_VALIDATE_SCHEMA(config) assert NO_PAIRING_WARNING not in caplog.text + + +def test_switch_with_hub_key_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Both would set the policy, and the hub's value would be silently ignored.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with pytest.raises(cv.Invalid, match="set the switch's restore_mode instead"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +def test_switch_without_hub_key_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +def test_switch_without_pairing_method_warns( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """Turning the switch off would leave no way for a new server to pair.""" + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +def test_switch_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, "static_pairing_code": "01234567"}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable) -> None: + """Only the unpaired access switch competes with the hub key.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config("enabled") + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_duplicate_switch_type_rejected( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """Two switches of one type would fight over the same hub setting.""" + entry = {"platform": DOMAIN, "type": switch_type} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID}, "switch": [entry, dict(entry)]}, + ) + config = _switch_config(switch_type) + + with pytest.raises(cv.Invalid, match="Only one sendspin"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_switch_requests_its_type( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """The hub skips the codegen value for a setting a switch drives, so the client + waits for that switch to restore before its first start.""" + set_core_config(PlatformFramework.ESP32_IDF) + + _switch_config(switch_type) + + assert _get_data().switch_types == {switch_type} diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml index cd9cd82bec..b201a5b850 100644 --- a/tests/components/sendspin/common-pairing.yaml +++ b/tests/components/sendspin/common-pairing.yaml @@ -10,5 +10,5 @@ wifi: sendspin: id: sendspin_hub_id static_pairing_code: "01234567" - # Covers the hub's unpaired access option. + # Covers the hub's unpaired access option, which is only valid without an unpaired access switch. unpaired_access: false diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml index 01e2f0b02b..dbf260286b 100644 --- a/tests/components/sendspin/common-switch.yaml +++ b/tests/components/sendspin/common-switch.yaml @@ -5,3 +5,6 @@ switch: - platform: sendspin name: "Sendspin Enabled" type: enabled + - platform: sendspin + name: "Sendspin Unpaired Access" + type: unpaired_access From d5d11e10774fad5d87b7e1668f89cb15a93a588a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:48:19 +0000 Subject: [PATCH 20/27] Bump aioesphomeapi from 46.8.0 to 46.9.0 (#20358) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6e1dfa1ba5..f429d84d52 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.8.0 +aioesphomeapi==46.9.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 7de2bcb58426fc39cad5f42de4fdce97720bce13 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 15:12:20 -0400 Subject: [PATCH 21/27] [api][sendspin] Add a request for the Sendspin pairing token (#20355) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 39 ++++++++++++++ esphome/components/api/api_connection.cpp | 42 ++++++++++++++- esphome/components/api/api_connection.h | 7 +++ esphome/components/api/api_pb2.cpp | 35 ++++++++++++ esphome/components/api/api_pb2.h | 54 ++++++++++++++++++- esphome/components/api/api_pb2_dump.cpp | 39 ++++++++++++++ esphome/components/api/api_pb2_service.cpp | 9 ++++ esphome/components/api/api_pb2_service.h | 4 ++ esphome/components/sendspin/__init__.py | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 12 +++++ esphome/components/sendspin/sendspin_hub.h | 21 ++++++++ tests/components/sendspin/common-pairing.yaml | 5 ++ .../components/api/test_api_proto.py | 35 ++++++++++++ 13 files changed, 301 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0d2ac36937..76b82810a4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -81,6 +81,8 @@ service APIConnection { rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} + + rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {} } @@ -398,6 +400,11 @@ message WizardCapabilities { bool configured = 1; } +message SendspinCapabilities { + // Bitmask of the Sendspin features this device supports + uint32 feature_flags = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -408,6 +415,7 @@ message DeviceCapabilitiesResponse { repeated SerialProxyInfo serial_proxies = 4 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; + SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"]; } // ==================== DEVICE WIZARD ==================== @@ -3069,3 +3077,34 @@ message BluetoothSetConnectionParamsResponse { uint64 address = 1; int32 error = 2; } + +// ==================== SENDSPIN ==================== +enum SendspinPairingTokenStatus { + // The Sendspin client has not started yet, so the token does not exist yet. Ask again later. + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0; + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1; + // The device has no API encryption key, so it will not send the token. Ask again once a key is set. + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2; + // Sendspin is turned off and has not started since boot, so the token does not exist yet. + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3; + // The Sendspin client failed to start, and will not start until the device reboots. + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4; +} + +// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device +message SendspinPairingTokenRequest { + option (id) = 159; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SENDSPIN"; + // Empty +} + +message SendspinPairingTokenResponse { + option (id) = 160; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SENDSPIN"; + + SendspinPairingTokenStatus status = 1; + // The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK. + string token = 2 [(max_data_length) = 127]; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7d56b1ce58..73ce4bd856 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -48,6 +48,9 @@ #ifdef USE_ZWAVE_PROXY #include "esphome/components/zwave_proxy/zwave_proxy.h" #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +#include "esphome/components/sendspin/sendspin_hub.h" +#endif #ifdef USE_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif @@ -1402,6 +1405,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { } #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +bool APIConnection::send_sendspin_pairing_token_response_() { + SendspinPairingTokenResponse resp; +#ifdef USE_API_NOISE + // has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions + // never reach hello, and activating a key closes every session opened before it. + if (!this->parent_->get_noise_ctx().has_psk()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; + return this->send_message(resp); + } + auto token = sendspin_::global_sendspin_hub->get_pairing_token(); + if (token.has_value()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK; + resp.token = StringRef(*token); + } else if (sendspin_::global_sendspin_hub->status_has_error()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED; + } else if (sendspin_::global_sendspin_hub->is_disabled()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED; + } else { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY; + } +#else + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; +#endif + return this->send_message(resp); +} + +void APIConnection::on_sendspin_pairing_token_request() { + if (!this->send_sendspin_pairing_token_response_()) { + this->on_fatal_error(); + } +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE, @@ -1872,7 +1909,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 18; + resp.api_version_minor = 19; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -2088,6 +2125,9 @@ bool APIConnection::send_device_capabilities_response_() { #endif #ifdef USE_API_WIZARD resp.wizard.configured = true; +#endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index b7f0bda0ed..9f784185b8 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + void on_sendspin_pairing_token_request(); +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg); @@ -432,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_VOICE_ASSISTANT bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + bool send_sendspin_pairing_token_response_(); +#endif #ifdef USE_CAMERA void try_send_camera_image_(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 646a8f4211..b59663aeb5 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -306,6 +306,19 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_SENDSPIN +uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + return pos; +} +uint32_t SendspinCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.feature_flags); + return size; +} +#endif uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); #ifdef USE_BLUETOOTH_PROXY @@ -324,6 +337,9 @@ uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__res #endif #ifdef USE_API_WIZARD pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard); +#endif +#ifdef USE_SENDSPIN + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin); #endif return pos; } @@ -346,6 +362,9 @@ uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { #endif #ifdef USE_API_WIZARD size += ProtoSize::calc_message(1, msg.wizard.calculate_size()); +#endif +#ifdef USE_SENDSPIN + size += ProtoSize::calc_message(1, msg.sendspin.calculate_size()); #endif return size; } @@ -4229,6 +4248,22 @@ uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { return size; } #endif +#ifdef USE_SENDSPIN +uint8_t *SendspinPairingTokenResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.token); + return pos; +} +uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += msg.status ? 2 : 0; + size += !msg.token.empty() ? 2 + msg.token.size() : 0; + return size; +} +#endif #ifndef HAS_PROTO_MESSAGE_DUMP static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 9ad114b2ca..421c3fccb3 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -383,6 +383,15 @@ enum SerialProxyIdentityFlag : uint32_t { SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1, SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2, }; +#ifdef USE_SENDSPIN +enum SendspinPairingTokenStatus : uint32_t { + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0, + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1, + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2, + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3, + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4, +}; +#endif } // namespace enums @@ -737,10 +746,27 @@ class WizardCapabilities final : public ProtoMessage { protected: }; #endif +#ifdef USE_SENDSPIN +class SendspinCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 119; + static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -758,6 +784,9 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #endif #ifdef USE_API_WIZARD WizardCapabilities wizard{}; +#endif +#ifdef USE_SENDSPIN + SendspinCapabilities sendspin{}; #endif static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -4138,5 +4167,28 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { protected: }; #endif +#ifdef USE_SENDSPIN +class SendspinPairingTokenResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 160; + static constexpr uint8_t ESTIMATED_SIZE = 11; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("sendspin_pairing_token_response"); } +#endif + enums::SendspinPairingTokenStatus status{}; + StringRef token{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 196a4249cc..3c6f5302b5 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -928,6 +928,25 @@ template<> const char *proto_enum_to_string(enum return ESPHOME_PSTR("UNKNOWN"); } } +#ifdef USE_SENDSPIN +template<> +const char *proto_enum_to_string(enums::SendspinPairingTokenStatus value) { + switch (value) { + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} +#endif const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest")); @@ -1096,6 +1115,13 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_SENDSPIN +const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1124,6 +1150,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": "); this->wizard.dump_to(out); out.append("\n"); +#endif +#ifdef USE_SENDSPIN + out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": "); + this->sendspin.dump_to(out); + out.append("\n"); #endif return out.c_str(); } @@ -2944,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const return out.c_str(); } #endif +#ifdef USE_SENDSPIN +const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse")); + dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); + dump_field(out, ESPHOME_PSTR("token"), this->token); + return out.c_str(); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 1ef70f1fd4..35248a8944 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -751,6 +751,15 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_wizard_input_set_request(msg); break; } +#endif +#ifdef USE_SENDSPIN + case 159 /* SendspinPairingTokenRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request")); +#endif + this->on_sendspin_pairing_token_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 13fc03e03e..7f33e5668c 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -252,6 +252,10 @@ class APIServerConnectionBase { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif + +#ifdef USE_SENDSPIN + void on_sendspin_pairing_token_request(){}; +#endif }; } // namespace esphome::api diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index ad9b22e367..b587a3e092 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -424,7 +424,7 @@ async def to_code(config: ConfigType) -> None: # esp_websocket_client links esp_tls even for ws:// connections. esp32.request_tls() - cg.add_define("USE_SENDSPIN", True) # for MDNS + cg.add_define("USE_SENDSPIN", True) # for MDNS and the native API # Service starts disabled and the hub enables it; always advertised where unsupported if mdns.request_service_enable_disable(): diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index cfbd91dfcf..df68020206 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -113,6 +113,10 @@ static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"}; #endif +SendspinHub *global_sendspin_hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +SendspinHub::SendspinHub() { global_sendspin_hub = this; } + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -231,6 +235,14 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop +std::optional SendspinHub::get_pairing_token() const { + if (this->client_ == nullptr) { + return std::nullopt; + } + return this->client_->pairing_token(); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 33356f21f6..cd8d0d0afa 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -62,6 +62,11 @@ inline constexpr float CHILD = HUB - 1.0f; /// Pairing-record slots, handed to the client as max_pairing_records. inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; +/// Feature flags reported to API clients in DeviceCapabilitiesResponse. +enum SendspinFeature : uint32_t { + SENDSPIN_FEATURE_PAIRING_TOKEN = 1 << 0, +}; + /// @brief Thin adapter over sendspin::SendspinClient. /// /// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for @@ -91,6 +96,8 @@ class SendspinHub final : public Component, public sendspin::SendspinNetworkProvider, public sendspin::SendspinPersistenceProvider { public: + SendspinHub(); + float get_setup_priority() const override { return sendspin_priority::HUB; } void setup() override; void loop() override; @@ -179,6 +186,18 @@ class SendspinHub final : public Component, /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + /// @brief Returns whether the client has been turned off with set_enabled(). + bool is_disabled() const { return this->enabled_.has_value() && !this->enabled_.value(); } + + /// @brief Returns the SendspinFeature flags reported to API clients. + uint32_t get_feature_flags() const { return SENDSPIN_FEATURE_PAIRING_TOKEN; } + + /// @brief Returns the pairing token a Sendspin server uses to pair with this device. + /// + /// The token is a long-lived secret, so this component never logs it. Returns std::nullopt until the client has + /// started once; it stays available after the client stops. Main loop only. + std::optional get_pairing_token() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the @@ -424,6 +443,8 @@ class SendspinPollingChild : public PollingComponent, public Parented None: assert "needs_authentication" not in body +def test_sendspin_pairing_token_messages_keep_their_wire_ids() -> None: + """Message ids are part of the wire protocol and must not change.""" + for message, expected in ( + ("SendspinPairingTokenRequest", 159), + ("SendspinPairingTokenResponse", 160), + ): + body = _extract_proto_message(PROTO_TEXT, message) + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, f"{message} is missing `option (id)`" + assert int(match.group(1)) == expected, ( + f"{message} has id {match.group(1)}, expected {expected}" + ) + + +def test_sendspin_pairing_token_response_fields_keep_their_wire_numbers() -> None: + """Clients decode the response purely by field number.""" + body = _extract_proto_message(PROTO_TEXT, "SendspinPairingTokenResponse") + for field_name, number in (("status", 1), ("token", 2)): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{number}\b", line), ( + f"{field_name} in SendspinPairingTokenResponse is no longer field " + f"number {number}" + ) + + +def test_sendspin_pairing_token_rpc_requires_authentication() -> None: + """The pairing token is a long-lived secret, so the rpc must not set + `needs_authentication` and inherits the default of true. + """ + body = _extract_rpc_body(PROTO_TEXT, "sendspin_pairing_token") + assert "needs_authentication" not in body + + # ==================== Group C: advertised API version ==================== From b82dfd7e64bfdd27ddaa7de3195ee405703195ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 11:18:21 -1000 Subject: [PATCH 22/27] [mdns] Bump espressif/mdns to 1.14.0 (#20383) --- esphome/components/mdns/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 0fb24fdf1d..760952e269 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -235,7 +235,7 @@ async def to_code(config: ConfigType) -> None: ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.12.0") + add_idf_component(name="espressif/mdns", ref="1.14.0") # ESPHome only advertises; the browse APIs are unused add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) # The mdns console CLI is never used by ESPHome diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index ed1202fdb9..3d9f1431f6 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -24,7 +24,7 @@ dependencies: espressif/esp32-camera: version: 2.1.7 espressif/mdns: - version: 1.12.0 + version: 1.14.0 espressif/esp_wifi_remote: version: 1.6.5 rules: From ba1af627fd28631c0661827f245213977ed8b537 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 13:14:00 -1000 Subject: [PATCH 23/27] [ci] Pin the shared I2C bus in test configs that left it implicit (#20412) --- tests/components/ags10/common.yaml | 1 + tests/components/bmi270/common.yaml | 1 + tests/components/ds248x/common.yaml | 4 ++++ tests/components/lsm6ds/common.yaml | 1 + tests/components/ltr501/common.yaml | 1 + tests/components/ltr_als_ps/common.yaml | 1 + 6 files changed, 9 insertions(+) diff --git a/tests/components/ags10/common.yaml b/tests/components/ags10/common.yaml index 8009c56295..8441729a8f 100644 --- a/tests/components/ags10/common.yaml +++ b/tests/components/ags10/common.yaml @@ -1,6 +1,7 @@ sensor: - platform: ags10 id: ags10_1 + i2c_id: i2c_bus tvoc: name: AGS10 TVOC update_interval: 60s diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 0f9b70631c..41f30fd5c6 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -61,6 +61,7 @@ sensor: motion: - platform: bmi270 id: bmi270_motion + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/ds248x/common.yaml b/tests/components/ds248x/common.yaml index 53ae56f20a..4429f5b772 100644 --- a/tests/components/ds248x/common.yaml +++ b/tests/components/ds248x/common.yaml @@ -5,10 +5,12 @@ # - DS2484: adjustable 1-Wire timing + RWPU pullup resistor selection ds248x: - id: ds2482_100 + i2c_id: i2c_bus address: 0x18 type: ds2482-100 active_pullup: true - id: ds2482_101 + i2c_id: i2c_bus address: 0x19 type: ds2482-101 active_pullup: true @@ -18,10 +20,12 @@ ds248x: bus_sleep: true hub_sleep: true - id: ds2482_800 + i2c_id: i2c_bus address: 0x1a type: ds2482-800 active_pullup: true - id: ds2484_hub + i2c_id: i2c_bus address: 0x1b type: ds2484 active_pullup: true diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml index aeacd31448..40bfbfc2a1 100644 --- a/tests/components/lsm6ds/common.yaml +++ b/tests/components/lsm6ds/common.yaml @@ -61,6 +61,7 @@ sensor: motion: - platform: lsm6ds id: lsm6ds_motion + i2c_id: i2c_bus # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/ltr501/common.yaml b/tests/components/ltr501/common.yaml index 77c6f13739..406450f8c5 100644 --- a/tests/components/ltr501/common.yaml +++ b/tests/components/ltr501/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: ltr501 + i2c_id: i2c_bus address: 0x23 type: ALS_PS gain: 1X diff --git a/tests/components/ltr_als_ps/common.yaml b/tests/components/ltr_als_ps/common.yaml index edad4b2e4f..6c555eeb74 100644 --- a/tests/components/ltr_als_ps/common.yaml +++ b/tests/components/ltr_als_ps/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: ltr_als_ps + i2c_id: i2c_bus address: 0x23 gain: 1x integration_time: 100ms From f1f1a7715143742fe9e04d0811653de9d809d928 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 13:48:03 -1000 Subject: [PATCH 24/27] [ci] Deal clang-tidy files across five shards (#20411) --- .github/workflows/ci.yml | 18 ++++++++++++------ script/clang-tidy | 38 +++++++++++++++++++++++++++++++++----- script/helpers.py | 2 +- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eb96af59b..6ed1452414 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -893,14 +893,20 @@ jobs: matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 1/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 2/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 3/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 3 + name: Run script/clang-tidy for ESP32 IDF 3/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 3 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 4/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 4 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 5/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 5 steps: - name: Check out code from GitHub diff --git a/script/clang-tidy b/script/clang-tidy index ad6c99d637..f97e9a482e 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse +import heapq import os from pathlib import Path import queue @@ -11,6 +12,7 @@ import subprocess import sys import tempfile import threading +import time from typing import Any import click @@ -278,9 +280,12 @@ def clang_options(idedata, environment): pids = set() -def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): +def run_tidy( + executable, args, options, tmpdir, path_queue, lock, failed_files, timings +): while True: path = path_queue.get() + started = time.monotonic() invocation = [executable] if tmpdir is not None: @@ -319,8 +324,9 @@ def run_tidy(executable, args, options, tmpdir, path_queue, lock, failed_files): check=False, close_fds=False, ) - if proc.returncode != 0: - with lock: + with lock: + timings.append((time.monotonic() - started, path)) + if proc.returncode != 0: print_error_for_file(path, proc.stdout) failed_files.append(path) path_queue.task_done() @@ -333,8 +339,27 @@ def progress_bar_show(value): def split_list(a, n): - k, m = divmod(len(a), n) - return [a[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] + """Deal the sorted files out to n splits in turn. + + Contiguous slices put whole directories on one split; expensive + neighbours such as esphome/core then land together and that split takes + several times longer than the others. Dealing spreads them out. + """ + return [a[i::n] for i in range(n)] + + +SLOWEST_FILES_SHOWN = 15 + + +def print_slowest_files(timings): + """Print the files clang-tidy spent the longest on, so the splits can be + rebalanced from CI logs.""" + if not timings: + return + total = sum(seconds for seconds, _ in timings) + print(f"\nclang-tidy spent {total:.0f} s on {len(timings)} files; slowest:") + for seconds, path in heapq.nlargest(SLOWEST_FILES_SHOWN, timings): + print(f" {seconds:6.1f} s {path}") def main(): @@ -458,6 +483,7 @@ def main(): tmpdir = tempfile.mkdtemp() failed_files = [] + timings = [] try: executable = get_binary("clang-tidy", 22) task_queue = queue.Queue(args.jobs) @@ -473,6 +499,7 @@ def main(): task_queue, lock, failed_files, + timings, ), ) t.daemon = True @@ -487,6 +514,7 @@ def main(): # Wait for all threads to be done. task_queue.join() + print_slowest_files(timings) except FileNotFoundError: return 1 diff --git a/script/helpers.py b/script/helpers.py index fb2c964bdd..11605fcef0 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -1616,7 +1616,7 @@ def lpt_partition( Heaviest item first into the lightest group. Ties keep input order, so pass pre-sorted items for deterministic output. script/clang-tidy's - split_list is the unweighted contiguous sibling. + split_list is the unweighted sibling that deals files out in turn. """ groups: list[list[str]] = [[] for _ in range(count)] group_weights = [0.0] * count From f6746b4977a60dcf4ec616bc9b8cc9fa80e91921 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 14:05:18 -1000 Subject: [PATCH 25/27] [api] Fix C++ unit test link failure when api is only a dependency (#20418) --- tests/components/api/__init__.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index 4a66287c33..bd84c8d9bb 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -1,6 +1,12 @@ +from pathlib import Path + import esphome.codegen as cg +from esphome.const import CONF_ESPHOME, CONF_INCLUDES +from esphome.core import CORE from tests.testing_helpers import ComponentManifestOverride +TESTS_DIR = Path(__file__).parent + def override_manifest(manifest: ComponentManifestOverride) -> None: # USE_API compiles every api source, so emit what they need. No socket @@ -10,16 +16,18 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_API_PLAINTEXT") # Linked wizard inputs only exist next to homeassistant entities, which need this cg.add_define("USE_API_HOMEASSISTANT_STATES") - # test_wizard.cpp supplies the tables that codegen emits for a real build - for define in ( - "USE_API_WIZARD", - "USE_API_WIZARD_INPUTS", - "USE_API_WIZARD_LINKED_INPUTS", - "USE_API_WIZARD_STANDALONE_INPUTS", - ): - cg.add_define(define) - cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + # test_wizard.cpp supplies the tables that codegen emits for a real build; + # it is only compiled when api's own tests are, not when api is a dependency + if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): + for define in ( + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + ): + cg.add_define(define) + cg.add_define("API_WIZARD_DATA_SIZE", 200) + cg.add_define("API_WIZARD_INPUT_COUNT", 2) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") From c6244fa4b95f0b9ae13784bd25f56de24fe48d0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 8 Oct 2026 14:25:11 -1000 Subject: [PATCH 26/27] [esp32] Record the PC for cache error and interrupt watchdog crashes (#20415) --- esphome/components/esp32/crash_handler.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b72a2777c7..d5afaf750f 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -531,6 +531,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; + // IDF leaves addr unset for SoC-level panics (cache error, interrupt WDT) + if (info->pseudo_excause) + s_raw_crash_data.pc = xt_frame->pc; if (!g_panic_abort) { // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots // never wrote them and abort() traps describe only the synthetic trap. From a6488220277202de4a42a9cf37cfa9f072726552 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:45:36 +1300 Subject: [PATCH 27/27] Bump version to 2026.10.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index ec43afe83a..455a83a021 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.0b1 +PROJECT_NUMBER = 2026.10.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index f0c339416a..5271266947 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b1" +__version__ = "2026.10.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (