diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index fa42372ac81..affe8fc2d6b 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c4c1ab072ff..d0bd1de9ef1 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ab8b9ee367..11d073e7e32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -443,7 +443,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1338,7 +1338,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 84d5e229d91..a0725374f92 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/AGENTS.md b/AGENTS.md index 113cfe1b2a9..30ce6dfe9a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -737,7 +737,9 @@ file does, and it is the authority when they disagree. The most useful starting 6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. - 7. **Detection:** Look for these patterns in compiler output: + 7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`. + + 8. **Detection:** Look for these patterns in compiler output: - Large code sections with STL symbols (vector, map, set) - `alloc`, `realloc`, `dealloc` in symbol names - `_M_realloc_insert`, `_M_default_append` (vector reallocation) diff --git a/esphome/__main__.py b/esphome/__main__.py index 6cc89de9699..f87fee49d2f 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -1342,8 +1342,12 @@ def _upload_via_native_api( # fall back to a plaintext upload noise_psk = None plaintext_fallback = False + allow_plaintext_upload = False if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: noise_psk = encryption_conf.get(CONF_KEY) + allow_plaintext_upload = bool( + encryption_conf.get(espota2.CONF_ALLOW_PLAINTEXT_UPLOAD) + ) if not noise_psk: raise EsphomeError( "OTA encryption is configured but no key was resolved; " @@ -1394,6 +1398,7 @@ def _upload_via_native_api( ota_type, noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) diff --git a/esphome/codegen.py b/esphome/codegen.py index 5debb52b4e2..3dfec65f233 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -64,7 +64,6 @@ from esphome.cpp_types import ( # noqa: F401 Application, Component, ComponentPtr, - Controller, EntityBase, EntityCategory, ESPTime, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 854bceecfad..2291dc54f8f 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -81,7 +81,7 @@ def AUTO_LOAD(config: ConfigType) -> list[str]: api_ns = cg.esphome_ns.namespace("api") -APIServer = api_ns.class_("APIServer", cg.Component, cg.Controller) +APIServer = api_ns.class_("APIServer", cg.Component) HomeAssistantServiceCallAction = api_ns.class_( "HomeAssistantServiceCallAction", automation.Action ) @@ -462,8 +462,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) # Request a log listener slot for API log streaming request_log_listener() diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 310cdcf67bb..2a89d31a74a 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -5,7 +5,6 @@ #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" -#include "esphome/core/controller_registry.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -61,8 +60,6 @@ void APIServer::socket_failed_(const LogString *msg) { } void APIServer::setup() { - ControllerRegistry::register_controller(this); - #ifdef USE_API_NOISE // Always reserve the slot: flash preferences are positional on esp8266, so // a yaml key build must keep the layout of a runtime key build diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index b8a71c93e7a..49cf77ca5af 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -14,7 +14,7 @@ #include "esphome/components/socket/socket.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/controller.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" #ifdef USE_PROVISIONING @@ -49,8 +49,7 @@ struct SavedNoisePsk { bool load_saved_noise_psk(noise::psk_t &out); #endif -class APIServer final : public Component, - public Controller +class APIServer final : public Component #ifdef USE_CAMERA , public camera::CameraListener @@ -94,58 +93,58 @@ class APIServer final : public Component, void handle_disconnect(APIConnection *conn); #ifdef USE_BINARY_SENSOR - void on_binary_sensor_update(binary_sensor::BinarySensor *obj) override; + void on_binary_sensor_update(binary_sensor::BinarySensor *obj); #endif #ifdef USE_COVER - void on_cover_update(cover::Cover *obj) override; + void on_cover_update(cover::Cover *obj); #endif #ifdef USE_FAN - void on_fan_update(fan::Fan *obj) override; + void on_fan_update(fan::Fan *obj); #endif #ifdef USE_LIGHT - void on_light_update(light::LightState *obj) override; + void on_light_update(light::LightState *obj); #endif #ifdef USE_SENSOR - void on_sensor_update(sensor::Sensor *obj) override; + void on_sensor_update(sensor::Sensor *obj); #endif #ifdef USE_SWITCH - void on_switch_update(switch_::Switch *obj) override; + void on_switch_update(switch_::Switch *obj); #endif #ifdef USE_TEXT_SENSOR - void on_text_sensor_update(text_sensor::TextSensor *obj) override; + void on_text_sensor_update(text_sensor::TextSensor *obj); #endif #ifdef USE_CLIMATE - void on_climate_update(climate::Climate *obj) override; + void on_climate_update(climate::Climate *obj); #endif #ifdef USE_NUMBER - void on_number_update(number::Number *obj) override; + void on_number_update(number::Number *obj); #endif #ifdef USE_DATETIME_DATE - void on_date_update(datetime::DateEntity *obj) override; + void on_date_update(datetime::DateEntity *obj); #endif #ifdef USE_DATETIME_TIME - void on_time_update(datetime::TimeEntity *obj) override; + void on_time_update(datetime::TimeEntity *obj); #endif #ifdef USE_DATETIME_DATETIME - void on_datetime_update(datetime::DateTimeEntity *obj) override; + void on_datetime_update(datetime::DateTimeEntity *obj); #endif #ifdef USE_TEXT - void on_text_update(text::Text *obj) override; + void on_text_update(text::Text *obj); #endif #ifdef USE_SELECT - void on_select_update(select::Select *obj) override; + void on_select_update(select::Select *obj); #endif #ifdef USE_LOCK - void on_lock_update(lock::Lock *obj) override; + void on_lock_update(lock::Lock *obj); #endif #ifdef USE_VALVE - void on_valve_update(valve::Valve *obj) override; + void on_valve_update(valve::Valve *obj); #endif #ifdef USE_MEDIA_PLAYER - void on_media_player_update(media_player::MediaPlayer *obj) override; + void on_media_player_update(media_player::MediaPlayer *obj); #endif #ifdef USE_WATER_HEATER - void on_water_heater_update(water_heater::WaterHeater *obj) override; + void on_water_heater_update(water_heater::WaterHeater *obj); #endif #ifdef USE_API_HOMEASSISTANT_SERVICES void send_homeassistant_action(const HomeassistantActionRequest &call); @@ -188,13 +187,13 @@ class APIServer final : public Component, #endif #ifdef USE_ALARM_CONTROL_PANEL - void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) override; + void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); #endif #ifdef USE_EVENT - void on_event(event::Event *obj) override; + void on_event(event::Event *obj); #endif #ifdef USE_UPDATE - void on_update(update::UpdateEntity *obj) override; + void on_update(update::UpdateEntity *obj); #endif #ifdef USE_ZWAVE_PROXY void on_zwave_proxy_request(const ZWaveProxyRequest &msg); diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h index 6b1ae9651de..36e96e10819 100644 --- a/esphome/components/api/subscribe_state.h +++ b/esphome/components/api/subscribe_state.h @@ -4,7 +4,6 @@ #ifdef USE_API #include "esphome/core/component.h" #include "esphome/core/component_iterator.h" -#include "esphome/core/controller.h" namespace esphome::api { class APIConnection; diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index ee24002b8a3..bdb438a99de 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,14 +16,14 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] @@ -40,8 +40,6 @@ button_ns = cg.esphome_ns.namespace("button") Button = button_ns.class_("Button", cg.EntityBase) ButtonPtr = Button.operator("ptr") -PressAction = button_ns.class_("PressAction", automation.Action) - validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -123,17 +121,9 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True +automation.register_apply_action( + "button.press", BUTTON_PRESS_SCHEMA, automation.ApplyCall("press()") ) -async def button_press_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h deleted file mode 100644 index 683328b394c..00000000000 --- a/esphome/components/button/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/components/button/button.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::button { - -template class PressAction final : public Action { - public: - explicit PressAction(Button *button) : button_(button) {} - - void play(const Ts &...x) override { this->button_->press(); } - - protected: - Button *button_; -}; - -} // namespace esphome::button diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 31b0a0c097e..b8bb9db8bf5 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -23,6 +23,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_IS_WRGB = "is_wrgb" diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index dc037086452..06286933b90 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -214,16 +214,6 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) -PreventDeepSleepAction = deep_sleep_ns.class_( - "PreventDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) -AllowDeepSleepAction = deep_sleep_ns.class_( - "AllowDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) WakeupPinMode = deep_sleep_ns.enum("WakeupPinMode") WAKEUP_PIN_MODES = { @@ -422,8 +412,12 @@ async def to_code(config: ConfigType) -> None: if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) - if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: - zephyr_add_prj_conf("POWEROFF", True) + if CORE.using_zephyr: + # Devices are suspended when CPU is entering a low power state + # https://github.com/nrfconnect/sdk-zephyr/blob/v3.7.99-ncs2-2/doc/services/pm/device.rst#system-managed-device-power-management + zephyr_add_prj_conf("PM_DEVICE", True) + if "zigbee" not in CORE.loaded_integrations: + zephyr_add_prj_conf("POWEROFF", True) cg.add_define("USE_DEEP_SLEEP") @@ -486,27 +480,15 @@ async def deep_sleep_enter_to_code( return var -@automation.register_action( - "deep_sleep.prevent", - PreventDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -@automation.register_action( - "deep_sleep.allow", - AllowDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -async def deep_sleep_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("deep_sleep.prevent", "prevent_deep_sleep()"), + ("deep_sleep.allow", "allow_deep_sleep()"), +): + automation.register_apply_action( + _name, + automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + automation.ApplyCall(_call), + ) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 0bbca4c5c4d..2afe19c0698 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -121,8 +121,6 @@ class Ext1WakeTrigger : public Trigger<>, public Component { template class EnterDeepSleepAction; -template class PreventDeepSleepAction; - /** This component allows setting up the node to go into deep sleep mode to conserve battery. * * To set this component up, first set *when* the deep sleep should trigger using set_run_cycles @@ -298,15 +296,4 @@ template class EnterDeepSleepAction final : public Action #endif }; -template -class PreventDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } -}; - -template class AllowDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } -}; - } // namespace esphome::deep_sleep diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 0d409b78575..c3da73f5107 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -11,7 +11,10 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg -from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION +from esphome.components.const import ( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, + CONF_IGNORE_NOT_FOUND, +) from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -111,6 +114,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_NVS_CACHE_IN_PSRAM = "nvs_cache_in_psram" CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" @@ -1595,6 +1599,29 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_EXECUTE_FROM_PSRAM], ) ) + if advanced.get(CONF_NVS_CACHE_IN_PSRAM): + psram_conf = full_config.get(PSRAM_DOMAIN) + if ( + psram_conf is None + or psram_conf[CONF_DISABLED] + or psram_conf[CONF_IGNORE_NOT_FOUND] + ): + errs.append( + cv.Invalid( + f"'{CONF_NVS_CACHE_IN_PSRAM}' requires PSRAM with 'ignore_not_found: false'", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_CACHE_IN_PSRAM], + ) + ) + if ( + advanced.get(CONF_NVS_ENCRYPTION) is not None + or conf_fw[CONF_SDKCONFIG_OPTIONS].get("CONFIG_NVS_ENCRYPTION") == "y" + ): + errs.append( + cv.Invalid( + f"'{CONF_NVS_CACHE_IN_PSRAM}' cannot be used with NVS encryption; the keys must stay in internal RAM", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_CACHE_IN_PSRAM], + ) + ) final_validate_pins(full_config) @@ -2035,6 +2062,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_RINGBUF_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_HEAP_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_NVS_CACHE_IN_PSRAM): cv.boolean, cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( min=8192, max=32768 ), @@ -2361,6 +2389,20 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _apply_nvs_cache_in_psram(explicit: bool) -> None: + """Keep the NVS cache in PSRAM unless NVS encryption is on, however it was enabled.""" + # The encrypted partition object holds the derived keys, which must stay in internal RAM + if is_idf_sdkconfig_option_enabled("CONFIG_NVS_ENCRYPTION"): + if explicit: + _LOGGER.warning( + "%s ignored: NVS encryption keeps the NVS cache in internal RAM", + CONF_NVS_CACHE_IN_PSRAM, + ) + return + set_idf_sdkconfig_default("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM", True) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_certificate_bundle_sdkconfig() -> None: """Enable the mbedTLS certificate bundle only when something asked for it. @@ -2913,6 +2955,17 @@ async def to_code(config): if advanced[CONF_EXECUTE_FROM_PSRAM]: add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True) + # Imported here as psram imports this module + from esphome.components.psram import is_guaranteed as psram_is_guaranteed + + # Frees internal heap (the cache scales with the NVS partition) but slows NVS, so only + # where PSRAM is known to be fitted. Decided at FINAL so every way of enabling NVS + # encryption has been seen and a user's sdkconfig_options value wins. + # Unset means on; only an explicit true is worth a warning when it has to be dropped. + requested = advanced.get(CONF_NVS_CACHE_IN_PSRAM) + if requested is not False and psram_is_guaranteed(): + CORE.add_job(_apply_nvs_cache_in_psram, requested is True) + # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides # significant performance benefits. Our benchmarks show socket operations are diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bace13c17fc..e14f5e7e86e 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -2,7 +2,7 @@ import logging import esphome.codegen as cg from esphome.components.noise import ( - encryption_schema, + ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, ) @@ -27,6 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv from esphome.types import ConfigType @@ -231,15 +232,38 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - encryption_conf[CONF_KEY] = api_key +# Uploader side options live only on the ota block; the api block keeps the +# shared schema +_ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( + { + cv.Optional(CONF_ALLOW_PLAINTEXT_UPLOAD): cv.boolean, + } +) + + +def _encryption_schema(config: ConfigType | None) -> ConfigType: + # Only a bare `encryption:` block is keyless; `false` or a list must fail + return _ENCRYPTION_SCHEMA({} if config is None else config) + + # Also called on merged same-port configs in final validate, where schemas # do not run def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType: - if CONF_PASSWORD in config and CONF_ENCRYPTION in config: - raise cv.Invalid( - f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " - f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'" - ) - return config + if ( + CONF_PASSWORD not in config + or (encryption := config.get(CONF_ENCRYPTION)) is None + ): + return config + # The migration install may still have to answer the old firmware's + # password prompt on the plaintext leg; the password is not built in + if encryption.get(CONF_ALLOW_PLAINTEXT_UPLOAD): + return config + raise cv.Invalid( + f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " + f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}' " + f"(or set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' for the one install that " + f"migrates a device still asking for it)" + ) def _consume_ota_sockets(config: ConfigType) -> ConfigType: @@ -269,7 +293,7 @@ CONFIG_SCHEMA = cv.All( ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.sensitive(), - cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(CONF_ENCRYPTION): _encryption_schema, cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), @@ -304,7 +328,10 @@ async def to_code(config: ConfigType) -> None: # An empty password opts in to the auth code path so set_auth_password() can be # called at runtime (e.g. to rotate the password from a lambda). When `password:` # is omitted entirely, the auth path is excluded to save flash on small devices. - if CONF_PASSWORD in config: + # A password is never built in next to encryption: validation only lets + # the two coexist for the migration install, where the password answers + # the running firmware and the build is authenticated by the key + if CONF_PASSWORD in config and CONF_ENCRYPTION not in config: cg.add_define("USE_OTA_PASSWORD") if config[CONF_PASSWORD]: cg.add(var.set_auth_password(config[CONF_PASSWORD])) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index d353d01d205..6248474624b 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -467,6 +467,8 @@ void ESPHomeOTAComponent::handle_data_() { if (this->extended_proto_()) { // Read ota type, 1 byte if (!this->data_readall_(buf, 1)) { + if (this->client_left_before_start_()) + return; this->log_read_error_(LOG_STR("OTA type")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -476,6 +478,9 @@ void ESPHomeOTAComponent::handle_data_() { // Read size, 4 bytes MSB first if (!this->data_readall_(buf, 4)) { + // The first request byte is the type on the extended protocol; a close after it was a cut-off request + if (!this->extended_proto_() && this->client_left_before_start_()) + return; this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -542,6 +547,8 @@ void ESPHomeOTAComponent::handle_data_() { // there is no would-block retry here and failures are already logged. read = this->noise_read_data_(buf, requested); if (read <= 0) { + if (this->remote_closed_) + this->log_remote_closed_(LOG_STR("data")); error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -666,7 +673,11 @@ bool ESPHomeOTAComponent::readall_(uint8_t *buf, size_t len) { return false; } } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed"); + // A partial message is a cut-off request, not a clean close; the caller reports the clean one + this->remote_closed_ = at == 0; + if (at > 0) { + ESP_LOGW(TAG, "Remote closed after %u of %zu bytes", (unsigned) at, len); + } return false; } else { at += read; @@ -712,7 +723,22 @@ void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); } -void ESPHomeOTAComponent::log_read_error_(const LogString *what) { ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); } +bool ESPHomeOTAComponent::client_left_before_start_() { + // Key probes and scanners hang up right after the handshake; nothing started, so no error status or callback + if (!this->remote_closed_) + return false; + ESP_LOGD(TAG, "Client left after the handshake"); + this->cleanup_connection_(); + return true; +} + +void ESPHomeOTAComponent::log_read_error_(const LogString *what) { + if (this->remote_closed_) { + this->log_remote_closed_(what); + return; + } + ESP_LOGW(TAG, "Read %s failed", LOG_STR_ARG(what)); +} void ESPHomeOTAComponent::log_start_(const LogString *phase) { char peername[socket::SOCKADDR_STR_LEN]; @@ -793,6 +819,7 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->handshake_buf_pos_ = 0; this->ota_state_ = OTAState::IDLE; this->ota_features_ = 0; + this->remote_closed_ = false; this->backend_ = nullptr; #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 92ba094c8de..6f04b78da5b 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -134,6 +134,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { void server_failed_(const LogString *msg); void log_socket_error_(const LogString *msg); void log_read_error_(const LogString *what); + bool client_left_before_start_(); void log_start_(const LogString *phase); void log_remote_closed_(const LogString *during); void cleanup_connection_(); @@ -186,6 +187,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { OTAState ota_state_{OTAState::IDLE}; uint8_t handshake_buf_pos_{0}; uint8_t ota_features_{0}; + bool remote_closed_{false}; // the peer hung up cleanly during a blocking read #ifdef USE_OTA_PASSWORD uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index d00375ae960..526499fe077 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -61,8 +61,6 @@ RESTORE_MODES = { } # Actions -TurnOffAction = fan_ns.class_("TurnOffAction", automation.Action) -ToggleAction = fan_ns.class_("ToggleAction", automation.Action) CycleSpeedAction = fan_ns.class_("CycleSpeedAction", automation.Action) FanStateTrigger = fan_ns.class_( @@ -310,21 +308,8 @@ FAN_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "fan.toggle", ToggleAction, FAN_ACTION_SCHEMA, synchronous=True -) -async def fan_toggle_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "fan.turn_off", TurnOffAction, FAN_ACTION_SCHEMA, synchronous=True -) -async def fan_turn_off_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - +automation.register_apply_action("fan.toggle", FAN_ACTION_SCHEMA, call="toggle") +automation.register_apply_action("fan.turn_off", FAN_ACTION_SCHEMA, call="turn_off") automation.register_apply_action( "fan.turn_on", diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 5f0843581bc..4c587904b73 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -6,24 +6,6 @@ namespace esphome::fan { -template class TurnOffAction final : public Action { - public: - explicit TurnOffAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->turn_off().perform(); } - - Fan *state_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->toggle().perform(); } - - Fan *state_; -}; - template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 4ceb6206fd9..aa5bec98d53 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -12,13 +12,13 @@ from esphome.const import ( CONF_ON_UNLOCK, CONF_WEB_SERVER, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] @@ -29,9 +29,6 @@ Lock = lock_ns.class_("Lock", cg.EntityBase) LockPtr = Lock.operator("ptr") LockCall = lock_ns.class_("LockCall") -UnlockAction = lock_ns.class_("UnlockAction", automation.Action) -LockAction = lock_ns.class_("LockAction", automation.Action) -OpenAction = lock_ns.class_("OpenAction", automation.Action) LockPublishAction = lock_ns.class_("LockPublishAction", automation.Action) LockStateForwarder = lock_ns.class_("LockStateForwarder") @@ -134,23 +131,14 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "lock.lock", LockAction, LOCK_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True -) -async def lock_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in ( + ("lock.unlock", "unlock()"), + ("lock.lock", "lock()"), + ("lock.open", "open()"), +): + automation.register_apply_action( + _name, LOCK_ACTION_SCHEMA, automation.ApplyCall(_call) + ) automation.register_apply_condition( diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index 13153bc1df0..e578a056439 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,36 +6,6 @@ namespace esphome::lock { -template class LockAction final : public Action { - public: - explicit LockAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->lock(); } - - protected: - Lock *lock_; -}; - -template class UnlockAction final : public Action { - public: - explicit UnlockAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->unlock(); } - - protected: - Lock *lock_; -}; - -template class OpenAction final : public Action { - public: - explicit OpenAction(Lock *a_lock) : lock_(a_lock) {} - - void play(const Ts &...x) override { this->lock_->open(); } - - protected: - Lock *lock_; -}; - /// Callback forwarder that triggers an Automation<> only when a specific lock state is entered. /// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_. template struct LockStateForwarder { diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 33abbe847a3..ef8b5684535 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -18,19 +18,18 @@ void MCP3204::dump_config() { } float MCP3204::read_data(uint8_t pin, bool differential) { - uint8_t command, b0, b1; - - command = (1 << 6) | // start bit - ((differential ? 0 : 1) << 5) | // single or differential bit - ((pin & 0x07) << 2); // pin + const uint8_t command = (1 << 6) | // start bit + ((differential ? 0 : 1) << 5) | // single or differential bit + ((pin & 0x07) << 2); // pin + // One full-duplex transaction: command out, 12-bit result back in bytes 1 and 2. + // Word aligned so ESP-IDF DMA uses the buffer in place; only ESP32-P4 also checks the length and bounces. + alignas(4) uint8_t buffer[3] = {command, 0x00, 0x00}; this->enable(); - this->transfer_byte(command); - b0 = this->transfer_byte(0x00); - b1 = this->transfer_byte(0x00); + this->transfer_array(buffer, sizeof(buffer)); this->disable(); - uint16_t digital_value = encode_uint16(b0, b1) >> 4; + uint16_t digital_value = encode_uint16(buffer[1], buffer[2]) >> 4; return float(digital_value) / 4096.000f * this->reference_voltage_; // in V } diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 092c4977ce1..f04f784ebcf 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -6,7 +6,6 @@ import re from urllib.parse import urljoin from esphome import automation, external_files, git -from esphome.automation import register_action, register_condition from esphome.bundle import add_bundle_file import esphome.codegen as cg from esphome.components import esp32, microphone, ota, psram @@ -58,17 +57,6 @@ micro_wake_word_ns = cg.esphome_ns.namespace("micro_wake_word") MicroWakeWord = micro_wake_word_ns.class_("MicroWakeWord", cg.Component) -DisableModelAction = micro_wake_word_ns.class_("DisableModelAction", automation.Action) -EnableModelAction = micro_wake_word_ns.class_("EnableModelAction", automation.Action) -StartAction = micro_wake_word_ns.class_("StartAction", automation.Action) -StopAction = micro_wake_word_ns.class_("StopAction", automation.Action) - -ModelIsEnabledCondition = micro_wake_word_ns.class_( - "ModelIsEnabledCondition", automation.Condition -) -IsRunningCondition = micro_wake_word_ns.class_( - "IsRunningCondition", automation.Condition -) WakeWordModel = micro_wake_word_ns.class_("WakeWordModel") @@ -619,23 +607,19 @@ async def to_code(config): MICRO_WAKE_WORD_ACTION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(MicroWakeWord)}) - -@register_action( +automation.register_apply_action( "micro_wake_word.start", - StartAction, MICRO_WAKE_WORD_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("start()"), ) -@register_action( - "micro_wake_word.stop", StopAction, MICRO_WAKE_WORD_ACTION_SCHEMA, synchronous=True +automation.register_apply_action( + "micro_wake_word.stop", + MICRO_WAKE_WORD_ACTION_SCHEMA, + automation.ApplyCall("stop()"), ) -@register_condition( - "micro_wake_word.is_running", IsRunningCondition, MICRO_WAKE_WORD_ACTION_SCHEMA +automation.register_apply_condition( + "micro_wake_word.is_running", MICRO_WAKE_WORD_ACTION_SCHEMA, "is_running()" ) -async def micro_wake_word_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( @@ -644,24 +628,18 @@ MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA = automation.maybe_simple_id( } ) - -@register_action( +automation.register_apply_action( "micro_wake_word.enable_model", - EnableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("enable()"), ) -@register_action( +automation.register_apply_action( "micro_wake_word.disable_model", - DisableModelAction, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("disable()"), ) -@register_condition( +automation.register_apply_condition( "micro_wake_word.model_is_enabled", - ModelIsEnabledCondition, MICRO_WAKE_WORLD_MODEL_ACTION_SCHEMA, + "is_enabled()", ) -async def model_action(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h deleted file mode 100644 index 59dfc624fad..00000000000 --- a/esphome/components/micro_wake_word/automation.h +++ /dev/null @@ -1,54 +0,0 @@ -#pragma once - -#include "micro_wake_word.h" -#include "streaming_model.h" - -#ifdef USE_ESP32 - -namespace esphome::micro_wake_word { - -template class StartAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class IsRunningCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class EnableModelAction final : public Action { - public: - explicit EnableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - void play(const Ts &...x) override { this->wake_word_model_->enable(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -template class DisableModelAction final : public Action { - public: - explicit DisableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - void play(const Ts &...x) override { this->wake_word_model_->disable(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -template class ModelIsEnabledCondition final : public Condition { - public: - explicit ModelIsEnabledCondition(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} - bool check(const Ts &...x) override { return this->wake_word_model_->is_enabled(); } - - protected: - WakeWordModel *wake_word_model_; -}; - -} // namespace esphome::micro_wake_word - -#endif diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 9a3f5b43e7b..c604f64d775 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -14,9 +14,9 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE, ID +from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.cpp_generator import MockObj from esphome.types import ConfigType AUTO_LOAD = ["audio"] @@ -29,30 +29,11 @@ microphone_ns = cg.esphome_ns.namespace("microphone") Microphone = microphone_ns.class_("Microphone") MicrophoneSource = microphone_ns.class_("MicrophoneSource") -CaptureAction = microphone_ns.class_( - "CaptureAction", automation.Action, cg.Parented.template(Microphone) -) -StopCaptureAction = microphone_ns.class_( - "StopCaptureAction", automation.Action, cg.Parented.template(Microphone) -) -MuteAction = microphone_ns.class_( - "MuteAction", automation.Action, cg.Parented.template(Microphone) -) -UnmuteAction = microphone_ns.class_( - "UnmuteAction", automation.Action, cg.Parented.template(Microphone) -) - - DataTrigger = microphone_ns.class_( "DataTrigger", automation.Trigger.template(cg.std_vector.template(cg.uint8).operator("ref")), ) -IsCapturingCondition = microphone_ns.class_( - "IsCapturingCondition", automation.Condition -) -IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) - async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): @@ -189,44 +170,22 @@ async def microphone_source_to_code( return mic_source -async def microphone_action( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("microphone.capture", "start()"), + ("microphone.stop_capture", "stop()"), + ("microphone.mute", "set_mute_state(true)"), + ("microphone.unmute", "set_mute_state(false)"), +): + automation.register_apply_action( + _name, MICROPHONE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) - -automation.register_action( - "microphone.capture", - CaptureAction, - MICROPHONE_ACTION_SCHEMA, - synchronous=True, -)(microphone_action) - -automation.register_action( - "microphone.stop_capture", - StopCaptureAction, - MICROPHONE_ACTION_SCHEMA, - synchronous=True, -)(microphone_action) - -automation.register_action( - "microphone.mute", MuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True -)(microphone_action) -automation.register_action( - "microphone.unmute", UnmuteAction, MICROPHONE_ACTION_SCHEMA, synchronous=True -)(microphone_action) - -automation.register_condition( - "microphone.is_capturing", IsCapturingCondition, MICROPHONE_ACTION_SCHEMA -)(microphone_action) -automation.register_condition( - "microphone.is_muted", IsMutedCondition, MICROPHONE_ACTION_SCHEMA -)(microphone_action) +automation.register_apply_condition( + "microphone.is_capturing", MICROPHONE_ACTION_SCHEMA, "is_running()" +) +automation.register_apply_condition( + "microphone.is_muted", MICROPHONE_ACTION_SCHEMA, "get_mute_state()" +) @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index c28616a290a..0fac18d8283 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -7,21 +7,6 @@ namespace esphome::microphone { -template class CaptureAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->start(); } -}; - -template class StopCaptureAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->stop(); } -}; - -template class MuteAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_mute_state(true); } -}; -template class UnmuteAction final : public Action, public Parented { - void play(const Ts &...x) override { this->parent_->set_mute_state(false); } -}; - class DataTrigger final : public Trigger &> { public: explicit DataTrigger(Microphone *mic) { @@ -29,14 +14,4 @@ class DataTrigger final : public Trigger &> { } }; -template class IsCapturingCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_running(); } -}; - -template class IsMutedCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } -}; - } // namespace esphome::microphone diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index f4be6843180..37694fcfc54 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -117,10 +117,7 @@ mqtt_ns = cg.esphome_ns.namespace("mqtt") MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) -MQTTPublishAction = mqtt_ns.class_("MQTTPublishAction", automation.Action) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) -MQTTEnableAction = mqtt_ns.class_("MQTTEnableAction", automation.Action) -MQTTDisableAction = mqtt_ns.class_("MQTTDisableAction", automation.Action) MQTTMessageTrigger = mqtt_ns.class_( "MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component ) @@ -503,22 +500,26 @@ MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA, synchronous=True -) -async def mqtt_publish_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_TOPIC], args, cg.std_string) - cg.add(var.set_topic(template_)) +# A bare literal is ambiguous between the std::string and (const char *, size_t) publish +# overloads, so constants and inlined `return "...";` lambdas are both spelled as std::string. +def _std_string(config: ConfigType, value: str) -> str: + rendered = automation.flash_string(config, value) + return rendered if CORE.is_esp8266 else f"std::string({rendered})" - template_ = await cg.templatable(config[CONF_PAYLOAD], args, cg.std_string) - cg.add(var.set_payload(template_)) - template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) - cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) - cg.add(var.set_retain(template_)) - return var + +automation.register_apply_action( + "mqtt.publish", + MQTT_PUBLISH_ACTION_SCHEMA, + automation.ApplyCall( + "publish({}, {}, {}, {})", + ( + (CONF_TOPIC, "std::string", _std_string), + (CONF_PAYLOAD, "std::string", _std_string), + (CONF_QOS, cg.uint8), + (CONF_RETAIN, cg.bool_), + ), + ), +) MQTT_PUBLISH_JSON_ACTION_SCHEMA = cv.Schema( @@ -608,34 +609,16 @@ automation.register_apply_condition( ) -@automation.register_action( - "mqtt.enable", - MQTTEnableAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(MQTTClientComponent), - } - ), - synchronous=True, -) -async def mqtt_enable_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "mqtt.disable", - MQTTDisableAction, - cv.Schema( - { - cv.GenerateID(): cv.use_id(MQTTClientComponent), - } - ), - synchronous=True, -) -async def mqtt_disable_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _call in (("mqtt.enable", "enable()"), ("mqtt.disable", "disable()")): + automation.register_apply_action( + _name, + cv.Schema( + { + cv.GenerateID(): cv.use_id(MQTTClientComponent), + } + ), + automation.ApplyCall(_call), + ) _platform_filter = filter_source_files_from_platform( diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 2fd9a5a5502..ced9c84e100 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -378,23 +378,6 @@ class MQTTDisconnectTrigger final : public Trigger { } }; -template class MQTTPublishAction final : public Action { - public: - MQTTPublishAction(MQTTClientComponent *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(std::string, topic) - TEMPLATABLE_VALUE(std::string, payload) - TEMPLATABLE_VALUE(uint8_t, qos) - TEMPLATABLE_VALUE(bool, retain) - - void play(const Ts &...x) override { - this->parent_->publish(this->topic_.value(x...), this->payload_.value(x...), this->qos_.value(x...), - this->retain_.value(x...)); - } - - protected: - MQTTClientComponent *parent_; -}; - template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} @@ -417,26 +400,6 @@ template class MQTTPublishJsonAction final : public Action class MQTTEnableAction final : public Action { - public: - MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->enable(); } - - protected: - MQTTClientComponent *parent_; -}; - -template class MQTTDisableAction final : public Action { - public: - MQTTDisableAction(MQTTClientComponent *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->disable(); } - - protected: - MQTTClientComponent *parent_; -}; - } // namespace esphome::mqtt #endif // USE_MQTT diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index cc7fa603c4e..64846dcb678 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -95,6 +95,7 @@ NoiseResponderHandshake::Action NoiseResponderHandshake::action() const { } int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { + CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); noise_buffer_set_input(mbuf, data, len); @@ -103,6 +104,7 @@ int NoiseResponderHandshake::read_message(uint8_t *data, size_t len) { int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { out_len = 0; + CpuFrequencyBoost boost; NoiseBuffer mbuf; noise_buffer_init(mbuf); noise_buffer_set_output(mbuf, out, capacity); diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index 24dee997269..0122ed9b31b 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -28,6 +28,7 @@ void DeviceFirmwareUpdate::setup() { this->reset_pin_->digital_write(true); } else { NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + arch_feed_wdt(); App.reboot(); } } diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 5574039e41f..9dcbf7e6869 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -6,8 +6,8 @@ #include "esphome/components/web_server_base/web_server_base.h" #include "esphome/core/component.h" -#include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/progmem.h" #ifdef USE_CLIMATE #include "esphome/core/log.h" diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 9bc8a263c82..5d03ed70a83 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -10,8 +10,8 @@ from esphome.const import ( CONF_STORAGE, KEY_PAST_SAFE_MODE, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import RawExpression from esphome.types import ConfigType CODEOWNERS = ["@paulmonigatti", "@jsuanet", "@kbx81"] @@ -22,7 +22,6 @@ CONF_ON_SAFE_MODE = "on_safe_mode" safe_mode_ns = cg.esphome_ns.namespace("safe_mode") SafeModeComponent = safe_mode_ns.class_("SafeModeComponent", cg.Component) -MarkSuccessfulAction = safe_mode_ns.class_("MarkSuccessfulAction", automation.Action) def _remove_id_if_disabled(value: ConfigType) -> ConfigType: @@ -53,26 +52,15 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "safe_mode.mark_successful", - MarkSuccessfulAction, cv.Schema( { cv.GenerateID(): cv.use_id(SafeModeComponent), } ), - synchronous=True, + automation.ApplyCall("mark_successful()"), ) -async def safe_mode_mark_successful_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - parent = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(parent)) - return var _CALLBACK_AUTOMATIONS = ( diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h deleted file mode 100644 index e2858dff341..00000000000 --- a/esphome/components/safe_mode/automation.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include "esphome/core/automation.h" -#include "safe_mode.h" - -namespace esphome::safe_mode { - -template class MarkSuccessfulAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->mark_successful(); } -}; - -} // namespace esphome::safe_mode diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 6c16d002ad0..2c79e9ed0a1 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -9,7 +9,6 @@ CODEOWNERS = ["@esphome/core"] script_ns = cg.esphome_ns.namespace("script") Script = script_ns.class_("Script", automation.Trigger.template()) ScriptExecuteAction = script_ns.class_("ScriptExecuteAction", automation.Action) -ScriptStopAction = script_ns.class_("ScriptStopAction", automation.Action) ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component) SingleScript = script_ns.class_("SingleScript", Script) RestartScript = script_ns.class_("RestartScript", Script) @@ -206,16 +205,11 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_apply_action( "script.stop", - ScriptStopAction, maybe_simple_id({cv.Required(CONF_ID): cv.use_id(Script)}), - synchronous=True, + automation.ApplyCall("stop()"), ) -async def script_stop_action_to_code(config, action_id, template_arg, args): - full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) - template_arg = cg.TemplateArguments(full_id.type, *template_arg) - return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_action( diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index d83555016de..b9ea75e0bcf 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -257,16 +257,6 @@ template class ScriptExecuteAction, T Args args_; }; -template class ScriptStopAction final : public Action { - public: - ScriptStopAction(C *script) : script_(script) {} - - void play(const Ts &...x) override { this->script_->stop(); } - - protected: - C *script_; -}; - /** Wait for a script to finish before continuing. * * Uses queue-based storage to safely handle concurrent executions. diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 49bee109361..42dae464ede 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -21,8 +21,8 @@ from esphome.const import ( CONF_VERSION, CONF_WIDTH, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType # mdns for autodiscovery @@ -109,13 +109,6 @@ SendspinHub = sendspin_ns.class_( ) -SendspinSwitchCommandAction = sendspin_ns.class_( - "SendspinSwitchCommandAction", - automation.Action, - cg.Parented.template(SendspinHub), -) - - @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -240,21 +233,11 @@ SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "sendspin.switch", - SendspinSwitchCommandAction, SENDSPIN_SIMPLE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("switch_client()"), ) -async def sendspin_switch_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h deleted file mode 100644 index 0b408b12350..00000000000 --- a/esphome/components/sendspin/automation.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/core/automation.h" -#include "sendspin_hub.h" - -namespace esphome::sendspin_ { - -#ifdef USE_SENDSPIN_CONTROLLER -template class SendspinSwitchCommandAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { - // Clear any EXTERNAL_SOURCE state so the switch command is followed - this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - this->parent_->send_client_command(sendspin::SendspinControllerCommand::SWITCH); - } -}; -#endif // USE_SENDSPIN_CONTROLLER - -} // namespace esphome::sendspin_ - -#endif // USE_ESP32 diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index 3c6c82b0091..e26e8204aaf 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -16,7 +16,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import ID -from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType from .. import ( @@ -198,16 +197,8 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -SendspinImageTransitionFinishedAction = sendspin_ns.class_( - "SendspinImageTransitionFinishedAction", - automation.Action, - cg.Parented.template(SendspinImageSlot), -) - - -@automation.register_action( +automation.register_apply_action( "sendspin.image.transition_finished", - SendspinImageTransitionFinishedAction, automation.maybe_simple_id( cv.Schema( { @@ -215,14 +206,5 @@ SendspinImageTransitionFinishedAction = sendspin_ns.class_( } ) ), - synchronous=True, + automation.ApplyCall("transition_finished()"), ) -async def sendspin_image_transition_finished_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> cg.MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/sendspin/image/automation.h b/esphome/components/sendspin/image/automation.h deleted file mode 100644 index 154e62a4b20..00000000000 --- a/esphome/components/sendspin/image/automation.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) - -#include "esphome/core/automation.h" -#include "sendspin_image.h" - -namespace esphome::sendspin_ { - -template -class SendspinImageTransitionFinishedAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->transition_finished(); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6a9f1f18ba2..743d0bb62cf 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -8,8 +8,6 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TASK_STACK_IN_PSRAM, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( @@ -41,18 +39,6 @@ SendspinMediaSource = sendspin_ns.class_( media_source.MediaSource, ) -EnableStaticDelayAdjustmentAction = sendspin_ns.class_( - "EnableStaticDelayAdjustmentAction", - automation.Action, - cg.Parented.template(SendspinMediaSource), -) - -DisableStaticDelayAdjustmentAction = sendspin_ns.class_( - "DisableStaticDelayAdjustmentAction", - automation.Action, - cg.Parented.template(SendspinMediaSource), -) - def _resolve_codecs(config: ConfigType) -> ConfigType: """Validate the codec preference list, filling in the default when it is not set.""" @@ -144,25 +130,16 @@ SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id( ) ) - -@automation.register_action( - "sendspin.media_source.enable_static_delay_adjustment", - EnableStaticDelayAdjustmentAction, - SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sendspin.media_source.disable_static_delay_adjustment", - DisableStaticDelayAdjustmentAction, - SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, - synchronous=True, -) -async def sendspin_static_delay_adjustment_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ( + "sendspin.media_source.enable_static_delay_adjustment", + "set_static_delay_adjustable(true)", + ), + ( + "sendspin.media_source.disable_static_delay_adjustment", + "set_static_delay_adjustable(false)", + ), +): + automation.register_apply_action( + _name, SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h deleted file mode 100644 index f5c35f107af..00000000000 --- a/esphome/components/sendspin/media_source/automations.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) - -#include "esphome/core/automation.h" -#include "sendspin_media_source.h" - -namespace esphome::sendspin_ { - -template -class EnableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } -}; - -template -class DisableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 58ec57c7681..15d358d50fb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -284,6 +284,13 @@ 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); +} + // THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index b00fdc436eb..8ab70d61ef1 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -181,6 +181,9 @@ class SendspinHub final : public Component, void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); + /// @brief Sends the SWITCH controller command; exposed as the sendspin.switch action. + void switch_client(); + template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } diff --git a/esphome/components/sgp30/sensor.py b/esphome/components/sgp30/sensor.py index beb549ce706..75244abee80 100644 --- a/esphome/components/sgp30/sensor.py +++ b/esphome/components/sgp30/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor +from esphome.components.const import CONF_HUMIDITY_SOURCE import esphome.config_validation as cv from esphome.const import ( CONF_BASELINE, @@ -31,7 +32,6 @@ SGP30Component = sgp30_ns.class_( CONF_ECO2_BASELINE = "eco2_baseline" CONF_TVOC_BASELINE = "tvoc_baseline" CONF_UPTIME = "uptime" -CONF_HUMIDITY_SOURCE = "humidity_source" CONFIG_SCHEMA = ( diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index 1a88d190e4f..bf8fbe1a975 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -1,6 +1,10 @@ import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor -from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX +from esphome.components.const import ( + CONF_HUMIDITY_SOURCE, + CONF_NOX_INDEX, + CONF_VOC_INDEX, +) import esphome.config_validation as cv from esphome.const import ( CONF_ALGORITHM_TUNING, @@ -33,8 +37,6 @@ SGP4xComponent = sgp4x_ns.class_( sensirion_common.SensirionI2CDevice, ) -CONF_HUMIDITY_SOURCE = "humidity_source" - def validate_sensors(config: ConfigType) -> ConfigType: if CONF_VOC_INDEX not in config and CONF_NOX_INDEX not in config: diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 2f8b4dbd117..1d492fce476 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -1,5 +1,6 @@ #ifdef USE_ESP8266 #include "uart_component_esp8266.h" +#include #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -251,23 +252,35 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); } } +// A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles +// the bit time then. The whole byte is read inside the ISR, so the clock cannot change partway through. +__attribute__((always_inline)) static inline uint32_t rx_bit_time(uint32_t bit_time) { +#if F_CPU != 160000000L + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- CPU2X is MMIO at a fixed address + return bit_time << (CPU2X & 1); +#else + return bit_time; +#endif +} + void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { - uint32_t wait = arg->bit_time_ + arg->bit_time_ / 3 - 500; + const uint32_t bit_time = rx_bit_time(arg->bit_time_); + uint32_t wait = bit_time + bit_time / 3 - 500; const uint32_t start = arch_get_cpu_cycle_count(); uint8_t rec = 0; // Manually unroll the loop for (int i = 0; i < arg->data_bits_; i++) - rec |= arg->read_bit_(&wait, start) << i; + rec |= arg->read_bit_(&wait, start, bit_time) << i; /* If parity is enabled, just read it and ignore it. */ /* TODO: Should we check parity? Or is it too slow for nothing added..*/ if (arg->parity_ == UART_CONFIG_PARITY_EVEN || arg->parity_ == UART_CONFIG_PARITY_ODD) - arg->read_bit_(&wait, start); + arg->read_bit_(&wait, start, bit_time); // Stop bit - arg->wait_(&wait, start); + arg->wait_(&wait, start, bit_time); if (arg->stop_bits_ == 2) - arg->wait_(&wait, start); + arg->wait_(&wait, start, bit_time); arg->rx_buffer_[arg->rx_in_pos_] = rec; arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; @@ -296,37 +309,39 @@ void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { } { + // Transmit runs from the main loop and never overlaps a CpuFrequencyBoost InterruptLock lock; - uint32_t wait = this->bit_time_; + const uint32_t bit_time = this->bit_time_; + uint32_t wait = bit_time; const uint32_t start = arch_get_cpu_cycle_count(); // Start bit - this->write_bit_(false, &wait, start); + this->write_bit_(false, &wait, start, bit_time); for (int i = 0; i < this->data_bits_; i++) { bool bit = data & (1 << i); - this->write_bit_(bit, &wait, start); + this->write_bit_(bit, &wait, start, bit_time); if (need_parity_bit) parity_bit ^= bit; } if (need_parity_bit) - this->write_bit_(parity_bit, &wait, start); + this->write_bit_(parity_bit, &wait, start, bit_time); // Stop bit - this->write_bit_(true, &wait, start); + this->write_bit_(true, &wait, start, bit_time); if (this->stop_bits_ == 2) - this->wait_(&wait, start); + this->wait_(&wait, start, bit_time); } } -void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start) { +void IRAM_ATTR ESP8266SoftwareSerial::wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { while (arch_get_cpu_cycle_count() - start < *wait) ; - *wait += this->bit_time_; + *wait += bit_time; } -bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start) { - this->wait_(wait, start); +bool IRAM_ATTR ESP8266SoftwareSerial::read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time) { + this->wait_(wait, start, bit_time); return this->rx_pin_.digital_read(); } -void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start) { +void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time) { this->tx_pin_.digital_write(bit); - this->wait_(wait, start); + this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { if (this->rx_in_pos_ == this->rx_out_pos_) diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 469885b6b6c..54bcad39933 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -28,9 +28,9 @@ class ESP8266SoftwareSerial { protected: static void gpio_intr(ESP8266SoftwareSerial *arg); - void wait_(uint32_t *wait, const uint32_t &start); - bool read_bit_(uint32_t *wait, const uint32_t &start); - void write_bit_(bool bit, uint32_t *wait, const uint32_t &start); + void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); uint32_t bit_time_{0}; uint8_t *rx_buffer_{nullptr}; diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2459163786d..3ec365cf381 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -62,7 +62,7 @@ DEFAULT_PORT = 80 web_server_ns = cg.esphome_ns.namespace("web_server") -WebServer = web_server_ns.class_("WebServer", cg.Component, cg.Controller) +WebServer = web_server_ns.class_("WebServer", cg.Component) sorting_groups = {} @@ -378,8 +378,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) version = config[CONF_VERSION] diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 49943f8a71b..6e48e2700f7 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -5,7 +5,6 @@ #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" -#include "esphome/core/controller_registry.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -365,7 +364,6 @@ json::SerializationBuffer<> WebServer::get_config_json() { } void WebServer::setup() { - ControllerRegistry::register_controller(this); this->base_->init(); #ifdef USE_LOGGER @@ -1415,8 +1413,11 @@ json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::st json::JsonBuilder builder; JsonObject root = builder.root(); - const char *state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value.c_str(); - set_json_icon_state_value(root, obj, "text", state, value.c_str(), start_config); + // A password entity shows the mask and prefills the input with nothing, so the secret never + // reaches the JSON and the mask cannot be written back as the value + const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; + set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), + start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 3e50eeeec07..3236ccfd3c8 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -6,8 +6,8 @@ #include "esphome/components/web_server_base/web_server_base.h" #ifdef USE_WEBSERVER #include "esphome/core/component.h" -#include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/progmem.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" @@ -187,7 +187,7 @@ class DeferredUpdateEventSourceList final : public std::list'. void handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -301,7 +301,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_SWITCH - void on_switch_update(switch_::Switch *obj) override; + void on_switch_update(switch_::Switch *obj); /// Handle a switch request under '/switch//'. void handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -319,7 +319,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_BINARY_SENSOR - void on_binary_sensor_update(binary_sensor::BinarySensor *obj) override; + void on_binary_sensor_update(binary_sensor::BinarySensor *obj); /// Handle a binary sensor request under '/binary_sensor/'. void handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -329,7 +329,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_FAN - void on_fan_update(fan::Fan *obj) override; + void on_fan_update(fan::Fan *obj); /// Handle a fan request under '/fan//'. void handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -339,7 +339,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_LIGHT - void on_light_update(light::LightState *obj) override; + void on_light_update(light::LightState *obj); /// Handle a light request under '/light//'. void handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -349,7 +349,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_TEXT_SENSOR - void on_text_sensor_update(text_sensor::TextSensor *obj) override; + void on_text_sensor_update(text_sensor::TextSensor *obj); /// Handle a text sensor request under '/text_sensor/'. void handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -359,7 +359,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_COVER - void on_cover_update(cover::Cover *obj) override; + void on_cover_update(cover::Cover *obj); /// Handle a cover request under '/cover//'. void handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -369,7 +369,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_NUMBER - void on_number_update(number::Number *obj) override; + void on_number_update(number::Number *obj); /// Handle a number request under '/number/'. void handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -378,7 +378,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_DATETIME_DATE - void on_date_update(datetime::DateEntity *obj) override; + void on_date_update(datetime::DateEntity *obj); /// Handle a date request under '/date/'. void handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -387,7 +387,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_DATETIME_TIME - void on_time_update(datetime::TimeEntity *obj) override; + void on_time_update(datetime::TimeEntity *obj); /// Handle a time request under '/time/'. void handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -396,7 +396,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_DATETIME_DATETIME - void on_datetime_update(datetime::DateTimeEntity *obj) override; + void on_datetime_update(datetime::DateTimeEntity *obj); /// Handle a datetime request under '/datetime/'. void handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -405,7 +405,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_TEXT - void on_text_update(text::Text *obj) override; + void on_text_update(text::Text *obj); /// Handle a text input request under '/text/'. void handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -414,7 +414,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_SELECT - void on_select_update(select::Select *obj) override; + void on_select_update(select::Select *obj); /// Handle a select request under '/select/'. void handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -423,7 +423,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_CLIMATE - void on_climate_update(climate::Climate *obj) override; + void on_climate_update(climate::Climate *obj); /// Handle a climate request under '/climate/'. void handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -432,7 +432,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_LOCK - void on_lock_update(lock::Lock *obj) override; + void on_lock_update(lock::Lock *obj); /// Handle a lock request under '/lock//'. void handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -442,7 +442,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_VALVE - void on_valve_update(valve::Valve *obj) override; + void on_valve_update(valve::Valve *obj); /// Handle a valve request under '/valve//'. void handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -452,7 +452,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_ALARM_CONTROL_PANEL - void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) override; + void on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj); /// Handle a alarm_control_panel request under '/alarm_control_panel/'. void handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -462,7 +462,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_WATER_HEATER - void on_water_heater_update(water_heater::WaterHeater *obj) override; + void on_water_heater_update(water_heater::WaterHeater *obj); /// Handle a water_heater request under '/water_heater//'. void handle_water_heater_request(AsyncWebServerRequest *request, const UrlMatch &match); @@ -484,8 +484,13 @@ class WebServer final : public Controller, public Component, public AsyncWebHand static json::SerializationBuffer<> radio_frequency_all_json_generator(WebServer *web_server, void *source); #endif +#ifdef USE_MEDIA_PLAYER + // Not exposed over HTTP; the stub only satisfies ControllerContract + void on_media_player_update(media_player::MediaPlayer *) {} +#endif + #ifdef USE_EVENT - void on_event(event::Event *obj) override; + void on_event(event::Event *obj); static json::SerializationBuffer<> event_state_json_generator(WebServer *web_server, void *source); static json::SerializationBuffer<> event_all_json_generator(WebServer *web_server, void *source); @@ -495,7 +500,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif #ifdef USE_UPDATE - void on_update(update::UpdateEntity *obj) override; + void on_update(update::UpdateEntity *obj); /// Handle a update request under '/update/'. void handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match); diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ea9e5a3b0c9..ac62812fcf9 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -41,16 +41,6 @@ _WG_KEY_REGEX = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") wireguard_ns = cg.esphome_ns.namespace("wireguard") Wireguard = wireguard_ns.class_("Wireguard", cg.Component, cg.PollingComponent) AllowedIP = wireguard_ns.struct("AllowedIP") -WireguardPeerOnlineCondition = wireguard_ns.class_( - "WireguardPeerOnlineCondition", automation.Condition -) -WireguardEnabledCondition = wireguard_ns.class_( - "WireguardEnabledCondition", automation.Condition -) -WireguardEnableAction = wireguard_ns.class_("WireguardEnableAction", automation.Action) -WireguardDisableAction = wireguard_ns.class_( - "WireguardDisableAction", automation.Action -) def _wireguard_key(value): @@ -163,47 +153,17 @@ async def to_code(config): await cg.register_component(var, config) -@automation.register_condition( - "wireguard.peer_online", - WireguardPeerOnlineCondition, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), +WIREGUARD_AUTOMATION_SCHEMA = cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}) + +automation.register_apply_condition( + "wireguard.peer_online", WIREGUARD_AUTOMATION_SCHEMA, "is_peer_up()" ) -async def wireguard_peer_up_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_condition( - "wireguard.enabled", - WireguardEnabledCondition, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), +automation.register_apply_condition( + "wireguard.enabled", WIREGUARD_AUTOMATION_SCHEMA, "is_enabled()" ) -async def wireguard_enabled_to_code(config, condition_id, template_arg, args): - var = cg.new_Pvariable(condition_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "wireguard.enable", - WireguardEnableAction, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), - synchronous=True, +automation.register_apply_action( + "wireguard.enable", WIREGUARD_AUTOMATION_SCHEMA, automation.ApplyCall("enable()") ) -async def wireguard_enable_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - - -@automation.register_action( - "wireguard.disable", - WireguardDisableAction, - cv.Schema({cv.GenerateID(): cv.use_id(Wireguard)}), - synchronous=True, +automation.register_apply_action( + "wireguard.disable", WIREGUARD_AUTOMATION_SCHEMA, automation.ApplyCall("disable()") ) -async def wireguard_disable_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c9c2feb7aef..68885f3a351 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -4,7 +4,6 @@ #include #include -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/time/real_time_clock.h" @@ -164,30 +163,5 @@ static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; /// Strip most part of the key only for secure printing void mask_key_to(char *buffer, size_t len, const char *key); -/// Condition to check if remote peer is online. -template -class WireguardPeerOnlineCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_peer_up(); } -}; - -/// Condition to check if Wireguard component is enabled. -template class WireguardEnabledCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->is_enabled(); } -}; - -/// Action to enable Wireguard component. -template class WireguardEnableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->enable(); } -}; - -/// Action to disable Wireguard component. -template class WireguardDisableAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->disable(); } -}; - } // namespace esphome::wireguard #endif diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index ad8ed5c95c3..10e8340a409 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -27,7 +27,11 @@ void arch_init() { if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE +#ifndef USE_BOOTLOADER_MCUBOOT + // Adafruit bootloader doesn't feed the WDT while + // erasing flash during a firmware update, so a shorter timeout can break the update. + wdt_config.window.max = 30000; +#elif defined(USE_ZIGBEE) // zboss thread uses a lot of CPU cycles during startup wdt_config.window.max = 10000; #else diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 4f9c70da75c..65ceae8a4bf 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -272,7 +272,9 @@ static void ezb_task(void *pv_parameters) { vTaskDelete(NULL); } -ZigbeeComponent::ZigbeeComponent() { +void ZigbeeComponent::setup() { + global_zigbee = this; + esp_zigbee_platform_config_t platform_config = { .storage_partition_name = "nvs", .radio_config = EZB_DEFAULT_RADIO_CONFIG(), @@ -299,11 +301,7 @@ ZigbeeComponent::ZigbeeComponent() { this->mark_failed(); return; } - this->dev_desc_ = ezb_af_create_device_desc(); -} -void ZigbeeComponent::setup() { - global_zigbee = this; #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); @@ -341,6 +339,15 @@ void ZigbeeComponent::setup() { }; ezb_af_set_node_power_desc(&desc); + // Finish zigbee data model + for (auto &attr_value : this->attr_values_) { + ezb_zcl_attr_desc_t attr_desc = attr_value.attr_desc; + void *value_p = &attr_value.value; + ezb_zcl_attr_desc_set_value(attr_desc, value_p); + } + // free memory + std::vector().swap(this->attr_values_); + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network @@ -383,6 +390,23 @@ void ZigbeeComponent::dump_config() { YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); } } + +bool ZigbeeComponent::string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id) { + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + return false; + } + ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); + if (cluster_desc == NULL) { + return false; + } + if (ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE) == NULL) { + return false; + } + ESP_LOGW(TAG, "Attribute 0x%04X already exists in endpoint %u cluster 0x%04X. Can't add new value", attr_id, + endpoint_id, cluster_id); + return true; +} } // namespace esphome::zigbee #endif diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index c19fc3ad63e..e84ed9817cd 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -30,11 +30,15 @@ static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); +struct AttrValue { + ezb_zcl_attr_desc_t attr_desc; + uint8_t value[4]; +}; + class ZigbeeAttribute; class ZigbeeComponent final : public Component { public: - ZigbeeComponent(); void setup() override; void loop() override; void dump_config() override; @@ -88,7 +92,8 @@ class ZigbeeComponent final : public Component { // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - ezb_af_device_desc_t dev_desc_; + std::vector attr_values_; + ezb_af_device_desc_t dev_desc_ = ezb_af_create_device_desc(); CallbackManager join_cb_{}; LazyCallbackManager start_cb_{}; bool start_reported_{false}; @@ -96,6 +101,8 @@ class ZigbeeComponent final : public Component { std::atomic joined_ = false; std::atomic join_pending_ = false; std::atomic factory_new_ = false; + // TODO: remove when esp-zigbee-lib fixes set_value before init + bool string_attr_exists_(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id); }; template @@ -110,10 +117,22 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 // The size byte of the zcl_str must be set to the maximum value, // even though the initial string may be shorter. if constexpr (std::is_same::value) { + if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } + return; + } auto zcl_str = get_zcl_string(value.c_str(), max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; } else if constexpr (std::is_convertible::value) { + if (this->string_attr_exists_(endpoint_id, cluster_id, role, attr_id)) { + if (attr != nullptr) { + this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; + } + return; + } auto zcl_str = get_zcl_string(value, max_size, true); add_attr_(attr, endpoint_id, cluster_id, role, attr_id, zcl_str); delete[] zcl_str; @@ -133,7 +152,18 @@ void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint if (cluster_desc == NULL) { return; } - esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); + // TODO: revert when esp-zigbee-lib fixes set_value before init + ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); + + if (attr_desc != NULL) { + static_assert(sizeof(*value_p) <= 4); + AttrValue attr_value; + attr_value.attr_desc = attr_desc; + memcpy(&attr_value.value, value_p, sizeof(*value_p)); + attr_values_.push_back(attr_value); + } else { + esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); + } if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5fcad90a81a..fb752850817 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -52,8 +52,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -# Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing -KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count" +# Key for the controllers (APIServer, WebServer) that receive entity state updates +KEY_CONTROLLER_REGISTRY_CONTROLLERS = "controller_registry_controllers" # CORE.data key for the "is_rp2040 deprecation warning already fired this # run" flag. Mirrors the ``cv.only_on_rp2040`` dedupe pattern; cleared @@ -1209,10 +1209,9 @@ class EsphomeCore: if not self.platform_counts[platform_name]: self.platform_counts[platform_name] = 1 - def register_controller(self) -> None: - """Track registration of a Controller for ControllerRegistry StaticVector sizing.""" - controller_count = self.data.setdefault(KEY_CONTROLLER_REGISTRY_COUNT, 0) - self.data[KEY_CONTROLLER_REGISTRY_COUNT] = controller_count + 1 + def register_controller(self, controller: "MockObj") -> None: + """Register a controller that receives every entity state update.""" + self.data.setdefault(KEY_CONTROLLER_REGISTRY_CONTROLLERS, []).append(controller) @property def cpp_main_section(self): diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index fac09e9e145..3c11d4a880f 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/controller.h" +#include "esphome/core/entity_includes.h" #include "esphome/core/helpers.h" #ifdef USE_CAMERA diff --git a/esphome/core/config.py b/esphome/core/config.py index 355fc625236..bb3f03d3663 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -46,7 +46,7 @@ from esphome.const import ( ) from esphome.core import ( CORE, - KEY_CONTROLLER_REGISTRY_COUNT, + KEY_CONTROLLER_REGISTRY_CONTROLLERS, CoroPriority, coroutine_with_priority, ) @@ -680,12 +680,22 @@ async def _add_platform_defines() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def _add_controller_registry_define() -> None: - # Generate StaticVector size for ControllerRegistry - controller_count = CORE.data.get(KEY_CONTROLLER_REGISTRY_COUNT, 0) - if controller_count > 0: - cg.add_define("USE_CONTROLLER_REGISTRY") - cg.add_define("CONTROLLER_REGISTRY_MAX", controller_count) +async def _add_controller_registry_dispatch() -> None: + # controller_dispatch.h defines ControllerRegistry::notify_*() as direct + # calls on the controllers returned by esphome_controllers(), emitted as + # static auto esphome_controllers() { return std::tuple{a, b}; } + controllers = CORE.data.get(KEY_CONTROLLER_REGISTRY_CONTROLLERS) + if not controllers: + return + cg.add_define("USE_CONTROLLER_REGISTRY") + controllers = cg.ArrayInitializer(*controllers) + cg.add_global(cg.RawStatement("#include ")) + cg.add_global( + cg.RawStatement( + f"static auto esphome_controllers() {{ return std::tuple{controllers}; }}" + ) + ) + cg.add_global(cg.RawStatement('#include "esphome/core/controller_dispatch.h"')) @coroutine_with_priority(CoroPriority.FINAL) @@ -761,7 +771,7 @@ async def to_code(config: ConfigType) -> None: ) CORE.add_job(_add_platform_defines) - CORE.add_job(_add_controller_registry_define) + CORE.add_job(_add_controller_registry_dispatch) CORE.add_job(_add_looping_components) CORE.add_job(_add_automations, config) diff --git a/esphome/core/controller.h b/esphome/core/controller.h deleted file mode 100644 index 09975b465f8..00000000000 --- a/esphome/core/controller.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "esphome/core/entity_includes.h" - -namespace esphome { - -class Controller { - public: -// Controller virtual methods (generated from entity_types.h) -// NOLINTBEGIN(bugprone-macro-parentheses) -#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback -#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) virtual void on_##callback(type *obj){}; -#include "esphome/core/entity_types.h" -#undef ENTITY_TYPE_ -#undef ENTITY_CONTROLLER_TYPE_ - // NOLINTEND(bugprone-macro-parentheses) -}; - -} // namespace esphome diff --git a/esphome/core/controller_dispatch.h b/esphome/core/controller_dispatch.h new file mode 100644 index 00000000000..fc6a03556df --- /dev/null +++ b/esphome/core/controller_dispatch.h @@ -0,0 +1,50 @@ +#pragma once + +// Included once by the generated main.cpp, after it returns the registered controllers as a tuple: +// +// static auto esphome_controllers() { return std::tuple{api_apiserver_id, web_server_webserver_id}; } +// #include "esphome/core/controller_dispatch.h" +// +// Defines ControllerRegistry::notify_*() as direct calls on those controllers. Excluded from +// esphome.h and the clang-tidy all-headers file, so nothing else includes it. + +#include +#include + +#include "esphome/core/controller_registry.h" + +namespace esphome { + +// NOLINTBEGIN(bugprone-macro-parentheses) + +/// A controller provides a plain on_*_update() member for every entity type in the build. +template +concept ControllerContract = requires(T &controller) { + controller; // keeps the requirement list non-empty when no entity type has a callback +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + controller.on_##callback(static_cast(nullptr)); +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ +}; + +template constexpr bool controllers_satisfy_contract(std::tuple *) { + return (ControllerContract> && ...); +} +static_assert(controllers_satisfy_contract(static_cast(nullptr)), + "A registered controller is missing an on_*_update() callback for an entity type in this build " + "(ControllerContract in esphome/core/controller_dispatch.h)"); + +#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback +#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ + void ControllerRegistry::notify_##callback(type *obj) { \ + std::apply([obj](auto *...controller) { (controller->on_##callback(obj), ...); }, esphome_controllers()); \ + } +#include "esphome/core/entity_types.h" +#undef ENTITY_TYPE_ +#undef ENTITY_CONTROLLER_TYPE_ + +// NOLINTEND(bugprone-macro-parentheses) + +} // namespace esphome diff --git a/esphome/core/controller_registry.cpp b/esphome/core/controller_registry.cpp deleted file mode 100644 index 907e0f923d6..00000000000 --- a/esphome/core/controller_registry.cpp +++ /dev/null @@ -1,11 +0,0 @@ -#include "esphome/core/controller_registry.h" - -#ifdef USE_CONTROLLER_REGISTRY - -namespace esphome { - -StaticVector ControllerRegistry::controllers; - -} // namespace esphome - -#endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/controller_registry.h b/esphome/core/controller_registry.h index c6113116ffd..8b1b6c7f9d5 100644 --- a/esphome/core/controller_registry.h +++ b/esphome/core/controller_registry.h @@ -5,37 +5,18 @@ #ifdef USE_CONTROLLER_REGISTRY #include "esphome/core/entity_includes.h" -#include "esphome/core/helpers.h" namespace esphome { -class Controller; - -/** Global registry for Controllers to receive entity state updates. +/** Fan-out of entity state updates to the controllers (APIServer, WebServer). * - * This singleton registry allows Controllers (APIServer, WebServer) to receive - * entity state change notifications without storing per-entity callbacks. - * - * Instead of each entity maintaining controller callbacks (32 bytes overhead per entity), - * entities call ControllerRegistry::notify_*_update() which iterates the small list - * of registered controllers (typically 2: API and WebServer). - * - * Each notify method directly iterates controllers and calls the virtual method, - * avoiding function pointer indirection for minimal dispatch overhead. - * - * Memory savings: 32 bytes per entity (2 controllers × 16 bytes std::function overhead) - * Typical config (25 entities): ~780 bytes saved - * Large config (80 entities): ~2,540 bytes saved + * Entities call ControllerRegistry::notify_*_update() instead of holding + * per-entity controller callbacks. The functions are only declared here; + * controller_dispatch.h, included by the generated main.cpp, defines them as + * direct calls on the controllers registered through CORE.register_controller(). */ class ControllerRegistry { public: - /** Register a controller to receive entity state updates. - * - * Controllers should call this in their setup() method. - * Typically only APIServer and WebServer register. - */ - static void register_controller(Controller *controller) { controllers.push_back(controller); } - // Notify method declarations (generated from entity_types.h) // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback @@ -45,38 +26,8 @@ class ControllerRegistry { #undef ENTITY_TYPE_ #undef ENTITY_CONTROLLER_TYPE_ // NOLINTEND(bugprone-macro-parentheses) - - protected: - static StaticVector controllers; }; } // namespace esphome -// Include controller.h AFTER the class definition so notify methods can be -// defined inline. This is safe because controller_registry.h is only ever -// included from .cpp files, never from other headers. -#include "esphome/core/controller.h" - -namespace esphome { - -// Inline notify methods — each is a tiny loop over 1-2 controllers. -// Defining them here (rather than in controller_registry.cpp) allows the -// compiler to inline them into the single call site in each entity's -// notify_frontend_(), eliminating an unnecessary function-call frame. - -// NOLINTBEGIN(bugprone-macro-parentheses) -#define ENTITY_TYPE_(type, singular, plural, count, upper) // no controller callback -#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ - inline void ControllerRegistry::notify_##callback(type *obj) { \ - for (auto *controller : controllers) { \ - controller->on_##callback(obj); \ - } \ - } -#include "esphome/core/entity_types.h" -#undef ENTITY_TYPE_ -#undef ENTITY_CONTROLLER_TYPE_ -// NOLINTEND(bugprone-macro-parentheses) - -} // namespace esphome - #endif // USE_CONTROLLER_REGISTRY diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7bc08ea5778..e1ae1d5fb60 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -629,7 +629,6 @@ #define USE_DASHBOARD_IMPORT // Default counts for static analysis -#define CONTROLLER_REGISTRY_MAX 2 #define ESPHOME_AREA_COUNT 10 #define ESPHOME_COMPONENT_COUNT 50 #define ESPHOME_DEVICE_COUNT 10 diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index cfc92932a9d..13ba0a9791c 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2066,6 +2066,32 @@ class LwIPLock { #endif }; +#if defined(USE_ESP8266) && F_CPU != 160000000L +// Forward decl from +// NOLINTNEXTLINE(readability-redundant-declaration) +extern "C" bool system_update_cpu_freq(uint8_t freq); +#endif + +/** Runs the CPU at 160 MHz while alive. ESP8266 built for 80 MHz only; elsewhere it compiles to nothing. + * + * The core resets the clock before every loop() pass, so a scope must stay within one pass, must not nest and + * must not yield to the main loop. Peripheral clocks are unchanged, but the cycle counter runs twice as fast, so + * code that times itself against F_CPU, including ISRs that fire while a scope is open, must read CPU2X. + */ +class CpuFrequencyBoost { + public: + CpuFrequencyBoost(const CpuFrequencyBoost &) = delete; + CpuFrequencyBoost &operator=(const CpuFrequencyBoost &) = delete; +#if defined(USE_ESP8266) && F_CPU != 160000000L + CpuFrequencyBoost() { system_update_cpu_freq(160); } + ~CpuFrequencyBoost() { system_update_cpu_freq(80); } +#else + // Not = default, so clang-tidy does not flag unused variables at call sites + CpuFrequencyBoost() {} + ~CpuFrequencyBoost() {} +#endif +}; + /** Helper class to request `loop()` to be called as fast as possible. * * Usually the ESPHome main loop runs at 60 Hz, sleeping in between invocations of `loop()` if necessary. When a higher diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 43011083159..87629c2507d 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -14,11 +14,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size } if (this->stack_buffer_ != nullptr && (stack_size > this->stack_size_ || use_psram != this->use_psram_)) { - // Existing buffer is too small or wrong memory type; deallocate to reallocate below - RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL - : RAMAllocator::ALLOC_INTERNAL); - allocator.deallocate(this->stack_buffer_, this->stack_size_); - this->stack_buffer_ = nullptr; + // Existing buffer is too small or wrong memory type; free it to reallocate below + this->stack_buffer_.reset(); } if (this->stack_buffer_ == nullptr) { @@ -26,13 +23,23 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size this->use_psram_ = use_psram; RAMAllocator allocator(use_psram ? RAMAllocator::ALLOC_EXTERNAL : RAMAllocator::ALLOC_INTERNAL); - this->stack_buffer_ = allocator.allocate(stack_size); + this->stack_buffer_ = allocator.make_unique_array_for_overwrite(stack_size); } if (this->stack_buffer_ == nullptr) { return false; } - this->handle_ = xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_, &this->tcb_); + if (this->tcb_ == nullptr) { + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->tcb_ = allocator.make_unique(); + } + if (this->tcb_ == nullptr) { + this->deallocate(); + return false; + } + + this->handle_ = + xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); if (this->handle_ == nullptr) { this->deallocate(); return false; @@ -65,13 +72,9 @@ bool StaticTask::deallocate() { if (!this->destroy()) { return false; } - if (this->stack_buffer_ != nullptr) { - RAMAllocator allocator(this->use_psram_ ? RAMAllocator::ALLOC_EXTERNAL - : RAMAllocator::ALLOC_INTERNAL); - allocator.deallocate(this->stack_buffer_, this->stack_size_); - this->stack_buffer_ = nullptr; - this->stack_size_ = 0; - } + this->stack_buffer_.reset(); + this->stack_size_ = 0; + this->tcb_.reset(); return true; } diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index e2996abedae..10c55f70c62 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -5,49 +5,58 @@ #include #include +#include "esphome/core/helpers.h" + #include namespace esphome { /** Helper for FreeRTOS static task management. - * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. + * Stores the TCB in internal RAM and manages the stack buffer with create/destroy methods. * Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on. */ class StaticTask { public: + StaticTask() = default; + // Non-copyable, non-movable — the task handle and its buffers are not transferable + StaticTask(const StaticTask &) = delete; + StaticTask &operator=(const StaticTask &) = delete; + StaticTask(StaticTask &&) = delete; + StaticTask &operator=(StaticTask &&) = delete; + /// @brief Check if the task has been created and not yet destroyed. bool is_created() const { return this->handle_ != nullptr; } /// @brief Get the FreeRTOS task handle. TaskHandle_t get_handle() const { return this->handle_; } - /// @brief Allocate stack and create task. + /// @brief Allocate the TCB (always internal RAM) and the stack, then create the task. /// @param fn Task function /// @param name Task name (for debug) /// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM - /// @return true on success + /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool use_psram); - /// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call. + /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is /// suspended here so that it cannot be scheduled again, and it is given no chance to clean up. /// @return true if the task was deleted; false if it is still running on another core, in which case the /// caller should try again later. bool destroy(); - /// @brief Delete the task (if created) and free the stack buffer. - /// @return true if the stack buffer was freed; false if the task is still running on another core, in + /// @brief Delete the task (if created) and free the TCB and stack buffer. + /// @return true if the buffers were freed; false if the task is still running on another core, in /// which case the caller should try again later. bool deallocate(); protected: TaskHandle_t handle_{nullptr}; - StaticTask_t tcb_; - StackType_t *stack_buffer_{nullptr}; + RAMUniquePtr tcb_; + RAMUniquePtr stack_buffer_; uint32_t stack_size_{0}; bool use_psram_{false}; }; diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 45d6559b3f4..04892f5feaa 100644 --- a/esphome/cpp_types.py +++ b/esphome/cpp_types.py @@ -37,7 +37,6 @@ optional = global_ns.namespace("std").class_("optional") arduino_json_ns = global_ns.namespace("ArduinoJson") JsonObject = arduino_json_ns.class_("JsonObject") JsonObjectConst = arduino_json_ns.class_("JsonObjectConst") -Controller = esphome_ns.class_("Controller") GPIOPin = esphome_ns.class_("GPIOPin") InternalGPIOPin = esphome_ns.class_("InternalGPIOPin", GPIOPin) gpio_ns = esphome_ns.namespace("gpio") diff --git a/esphome/espota2.py b/esphome/espota2.py index c683ffa323c..952f88fc4b6 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -211,6 +211,27 @@ class OTAEncryptionFallback(OTAError): """The encrypted attempt failed and the caller may retry in plaintext.""" +# Uploader side option under `ota: encryption:`; the ota component imports the +# name so the upload path never loads the component module +CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" +ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( + f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " + "migrates a device which never encrypted. If this device encrypted before, " + "something on the network stripped the offer: remove the option and check " + "the network." +) +# Logged only once the device is seen encrypting, so the migration install +# itself is never nagged and the user learns exactly when removal is safe +ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING = f""" +****************************************************************** +* This device offers OTA encryption and accepted the key, so +* '{CONF_ALLOW_PLAINTEXT_UPLOAD}' under 'ota: encryption:' has done +* its job. Remove it from the configuration now, together with +* any 'password:' on that block: leaving the option in place lets +* an attacker on the network strip the encryption offer and +* downgrade a future upload to plaintext. +******************************************************************""" + # Remove before 2027.3.0 PLAINTEXT_FALLBACK_NOTICE = ( "A device with an api encryption key offers encryption after this " @@ -512,6 +533,7 @@ def perform_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: # Validate up front; an out-of-range value would only surface as a # ValueError deep inside send_check, bypassing OTAError handling @@ -577,25 +599,32 @@ def perform_ota( features = 0 if noise_psk and not (extended_proto and features & SERVER_FEATURE_SUPPORTS_NOISE): - if plaintext_fallback: - # Remove before 2027.3.0: older firmware that cannot encrypt still - # gets its update on this connection + # Remove before 2027.3.0: drop `or plaintext_fallback` and + # PLAINTEXT_FALLBACK_NOTICE here; allow_plaintext_upload stays + if allow_plaintext_upload or plaintext_fallback: + # The running firmware cannot encrypt; it still gets this update, + # and the build being sent offers encryption for the next one _LOGGER.warning( "The device did not offer OTA encryption; continuing in plaintext. %s", - PLAINTEXT_FALLBACK_NOTICE, + ALLOW_PLAINTEXT_UPLOAD_NOTICE + if allow_plaintext_upload + else PLAINTEXT_FALLBACK_NOTICE, ) noise_psk = None else: # Fail closed: an attacker could otherwise strip the offer and # capture the image (wifi credentials, api key) + # Remove before 2027.3.0: installing without the block no longer + # falls back then; advise 'allow_plaintext_upload: true' instead raise OTAError( "An OTA encryption key is configured but the device did not " "offer encryption; refusing to send the image in plaintext. " "The running firmware predates ESPHome 2026.9.0 or has no " "'api: encryption: key'. With an api key, install once " "without the 'ota: encryption:' block (that build offers " - "encryption), then restore it; otherwise flash by serial or " - "the web_server OTA platform." + f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " + "true' under 'ota: encryption:' for this one install, or flash by " + "serial or the web_server OTA platform." ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -619,6 +648,8 @@ def perform_ota( raise OTAEncryptionFallback(str(err)) from err raise _LOGGER.info("Encrypted connection established") + if allow_plaintext_upload: + _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -824,6 +855,7 @@ def run_ota_impl_( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: from esphome.core import CORE @@ -899,6 +931,7 @@ def run_ota_impl_( ota_type, encryption.noise_psk, encryption.plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) except OTAEncryptionFallback as err: # Same address and attempt budget: not a network retry @@ -940,6 +973,7 @@ def run_ota( ota_type: int = OTA_TYPE_UPDATE_APP, noise_psk: str | None = None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> tuple[int, str | None]: try: return run_ota_impl_( @@ -950,6 +984,7 @@ def run_ota( ota_type, noise_psk, plaintext_fallback, + allow_plaintext_upload, ) except OTAError as err: _LOGGER.error(err) diff --git a/esphome/writer.py b/esphome/writer.py index 68f9950cbb3..c5c9b4a5cc1 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -212,6 +212,16 @@ VERSION_H_TARGET = "esphome/core/version.h" BUILD_INFO_DATA_H_TARGET = "esphome/core/build_info_data.h" BUILD_INFO_DATA_CPP_TARGET = "esphome/core/build_info_data.cpp" ENTITY_TYPES_H_TARGET = "esphome/core/entity_types.h" +# Headers that must not be included bare from esphome.h or the clang-tidy +# all-headers file: X-macro files, headers main.cpp includes itself, and +# deprecated headers that only resolve when their new component is loaded. +ESPHOME_H_EXCLUDE = { + Path(ENTITY_TYPES_H_TARGET), + # main.cpp includes it after defining esphome_controllers() + Path("esphome/core/controller_dispatch.h"), + # moved to components/ring_buffer/, removed in 2026.11.0 + Path("esphome/core/ring_buffer.h"), +} ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -237,20 +247,9 @@ def copy_src_tree(): source_files_l.sort() # Build #include list for esphome.h - # X-macro files are included multiple times with different macro definitions - # and must not be included bare in esphome.h - # Deprecated headers that re-export from a relocated component must not be - # auto-included, since their #include of the new path only resolves when the - # new component is loaded by a consumer. - esphome_h_exclude = { - Path(ENTITY_TYPES_H_TARGET), - Path( - "esphome/core/ring_buffer.h" - ), # moved to components/ring_buffer/, removed in 2026.11.0 - } include_l = [] for target, _ in source_files_l: - if target.suffix in HEADER_FILE_EXTENSIONS and target not in esphome_h_exclude: + if target.suffix in HEADER_FILE_EXTENSIONS and target not in ESPHOME_H_EXCLUDE: include_l.append(f'#include "{target}"') include_l.append("") include_s = "\n".join(include_l) diff --git a/requirements.txt b/requirements.txt index c810755817f..f678a9e5e9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ ninja==1.13.2 # native esp8266 arduino toolchain build driver filelock==4.0.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this -pyparsing >= 3.3.2 +pyparsing >= 3.3.3 # For autocompletion argcomplete>=3.7.2 diff --git a/script/ci-custom.py b/script/ci-custom.py index bdf7750ce8f..aaf177c941d 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -164,7 +164,21 @@ def lint_post_check(func): return func -def lint_re_check(regex, **kwargs): +def _nolint_in_match(content, haystack, match, mask): + """With masking, only a trailing comment counts: the raw span still holds string contents, and + the masked text is blank exactly where comments and strings were, so NOLINT must sit after the + last real code character of the span.""" + raw = content[match.start() : match.end()] + if not mask: + return "NOLINT" in raw + masked = haystack[match.start() : match.end()].rstrip() + return "NOLINT" in raw[len(masked) :] + + +def lint_re_check(regex, mask=False, prefilter=None, **kwargs): + """mask=True blanks comments and string literals first so prose about the pattern is not reported; + the masked text keeps its length, so match offsets still index the original content. + prefilter is a literal every match must contain, checked before the costlier masking.""" flags = kwargs.pop("flags", re.MULTILINE) prog = re.compile(regex, flags) decor = lint_content_check(**kwargs) @@ -173,8 +187,11 @@ def lint_re_check(regex, **kwargs): @functools.wraps(func) def new_func(fname, content): errs = [] - for match in prog.finditer(content): - if "NOLINT" in match.group(0): + if prefilter is not None and prefilter not in content: + return errs + haystack = _mask_cpp_comments_strings(content) if mask else content + for match in prog.finditer(haystack): + if _nolint_in_match(content, haystack, match, mask): continue lineno = content.count("\n", 0, match.start()) + 1 substr = content[: match.start()] @@ -1199,6 +1216,25 @@ def lint_no_std_bind(fname, match): ) +@lint_re_check( + r"[^\w]std\s*::\s*nothrow\b" + CPP_RE_EOL, + mask=True, + prefilter="nothrow", + include=cpp_include, +) +def lint_no_std_nothrow(fname, match): + return ( + f"{highlight('new (std::nothrow)')} aborts on ESP-IDF when the allocation fails, exceptions are disabled " + f"there, so it never returns nullptr.\n" + f"Please use {highlight('RAMAllocator')} from esphome/core/helpers.h, which does.\n" + f" Before: {highlight('auto *buf = new (std::nothrow) uint8_t[n];')}\n" + f" After: {highlight('auto buf = RAMAllocator().make_unique_array_for_overwrite(n);')}\n" + f"For one object use {highlight('RAMAllocator().make_unique(args...)')}; both return empty on failure.\n" + f"Default flags prefer PSRAM; pass RAMAllocator::PREFER_INTERNAL to keep it where new put it.\n" + f"(If strictly necessary, add `// NOLINT` to the end of the line)" + ) + + LOG_CALL_START_RE = re.compile(r"ESP_LOG\w+\s*\(") # Comments, raw/plain string literals and single char literals are consumed whole so ; ( ) ? : # inside them are never seen. A char literal is exactly one (escaped) char so a digit separator diff --git a/script/helpers.py b/script/helpers.py index a8a237118f9..fb2c964bdd9 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -429,11 +429,9 @@ def build_all_include(header_files: list[str] | None = None) -> None: if line ] - from esphome.writer import ENTITY_TYPES_H_TARGET + from esphome.writer import ESPHOME_H_EXCLUDE - # X-macro files are included multiple times with different macro definitions - # and must not be included bare in the all-include header - exclude = {ENTITY_TYPES_H_TARGET} + exclude = {path.as_posix() for path in ESPHOME_H_EXCLUDE} headers = [f'#include "{h}"' for h in header_files if h not in exclude] headers.sort() headers.append("") diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml new file mode 100644 index 00000000000..bcfc1eaf4c8 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_disabled.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_cache_in_psram: false + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml new file mode 100644 index 00000000000..d73b5045da0 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted.yaml @@ -0,0 +1,14 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml new file mode 100644 index 00000000000..3e1b32ba410 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_encrypted_sdkconfig.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + sdkconfig_options: + CONFIG_NVS_ENCRYPTION: "y" + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml new file mode 100644 index 00000000000..86174146d67 --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_explicit.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_cache_in_psram: true + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml new file mode 100644 index 00000000000..618d265cc0a --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_guaranteed.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml new file mode 100644 index 00000000000..b52255c5adb --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_not_guaranteed.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal diff --git a/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml b/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml new file mode 100644 index 00000000000..ab4043ccacc --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_cache_psram_user_off.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + sdkconfig_options: + CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM: "n" + +psram: + mode: octal + ignore_not_found: false diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 08f5dcc8227..b41234db1a0 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -4,6 +4,7 @@ Test ESP32 configuration import asyncio from collections.abc import Callable +import logging from pathlib import Path from typing import Any @@ -1575,3 +1576,137 @@ def test_vasprintf_stub_only_on_rom_vsnprintf_variants( assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected defines = {define.name for define in CORE.defines} assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected + + +@pytest.mark.parametrize( + ("fixture", "expected"), + [ + ("nvs_cache_psram_guaranteed.yaml", True), + ("nvs_cache_psram_explicit.yaml", True), + ("nvs_cache_psram_not_guaranteed.yaml", None), + ("nvs_cache_psram_disabled.yaml", None), + # the encryption keys must stay in internal RAM, whichever way encryption is enabled + ("nvs_cache_psram_encrypted.yaml", None), + ("nvs_cache_psram_encrypted_sdkconfig.yaml", None), + ], +) +def test_nvs_cache_in_psram_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + expected: bool | None, +) -> None: + """The NVS cache moves to PSRAM only with guaranteed PSRAM, the option not off and no NVS encryption.""" + generate_main(component_config_path(fixture)) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is expected + + +def test_nvs_cache_in_psram_user_sdkconfig_wins( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A raw sdkconfig_options value for the NVS cache option is left alone.""" + generate_main(component_config_path("nvs_cache_psram_user_off.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM"] == RawSdkconfigValue("n") + + +@pytest.mark.parametrize( + ("full_config", "error_match"), + [ + pytest.param( + {CONF_ESPHOME: {}, "psram": {"disabled": False, "ignore_not_found": True}}, + r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'", + id="nvs_cache_in_psram_needs_guaranteed_psram", + ), + pytest.param( + {CONF_ESPHOME: {}}, + r"'nvs_cache_in_psram' requires PSRAM with 'ignore_not_found: false'", + id="nvs_cache_in_psram_needs_psram", + ), + ], +) +def test_nvs_cache_in_psram_explicit_true_errors( + full_config: dict, error_match: str, set_core_config: SetCoreConfigCallable +) -> None: + """An explicit nvs_cache_in_psram: true that cannot apply is a config error, not a silent no-op.""" + set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config) + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + + config = { + "variant": "esp32s3", + "framework": {"type": "esp-idf", "advanced": {"nvs_cache_in_psram": True}}, + } + with pytest.raises(cv.Invalid, match=error_match): + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_nvs_cache_in_psram_explicit_true_rejects_encryption( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={ + CONF_ESPHOME: {}, + "psram": {"disabled": False, "ignore_not_found": False}, + }, + ) + from esphome.components.esp32 import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + + config = { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_cache_in_psram": True, "nvs_encryption": {"key_id": 0}}, + }, + } + with pytest.raises(cv.Invalid, match="cannot be used with NVS encryption"): + FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_nvs_cache_in_psram_default_with_encryption_is_quiet( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Encryption on a board that never mentioned the option must not warn about it.""" + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("nvs_cache_psram_encrypted.yaml")) + assert "nvs_cache_in_psram" not in caplog.text + + +@pytest.mark.asyncio +async def test_nvs_cache_in_psram_explicit_request_warns_when_encrypted( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """An explicit request dropped for NVS encryption enabled elsewhere logs a warning.""" + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_SDKCONFIG_OPTIONS: {}} + ) + from esphome.components.esp32 import ( + _apply_nvs_cache_in_psram, + add_idf_sdkconfig_option, + ) + + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + with caplog.at_level(logging.WARNING): + await _apply_nvs_cache_in_psram(True) + assert "nvs_cache_in_psram ignored" in caplog.text + assert ( + "CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM" + not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + ) + + +def test_nvs_cache_in_psram_explicit_true_on_valid_board_is_quiet( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """An explicit true that applies sets the option and warns about nothing.""" + with caplog.at_level(logging.WARNING): + generate_main(component_config_path("nvs_cache_psram_explicit.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is True + assert "nvs_cache_in_psram" not in caplog.text diff --git a/tests/component_tests/ota/test_esphome_ota.py b/tests/component_tests/ota/test_esphome_ota.py index d8bcd7e2758..cd66503c1bb 100644 --- a/tests/component_tests/ota/test_esphome_ota.py +++ b/tests/component_tests/ota/test_esphome_ota.py @@ -12,10 +12,11 @@ from esphome import config_validation as cv from esphome.components.esphome.ota import ( AUTO_LOAD, FILTER_SOURCE_FILES, + _encryption_schema, _validate_no_password_with_encryption, ota_esphome_final_validate, ) -from esphome.components.noise import static_encryption_key +from esphome.components.noise import encryption_schema, static_encryption_key from esphome.const import ( CONF_API, CONF_ENCRYPTION, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_VERSION, ) from esphome.core import CORE, ID +from esphome.espota2 import CONF_ALLOW_PLAINTEXT_UPLOAD import esphome.final_validate as fv @@ -198,6 +200,21 @@ def test_encryption_without_any_key_rejected() -> None: fv.full_config.reset(token) +def test_encryption_schema_allow_plaintext_upload() -> None: + """The opt in is an ota only option with no default, so a merged block + that does not mention it cannot clear it; the shared api schema does not + know it.""" + assert _encryption_schema(None) == {} + assert _encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) == { + CONF_ALLOW_PLAINTEXT_UPLOAD: True + } + assert _encryption_schema({CONF_KEY: API_KEY}) == {CONF_KEY: API_KEY} + with pytest.raises(cv.Invalid): + _encryption_schema(False) + with pytest.raises(cv.Invalid): + encryption_schema({CONF_ALLOW_PLAINTEXT_UPLOAD: True}) + + def test_encryption_key_mismatch_between_merged_configs_rejected() -> None: """Same-port configs with different encryption keys raise.""" full_conf = { @@ -489,6 +506,13 @@ def test_static_encryption_key() -> None: {"USE_OTA_ENCRYPTION", "USE_OTA_PASSWORD"}, {"USE_OTA_ENCRYPTION_REQUIRED", "USE_OTA_ENCRYPTION_PROVISIONED"}, ), + # The migration install keeps the password for the old firmware's + # prompt but the build it sends is authenticated by the key alone + ( + "migration_password", + {"USE_OTA_ENCRYPTION", "USE_OTA_ENCRYPTION_REQUIRED"}, + {"USE_OTA_PASSWORD", "USE_OTA_ENCRYPTION_PROVISIONED"}, + ), # The ota encryption block is what makes the device refuse plaintext ( "encryption_required", @@ -549,6 +573,16 @@ def test_password_with_encryption_rejected() -> None: _validate_no_password_with_encryption(config) +def test_password_with_migration_install_accepted() -> None: + """The old firmware may still ask for the password on the plaintext + leg of the migration install.""" + config = { + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: API_KEY, CONF_ALLOW_PLAINTEXT_UPLOAD: True}, + } + assert _validate_no_password_with_encryption(config) is config + + def test_password_alone_accepted() -> None: """A password without encryption still validates.""" config = {CONF_PASSWORD: "pw"} diff --git a/tests/component_tests/ota/test_esphome_ota_migration_password.yaml b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml new file mode 100644 index 00000000000..6a44f75751b --- /dev/null +++ b/tests/component_tests/ota/test_esphome_ota_migration_password.yaml @@ -0,0 +1,14 @@ +esphome: + name: ota-migration-password + +host: + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index 2aa558726c3..a5e3731c751 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -1,5 +1,4 @@ import esphome.codegen as cg -from esphome.core import CORE from tests.testing_helpers import ComponentManifestOverride @@ -12,6 +11,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") - CORE.register_controller() # api_server registers with the controller registry manifest.to_code = to_code_testing diff --git a/tests/components/button/common.yaml b/tests/components/button/common.yaml index d5978601f43..352a4c29234 100644 --- a/tests/components/button/common.yaml +++ b/tests/components/button/common.yaml @@ -4,3 +4,9 @@ button: id: some_button on_press: - logger.log: Button pressed + +esphome: + on_boot: + - button.press: some_button + - button.press: + id: some_button diff --git a/tests/components/deep_sleep/common-esp32-all.yaml b/tests/components/deep_sleep/common-esp32-all.yaml index 9dc2f872588..68ef8f7b584 100644 --- a/tests/components/deep_sleep/common-esp32-all.yaml +++ b/tests/components/deep_sleep/common-esp32-all.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common-esp32-ext1.yaml b/tests/components/deep_sleep/common-esp32-ext1.yaml index c531d447438..e22436dd648 100644 --- a/tests/components/deep_sleep/common-esp32-ext1.yaml +++ b/tests/components/deep_sleep/common-esp32-ext1.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common-esp32.yaml b/tests/components/deep_sleep/common-esp32.yaml index e670787cc0b..248f527616d 100644 --- a/tests/components/deep_sleep/common-esp32.yaml +++ b/tests/components/deep_sleep/common-esp32.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: default: 10s gpio_wakeup_reason: 30s diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index 7a1a709965b..6c6222ff875 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -4,6 +4,10 @@ esphome: - deep_sleep.prevent - delay: 1s - deep_sleep.allow + - deep_sleep.prevent: + id: deep_sleep_1 + - deep_sleep.allow: + id: deep_sleep_1 - if: condition: lambda: 'return false;' diff --git a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml index 485490576d8..b59b927e0a1 100644 --- a/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml +++ b/tests/components/deep_sleep/test-ota-rollback.nrf52-mcumgr.yaml @@ -6,6 +6,7 @@ packages: deep_sleep: !include common.yaml deep_sleep: + id: deep_sleep_1 run_duration: 10s zephyr_ble_server: diff --git a/tests/components/deep_sleep/test.bk72xx-ard.yaml b/tests/components/deep_sleep/test.bk72xx-ard.yaml index bdbd27c902e..df0c85d7088 100644 --- a/tests/components/deep_sleep/test.bk72xx-ard.yaml +++ b/tests/components/deep_sleep/test.bk72xx-ard.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 30s sleep_duration: 12h on_wake: diff --git a/tests/components/deep_sleep/test.esp8266-ard.yaml b/tests/components/deep_sleep/test.esp8266-ard.yaml index e4c592c0951..8e5b1ca4f72 100644 --- a/tests/components/deep_sleep/test.esp8266-ard.yaml +++ b/tests/components/deep_sleep/test.esp8266-ard.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 10s sleep_duration: 50s on_wake: diff --git a/tests/components/deep_sleep/test.nrf52-adafruit.yaml b/tests/components/deep_sleep/test.nrf52-adafruit.yaml index 6362142be2e..bfc89303a93 100644 --- a/tests/components/deep_sleep/test.nrf52-adafruit.yaml +++ b/tests/components/deep_sleep/test.nrf52-adafruit.yaml @@ -1,4 +1,5 @@ deep_sleep: + id: deep_sleep_1 run_duration: 10s sleep_duration: 50s diff --git a/tests/components/fan/common.yaml b/tests/components/fan/common.yaml index 4867804394a..12c7b6c8726 100644 --- a/tests/components/fan/common.yaml +++ b/tests/components/fan/common.yaml @@ -104,6 +104,9 @@ button: fan.is_on: test_fan then: - fan.turn_off: test_fan + - fan.toggle: test_fan + - fan.turn_off: + id: test_fan - if: condition: fan.is_off: diff --git a/tests/components/lock/common.yaml b/tests/components/lock/common.yaml index 2b360ce3fbf..397130dee5d 100644 --- a/tests/components/lock/common.yaml +++ b/tests/components/lock/common.yaml @@ -3,7 +3,8 @@ esphome: then: - lock.lock: test_lock1 - lock.unlock: test_lock1 - - lock.open: test_lock1 + - lock.open: + id: test_lock1 - if: condition: lock.is_locked: test_lock1 diff --git a/tests/components/microphone/common-actions.yaml b/tests/components/microphone/common-actions.yaml new file mode 100644 index 00000000000..885282bcdc7 --- /dev/null +++ b/tests/components/microphone/common-actions.yaml @@ -0,0 +1,12 @@ +# capture, stop_capture and is_capturing on the external microphone. +button: + - platform: template + name: Microphone Capture + on_press: + - if: + condition: + microphone.is_capturing: mic_id_external + then: + - microphone.stop_capture: mic_id_external + else: + - microphone.capture: mic_id_external diff --git a/tests/components/microphone/test-actions.esp32-idf.yaml b/tests/components/microphone/test-actions.esp32-idf.yaml new file mode 100644 index 00000000000..c2f27a42861 --- /dev/null +++ b/tests/components/microphone/test-actions.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + i2s_din_pin1: GPIO33 + i2s_din_pin2: GPIO34 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + microphone: !include common-pdm.yaml + actions: !include common-actions.yaml diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index a1d27cdbd52..5ed997bfedb 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -101,6 +101,13 @@ button: payload: Hello qos: 2 retain: true + - mqtt.publish: + topic: !lambda return "some/topic/button/lambda"; + payload: !lambda |- + std::string payload = "Hello"; + return payload; + qos: !lambda return 1; + retain: !lambda return false; climate: - platform: thermostat diff --git a/tests/components/ota/allow_plaintext_upload.yaml b/tests/components/ota/allow_plaintext_upload.yaml new file mode 100644 index 00000000000..be222ec4332 --- /dev/null +++ b/tests/components/ota/allow_plaintext_upload.yaml @@ -0,0 +1,13 @@ +wifi: + ssid: MySSID + password: password1 + +api: + encryption: + key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + +ota: + - platform: esphome + password: "superlongpasswordthatnoonewillknow" + encryption: + allow_plaintext_upload: true diff --git a/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml new file mode 100644 index 00000000000..834e6197acc --- /dev/null +++ b/tests/components/ota/validate-allow_plaintext_upload.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + ota: !include allow_plaintext_upload.yaml diff --git a/tests/components/safe_mode/common-enabled.yaml b/tests/components/safe_mode/common-enabled.yaml index 43025c60dbd..b2a10b6d93f 100644 --- a/tests/components/safe_mode/common-enabled.yaml +++ b/tests/components/safe_mode/common-enabled.yaml @@ -3,6 +3,7 @@ wifi: password: password1 safe_mode: + id: safe_mode_1 boot_is_good_after: 2min num_attempts: 3 reboot_timeout: 2min @@ -20,3 +21,5 @@ switch: esphome: on_boot: - safe_mode.mark_successful + - safe_mode.mark_successful: + id: safe_mode_1 diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index f4818e22962..6b7290277a1 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -15,6 +15,8 @@ esphome: strings: ["a", "b"] - script.wait: my_script - script.stop: my_script + - script.stop: + id: my_script_queued - if: condition: - script.is_running: my_script diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04d..87dfb0add6f 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,9 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + +wifi: + on_connect: + then: + - sendspin.media_source.enable_static_delay_adjustment: media_source_id + - sendspin.media_source.disable_static_delay_adjustment: media_source_id diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 88eb0168e36..56a685eac3a 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -167,6 +167,33 @@ class _Device: assert self.proc.returncode is None, "process died on rejected OTA" +def _handshake_then_close(port: int, noise_psk: str) -> None: + """Negotiate and complete the Noise handshake like a key probe, then + hang up without sending an OTA type.""" + with socket.create_connection((LOCALHOST, port), timeout=5.0) as sock: + espota2.send_check(sock, espota2.MAGIC_BYTES, "magic bytes") + _, version = espota2.receive_exactly(sock, 2, "version", espota2.RESPONSE_OK) + features_to_send = ( + espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION + | espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH + | espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL + | espota2.CLIENT_FEATURE_SUPPORTS_NOISE + ) + espota2.send_check(sock, features_to_send, "features") + espota2.receive_exactly(sock, 1, "features", espota2.RESPONSE_FEATURE_FLAGS) + (features,) = espota2.receive_exactly(sock, 1, "feature flags", None) + assert features & espota2.SERVER_FEATURE_SUPPORTS_NOISE + prologue = ( + espota2.NOISE_PROLOGUE_INIT + + bytes(espota2.MAGIC_BYTES) + + bytes([espota2.RESPONSE_OK, version, features_to_send]) + + bytes([espota2.RESPONSE_FEATURE_FLAGS, features]) + ) + noise = espota2.NoiseSocketWrapper(sock, noise_psk, prologue) + noise.do_handshake() + espota2.receive_exactly(noise, 1, "auth", espota2.RESPONSE_AUTH_OK) + + async def _provision_key( dev: _Device, api_client_connected: APIClientConnectedFactory ) -> None: @@ -221,16 +248,24 @@ async def test_host_ota_encrypted( compile_esphome: CompileFunction, reserved_tcp_port: tuple[int, socket.socket], ) -> None: - """Encrypted self-OTA succeeds; a plaintext upload to the same device fails.""" + """A client that leaves right after the handshake, as a key probe does, + is a clean close, not an OTA error; a plaintext upload is refused; an + encrypted self-OTA succeeds.""" pytest.importorskip("aioesphomeapi.noise") dev = _Device( *await _build( yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port ) ) - async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, _lines): + async with run_binary(dev.binary_path, line_callback=dev.on_log) as (proc, lines): dev.proc = proc await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await asyncio.get_running_loop().run_in_executor( + None, _handshake_then_close, dev.ota_port, API_KEY + ) + # The error path logs its warning instead of this line, never after it + await _wait_for_line(lines, "Client left after the handshake") + assert not [line for line in lines if "[W][esphome.ota" in line] await dev.refused_ota( None, None, "plaintext upload to an encrypted device must fail" ) diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index be2c98051ff..5a943cda083 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -1,4 +1,6 @@ -"""Unit tests for the ESP_LOG-needs-braces lint rule in script/ci-custom.py. +"""Unit tests for the ESP_LOG-needs-braces and std::nothrow lint rules in script/ci-custom.py. + +The nothrow rule is a masked lint_re_check, so its tests also pin the decorator's mask option. The rule flags an if/else/for/while whose only body is an unbraced ESP_LOG*() call (which becomes an empty statement -- and a -Wempty-body warning -- once the log level compiles the macro out). These @@ -151,6 +153,43 @@ def test_nolint_on_control_line_suppresses() -> None: assert not _lint("if (x) // NOLINT\n ESP_LOGD(t);\n") +# --- std::nothrow --- + + +def _lint_nothrow(content: str) -> list: + return ci_custom.lint_no_std_nothrow("test.cpp", content) + + +def test_nothrow_is_reported_at_its_line_and_column_and_points_at_ramallocator() -> ( + None +): + errors = _lint_nothrow( + "int a;\nint b;\n auto *p = new (std::nothrow) uint8_t[n];\n" + ) + assert [(line, col) for line, col, _msg in errors] == [(3, 18)] + assert "RAMAllocator" in errors[0][2] + + +def test_nothrow_spacing_and_the_nothrow_t_type() -> None: + assert len(_lint_nothrow("auto *p = new (std :: nothrow) Foo;\n")) == 1 + assert not _lint_nothrow( + "void *operator new(size_t n, const std::nothrow_t &) noexcept;\n" + ) + + +def test_nothrow_in_comments_and_strings_is_masked() -> None: + assert not _lint_nothrow("// new (std::nothrow) aborts on ESP-IDF\n") + assert not _lint_nothrow('ESP_LOGD(TAG, "std::nothrow");\n') + + +def test_nothrow_nolint_suppresses() -> None: + assert not _lint_nothrow("auto *p = new (std::nothrow) Foo; // NOLINT\n") + + +def test_nothrow_nolint_inside_a_string_does_not_suppress() -> None: + assert len(_lint_nothrow('auto *p = new (std::nothrow) Foo; log("NOLINT");\n')) == 1 + + # --- rule: UNIT_ constants must not be redefined (mirror of the CONF_ check) --- # Real UNIT_ constants that live in each canonical home. diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 0374a2cac2f..189c024b3a7 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, core +import esphome.codegen as cg from esphome.components.safe_mode import to_code as safe_mode_to_code from esphome.const import ( CONF_AREA, @@ -23,7 +24,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, Toolchain, ) -from esphome.core import CORE, config +from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config from esphome.core.config import ( Area, make_app_name_cpp, @@ -455,6 +456,35 @@ async def test_add_looping_components_with_entries() -> None: assert "(1 * HasLoopOverride::value)" in text +@pytest.mark.asyncio +async def test_add_controller_registry_dispatch_without_controllers() -> None: + """Nothing is emitted when no controller registered.""" + CORE.data.pop(KEY_CONTROLLER_REGISTRY_CONTROLLERS, None) + + await config._add_controller_registry_dispatch() + + assert "USE_CONTROLLER_REGISTRY" not in {d.name for d in CORE.defines} + assert not [s for s in CORE.global_statements if "controller" in str(s)] + + +@pytest.mark.asyncio +async def test_add_controller_registry_dispatch_with_controllers() -> None: + """Registered controllers become one tuple plus the dispatch include.""" + CORE.register_controller(cg.MockObj("api_apiserver_id")) + CORE.register_controller(cg.MockObj("web_server_webserver_id")) + + await config._add_controller_registry_dispatch() + + assert "USE_CONTROLLER_REGISTRY" in {d.name for d in CORE.defines} + statements = [str(s) for s in CORE.global_statements] + assert "#include " in statements + assert ( + "static auto esphome_controllers() { return std::tuple{api_apiserver_id, web_server_webserver_id}; }" + in statements + ) + assert '#include "esphome/core/controller_dispatch.h"' in statements + + def test_valid_include_with_angle_brackets() -> None: """Test valid_include accepts angle bracket includes.""" assert valid_include("") == "" diff --git a/tests/unit_tests/test_codegen.py b/tests/unit_tests/test_codegen.py index 8d01fef7c20..c5c971b22dc 100644 --- a/tests/unit_tests/test_codegen.py +++ b/tests/unit_tests/test_codegen.py @@ -70,7 +70,6 @@ from esphome.cpp_generator import _extract_component_ns "arduino_json_ns", "JsonObject", "JsonObjectConst", - "Controller", "GPIOPin", ), ) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 439220f09c6..60f6b28e076 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -218,6 +218,7 @@ def _upload( firmware: bytes, noise_psk: str | None, plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, ) -> None: device.start() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -231,13 +232,18 @@ def _upload( Path("firmware.bin"), noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) finally: sock.close() def _run_ota( - device: FakeEncryptedDevice, firmware: bytes, tmp_path: Path, noise_psk: str + device: FakeEncryptedDevice, + firmware: bytes, + tmp_path: Path, + noise_psk: str, + plaintext_fallback: bool = True, ) -> int: """Drive the retry loop, which is where the plaintext fallback reconnects.""" path = tmp_path / "firmware.bin" @@ -249,7 +255,7 @@ def _run_ota( None, path, noise_psk=noise_psk, - plaintext_fallback=True, + plaintext_fallback=plaintext_fallback, ) return rc @@ -299,11 +305,69 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises(espota2.OTAError, match="refusing to send the image"): + with pytest.raises( + espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" + ): _upload(device, b"firmware", PSK) device.join_and_check() +def test_allow_plaintext_upload_when_device_does_not_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The explicit opt in sends the image in plaintext to a device that + cannot encrypt, naming the option in the warning.""" + firmware = b"firmware" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any("'allow_plaintext_upload' is set" in r.message for r in caplog.records) + assert not any("2027.3.0" in r.message for r in caplog.records) + assert not any( + "Remove it from the configuration" in r.message for r in caplog.records + ) + + +def test_allow_plaintext_upload_warns_once_device_encrypts( + caplog: pytest.LogCaptureFixture, +) -> None: + """The removal warning appears exactly when it is safe to act on: the + device offered encryption and accepted the key with the option still set.""" + pytest.importorskip("aioesphomeapi.noise") + firmware = b"firmware" + device = FakeEncryptedDevice() + with caplog.at_level(logging.WARNING): + _upload(device, firmware, PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received == firmware + assert any( + "Remove it from the configuration now" in r.message for r in caplog.records + ) + with caplog.at_level(logging.WARNING): + caplog.clear() + _upload(FakeEncryptedDevice(), firmware, PSK) + assert not caplog.records + + +def test_allow_plaintext_upload_keeps_wrong_key_failing( + caplog: pytest.LogCaptureFixture, +) -> None: + """The opt in only covers a device that does not offer; a rejected key + never turns into a plaintext upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, require_noise=False) + with ( + caplog.at_level(logging.WARNING), + pytest.raises(espota2.OTAError, match="encryption key correct"), + ): + _upload(device, b"firmware", PSK, allow_plaintext_upload=True) + device.join_and_check() + assert device.received != b"firmware" + assert not any("plaintext" in r.message for r in caplog.records) + + # Remove before 2027.3.0 def test_fallback_when_device_does_not_offer(caplog: pytest.LogCaptureFixture) -> None: """The api key is tried opportunistically; an older device that cannot @@ -519,3 +583,18 @@ def test_recv_serves_buffered_plaintext_without_new_frame() -> None: assert wrapper.recv(1) == b"A" # reads and decrypts one frame assert wrapper.recv(1) == b"B" # served from the buffer, no new frame wrapper._decrypt.decrypt.assert_called_once() + + +def test_bare_block_refuses_a_device_that_cannot_encrypt( + caplog: pytest.LogCaptureFixture, tmp_path: Path +) -> None: + """What the CLI sends for a bare `encryption:` block: a key with neither + fallback. The retry loop never reconnects in plaintext.""" + device = FakeEncryptedDevice(offer_noise=False, require_noise=False) + with patch("time.sleep"), caplog.at_level(logging.WARNING): + rc = _run_ota(device, b"firmware", tmp_path, PSK, plaintext_fallback=False) + device.join_and_check() + assert rc == 1 + assert device.received != b"firmware" + assert any("refusing to send the image" in r.message for r in caplog.records) + assert not any("Retrying in plaintext" in r.message for r in caplog.records) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index cada9bbf04c..d689ac1a5a0 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -2116,6 +2116,7 @@ def test_upload_program_ota_success( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2154,9 +2155,75 @@ def test_upload_program_ota_encryption_key( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=False, + allow_plaintext_upload=False, ) +def test_upload_program_bare_encryption_block_never_falls_back( + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """A bare `ota: encryption:` (the api key inherited by final validate, no + option) fails closed against a device that does not offer encryption.""" + from esphome.components.esphome.ota import ota_esphome_final_validate + import esphome.final_validate as fv + + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_API: {CONF_ENCRYPTION: {CONF_KEY: key}}, + CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME, CONF_PORT: 3232, CONF_ENCRYPTION: {}}], + } + token = fv.full_config.set(config) + try: + ota_esphome_final_validate({}) + config = fv.full_config.get() + finally: + fv.full_config.reset(token) + assert config[CONF_OTA][0][CONF_ENCRYPTION] == {CONF_KEY: key} + + with patch("esphome.espota2.run_ota", return_value=(0, "192.168.1.100")) as run_ota: + upload_program(config, MockArgs(), ["192.168.1.100"]) + assert run_ota.call_args.args[5] == key + assert run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } + + +def test_upload_program_ota_allow_plaintext_upload( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """The uploader side opt in reaches run_ota without the removed fallback.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + key = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "pw", + CONF_ENCRYPTION: {CONF_KEY: key, "allow_plaintext_upload": True}, + } + ] + } + exit_code, _ = upload_program(config, MockArgs(), ["192.168.1.100"]) + + assert exit_code == 0 + assert mock_run_ota.call_args.args[2] == "pw" + assert mock_run_ota.call_args.args[5] == key + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": True, + } + + def test_upload_program_ota_api_key_opportunistic( mock_run_ota: Mock, mock_get_port_type: Mock, @@ -2187,6 +2254,7 @@ def test_upload_program_ota_api_key_opportunistic( OTA_TYPE_UPDATE_APP, key, plaintext_fallback=True, + allow_plaintext_upload=False, ) @@ -2215,7 +2283,10 @@ def test_upload_program_ota_no_usable_api_key_stays_plaintext( assert exit_code == 0 assert mock_run_ota.call_args.args[5] is None - assert mock_run_ota.call_args.kwargs == {"plaintext_fallback": False} + assert mock_run_ota.call_args.kwargs == { + "plaintext_fallback": False, + "allow_plaintext_upload": False, + } def test_upload_program_ota_encryption_without_key_fails_closed( @@ -2275,6 +2346,7 @@ def test_upload_program_ota_with_file_arg( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2331,6 +2403,7 @@ def test_upload_program_ota_partition_table_with_file_arg( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2394,6 +2467,7 @@ def test_upload_program_ota_partition_table_mqttip( OTA_TYPE_UPDATE_PARTITION_TABLE, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -2583,6 +2657,7 @@ def test_upload_program_ota_bootloader_with_file_arg( OTA_TYPE_UPDATE_BOOTLOADER, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3078,6 +3153,7 @@ def test_upload_program_ota_with_mqtt_resolution( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -3134,6 +3210,7 @@ def test_upload_program_ota_with_mqtt_empty_broker( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) # Verify warning was logged assert "MQTT IP discovery failed" in caplog.text @@ -5307,6 +5384,7 @@ def test_upload_program_ota_static_ip_with_mqttip( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5358,6 +5436,7 @@ def test_upload_program_ota_multiple_mqttip_resolves_once( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, ) @@ -5542,6 +5621,7 @@ def test_upload_program_ota_mqtt_timeout_fallback( OTA_TYPE_UPDATE_APP, None, plaintext_fallback=False, + allow_plaintext_upload=False, )