diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20c349ac003..57053c36457 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate cache-key id: cache-key - run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT + run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -58,7 +58,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt pre-commit + pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit pip install -e . pylint: @@ -108,6 +108,34 @@ jobs: script/generate-esp32-boards.py --check script/generate-rp2040-boards.py --check + import-time: + name: Check import esphome.__main__ time + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.import-time == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Check import time against budget and write waterfall HAR + run: | + . venv/bin/activate + script/check_import_time.py --check --har importtime.har + - name: Upload waterfall HAR + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: import-time-waterfall + path: importtime.har + if-no-files-found: ignore + retention-days: 14 + pytest: name: Run pytest strategy: @@ -176,6 +204,7 @@ jobs: clang-tidy: ${{ steps.determine.outputs.clang-tidy }} clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} python-linters: ${{ steps.determine.outputs.python-linters }} + import-time: ${{ steps.determine.outputs.import-time }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -219,6 +248,7 @@ jobs: echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT + echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -339,7 +369,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0 + uses: CodSpeedHQ/action@c381be0bfd20e844fb45594f6aa182ffcd94545c # v4.15.0 with: run: ${{ steps.build.outputs.binary }} mode: simulation diff --git a/.gitignore b/.gitignore index da568d9b832..4a4a88fd48f 100644 --- a/.gitignore +++ b/.gitignore @@ -146,5 +146,6 @@ sdkconfig.* /components /managed_components +/dependencies.lock api-docs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9b7df6ec53..ad82bd8e5d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.11 + rev: v0.15.12 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index 20c19a7dfa0..471def542bb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,6 +347,7 @@ esphome/components/modbus_controller/select/* @martgras @stegm esphome/components/modbus_controller/sensor/* @martgras esphome/components/modbus_controller/switch/* @martgras esphome/components/modbus_controller/text_sensor/* @martgras +esphome/components/modbus_server/* @exciton esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_std_check/* @Fabian-Schmidt diff --git a/esphome/automation.py b/esphome/automation.py index 97d9a0a47a8..20eb9358cad 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -597,7 +597,7 @@ async def component_resume_action_to_code( comp = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, comp) if CONF_UPDATE_INTERVAL in config: - template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int) + template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32) cg.add(var.set_update_interval(template_)) return var diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index 9fcdf42ecb4..2f5d4c7c2bf 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@grahambrown11", "@hwstar"] @@ -181,7 +185,7 @@ async def setup_alarm_control_panel_core_(var, config): async def register_alarm_control_panel(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_alarm_control_panel(var)) + queue_entity_register("alarm_control_panel", config) CORE.register_platform_component("alarm_control_panel", var) await setup_alarm_control_panel_core_(var, config) diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp index c2ae3b2f768..2f59a7fa5a7 100644 --- a/esphome/components/animation/animation.cpp +++ b/esphome/components/animation/animation.cpp @@ -62,7 +62,12 @@ void Animation::set_frame(int frame) { } void Animation::update_data_start_() { - const uint32_t image_size = this->get_width_stride() * this->height_; + uint32_t image_size = this->get_width_stride() * this->height_; + // RGB565 with an alpha channel stores the alpha plane immediately after the RGB + // plane within each frame, so the per-frame stride includes the alpha bytes. + if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) { + image_size += static_cast(this->width_) * this->height_; + } this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_; } diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index c3e4c386334..391efbd6eb6 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1025,6 +1025,13 @@ message CameraImageRequest { bool stream = 2; } +// ==================== TEMPERATURE UNIT ==================== +enum TemperatureUnit { + TEMPERATURE_UNIT_CELSIUS = 0; + TEMPERATURE_UNIT_FAHRENHEIT = 1; + TEMPERATURE_UNIT_KELVIN = 2; +} + // ==================== CLIMATE ==================== enum ClimateMode { CLIMATE_MODE_OFF = 0; @@ -1110,6 +1117,7 @@ message ListEntitiesClimateResponse { float visual_max_humidity = 25; uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"]; uint32 feature_flags = 27; + TemperatureUnit temperature_unit = 28; } message ClimateStateResponse { option (id) = 47; @@ -1203,6 +1211,7 @@ message ListEntitiesWaterHeaterResponse { repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"]; // Bitmask of WaterHeaterFeature flags uint32 supported_features = 12; + TemperatureUnit temperature_unit = 13; } message WaterHeaterStateResponse { @@ -1410,6 +1419,8 @@ enum LockState { LOCK_STATE_JAMMED = 3; LOCK_STATE_LOCKING = 4; LOCK_STATE_UNLOCKING = 5; + LOCK_STATE_OPENING = 6; + LOCK_STATE_OPEN = 7; } enum LockCommand { LOCK_UNLOCK = 0; @@ -1628,7 +1639,7 @@ message BluetoothLEAdvertisementResponse { message BluetoothLERawAdvertisement { option (inline_encode) = true; - uint64 address = 1 [(force) = true]; + uint64 address = 1 [(force) = true, (mac_address) = true]; sint32 rssi = 2 [(force) = true]; uint32 address_type = 3 [(max_value) = 4]; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index d5d0b37e8df..ac9c4e59cca 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -110,4 +110,10 @@ extend google.protobuf.FieldOptions { // length varint calculations and direct byte writes, since the length // varint is guaranteed to be 1 byte. optional uint32 max_data_length = 50018; + + // mac_address: Field is a 48-bit MAC address stored in a uint64. + // Emits encode_varint_raw_48bit which has a 7-byte fast path that avoids + // the per-byte loop when the upper bits are non-zero (the common case + // for real MAC addresses, since OUIs occupy the top 24 bits). + optional bool mac_address = 50019 [default=false]; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 3d124539395..eb25bf7461a 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -1439,6 +1439,7 @@ uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); #endif ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesClimateResponse::calculate_size() const { @@ -1488,6 +1489,7 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const { size += ProtoSize::calc_uint32(2, this->device_id); #endif size += ProtoSize::calc_uint32(2, this->feature_flags); + size += this->temperature_unit ? 3 : 0; return size; } uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1645,6 +1647,7 @@ uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true); } ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); return pos; } uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { @@ -1667,6 +1670,7 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { size += this->supported_modes->size() * 2; } size += ProtoSize::calc_uint32(1, this->supported_features); + size += this->temperature_unit ? 2 : 0; return size; } uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -2348,7 +2352,7 @@ BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO uint8_t *len_pos = pos; ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); if (sub_msg.address_type) { @@ -2369,7 +2373,7 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { for (uint16_t i = 0; i < this->advertisements_len; i++) { auto &sub_msg = this->advertisements[i]; size += 2; - size += ProtoSize::calc_uint64_force(1, sub_msg.address); + size += ProtoSize::calc_uint64_48bit_force(1, sub_msg.address); size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); size += sub_msg.address_type ? 2 : 0; size += 2 + sub_msg.data_len; diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5aa592e4fa8..7b82f1884d1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -92,6 +92,11 @@ enum SupportsResponseType : uint32_t { SUPPORTS_RESPONSE_STATUS = 100, }; #endif +enum TemperatureUnit : uint32_t { + TEMPERATURE_UNIT_CELSIUS = 0, + TEMPERATURE_UNIT_FAHRENHEIT = 1, + TEMPERATURE_UNIT_KELVIN = 2, +}; #ifdef USE_CLIMATE enum ClimateMode : uint32_t { CLIMATE_MODE_OFF = 0, @@ -176,6 +181,8 @@ enum LockState : uint32_t { LOCK_STATE_JAMMED = 3, LOCK_STATE_LOCKING = 4, LOCK_STATE_UNLOCKING = 5, + LOCK_STATE_OPENING = 6, + LOCK_STATE_OPEN = 7, }; enum LockCommand : uint32_t { LOCK_UNLOCK = 0, @@ -1372,7 +1379,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 46; - static constexpr uint8_t ESTIMATED_SIZE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 153; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); } #endif @@ -1394,6 +1401,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { float visual_min_humidity{0.0f}; float visual_max_humidity{0.0f}; uint32_t feature_flags{0}; + enums::TemperatureUnit temperature_unit{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1471,7 +1479,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 132; - static constexpr uint8_t ESTIMATED_SIZE = 63; + static constexpr uint8_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); } #endif @@ -1480,6 +1488,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { float target_temperature_step{0.0f}; const water_heater::WaterHeaterModeMask *supported_modes{}; uint32_t supported_features{0}; + enums::TemperatureUnit temperature_unit{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bdcb6d4146d..5258b355ceb 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -297,6 +297,18 @@ template<> const char *proto_enum_to_string(enums:: } } #endif +template<> const char *proto_enum_to_string(enums::TemperatureUnit value) { + switch (value) { + case enums::TEMPERATURE_UNIT_CELSIUS: + return ESPHOME_PSTR("TEMPERATURE_UNIT_CELSIUS"); + case enums::TEMPERATURE_UNIT_FAHRENHEIT: + return ESPHOME_PSTR("TEMPERATURE_UNIT_FAHRENHEIT"); + case enums::TEMPERATURE_UNIT_KELVIN: + return ESPHOME_PSTR("TEMPERATURE_UNIT_KELVIN"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #ifdef USE_CLIMATE template<> const char *proto_enum_to_string(enums::ClimateMode value) { switch (value) { @@ -475,6 +487,10 @@ template<> const char *proto_enum_to_string(enums::LockState v return ESPHOME_PSTR("LOCK_STATE_LOCKING"); case enums::LOCK_STATE_UNLOCKING: return ESPHOME_PSTR("LOCK_STATE_UNLOCKING"); + case enums::LOCK_STATE_OPENING: + return ESPHOME_PSTR("LOCK_STATE_OPENING"); + case enums::LOCK_STATE_OPEN: + return ESPHOME_PSTR("LOCK_STATE_OPEN"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -1539,6 +1555,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit)); return out.c_str(); } const char *ClimateStateResponse::dump_to(DumpBuffer &out) const { @@ -1612,6 +1629,7 @@ const char *ListEntitiesWaterHeaterResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("supported_modes"), static_cast(it), 4); } dump_field(out, ESPHOME_PSTR("supported_features"), this->supported_features); + dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit)); return out.c_str(); } const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 6ae2a3e3694..0ba2961a138 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -21,6 +21,7 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name) { } #endif +#ifdef USE_API void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements switch (msg_type) { @@ -706,5 +707,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } } +#endif // USE_API } // namespace esphome::api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index c30bd2e6124..6c26c4e1876 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -30,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } +// Custom deleter defined here so `delete` sees the complete APIConnection type. +// This prevents libc++ from emitting an "incomplete type" error when other +// translation units only have the forward declaration of APIConnection. +void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } + void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e662d78eba9..6b575e536d3 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -193,7 +193,13 @@ class APIServer final : public Component, // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - using APIConnectionPtr = std::unique_ptr; + // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not + // eagerly instantiate `delete static_cast(p)` here, where + // only the forward declaration of APIConnection is visible (incomplete type). + struct APIConnectionDeleter { + void operator()(APIConnection *p) const; + }; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; @@ -292,7 +298,7 @@ class APIServer final : public Component, uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. - std::array, MAX_API_CONNECTIONS> clients_{}; + std::array clients_{}; // Vectors and strings (12 bytes each on 32-bit) // Shared proto write buffer for all connections. // Not pre-allocated: all send paths call prepare_first_message_buffer() which diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 8cac7fff3bf..3ff65029e1b 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -342,6 +342,32 @@ class ProtoEncode { } encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value); } + /// Encode a 48-bit MAC address (stored in a uint64) as varint. + /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the + /// fast path -- any non-zero bit in the top 6 of 48 -- emits exactly 7 bytes + /// with no per-byte branch. Falls back to the general loop otherwise. + /// Caller must guarantee value fits in 48 bits (checked in debug builds). + static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, + uint64_t value) { +#ifdef ESPHOME_DEBUG_API + assert(value < (1ULL << (MAC_ADDRESS_SIZE * 8)) && "encode_varint_raw_48bit: value exceeds 48 bits"); +#endif + // 7-byte varint holds 49 bits (7 * 7), so a 48-bit value needs all 7 bytes + // whenever bit 42 or higher is set (i.e. value >= 1 << (48 - 6)). + if (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6))) [[likely]] { + PROTO_ENCODE_CHECK_BOUNDS(pos, 7); + pos[0] = static_cast(value | 0x80); + pos[1] = static_cast((value >> 7) | 0x80); + pos[2] = static_cast((value >> 14) | 0x80); + pos[3] = static_cast((value >> 21) | 0x80); + pos[4] = static_cast((value >> 28) | 0x80); + pos[5] = static_cast((value >> 35) | 0x80); + pos[6] = static_cast(value >> 42); + pos += 7; + return; + } + encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value); + } static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, uint32_t field_id, uint32_t type) { encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type); @@ -817,6 +843,14 @@ class ProtoSize { static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_force(uint32_t field_id_size, uint64_t value) { return field_id_size + varint(value); } + /// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path. + /// When any of the top 6 of 48 bits is set the encoded varint is 7 bytes; + /// otherwise fall back to the general size calculation. + /// Caller must guarantee value fits in 48 bits (encoder asserts in debug). + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size, + uint64_t value) { + return field_id_size + (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6)) ? 7 : varint(value)); + } static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) { return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0; } diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 94b68db4b33..5031b72cceb 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): cg.add(var.set_sensing_distance(template_)) if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME): - template_ = await cg.templatable(selfcheck, args, cg.int32) + template_ = await cg.templatable(selfcheck, args, cg.int_) cg.add(var.set_poweron_selfcheck_time(template_)) if protect := config.get(CONF_PROTECT_TIME): - template_ = await cg.templatable(protect, args, cg.int32) + template_ = await cg.templatable(protect, args, cg.int_) cg.add(var.set_protect_time(template_)) if trig_base := config.get(CONF_TRIGGER_BASE): - template_ = await cg.templatable(trig_base, args, cg.int32) + template_ = await cg.templatable(trig_base, args, cg.int_) cg.add(var.set_trigger_base(template_)) if trig_keep := config.get(CONF_TRIGGER_KEEP): - template_ = await cg.templatable(trig_keep, args, cg.int32) + template_ = await cg.templatable(trig_keep, args, cg.int_) cg.add(var.set_trigger_keep(template_)) if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index fee582ca25c..fe111be31e7 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -220,7 +220,7 @@ async def to_code(config): data = _get_data() if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.1.1") + add_idf_component(name="esphome/micro-decoder", ref="0.2.0") # All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash if not data.flac_support: diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index 045fb7cf454..ab952895a88 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -154,7 +154,7 @@ void BH1750Sensor::loop() { break; } - ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); + ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx); this->status_clear_warning(); this->publish_state(lx); this->state_ = IDLE; diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 29ddbab02cd..1456e5bc663 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -62,6 +62,7 @@ from esphome.const import ( 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, ) @@ -624,7 +625,7 @@ async def setup_binary_sensor_core_(var, config): async def register_binary_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_binary_sensor(var)) + queue_entity_register("binary_sensor", config) CORE.register_platform_component("binary_sensor", var) await setup_binary_sensor_core_(var, config) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index eb68abce3b7..b13e4a88dd1 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -50,29 +50,31 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - if (*this->at_index_ == this->timing_count_) { + // at_index_ has a value here (the !has_value() branch above returns). + size_t at_index = *this->at_index_; + if (at_index == this->timing_count_) { this->trigger_(); return; } - MultiClickTriggerEvent evt = this->timing_[*this->at_index_]; + MultiClickTriggerEvent evt = this->timing_[at_index]; if (evt.max_length != 4294967294UL) { - ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, *this->at_index_, evt.min_length, evt.max_length); // NOLINT + ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); - } else if (*this->at_index_ + 1 != this->timing_count_) { - ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT + } else if (at_index + 1 != this->timing_count_) { + ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { - ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT + ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT this->is_valid_ = false; this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); } - *this->at_index_ = *this->at_index_ + 1; + this->at_index_ = at_index + 1; } void MultiClickTriggerBase::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index 4bee69fe6da..f8bedce329b 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -16,6 +16,7 @@ from esphome.components.libretiny.const import ( FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, ) @@ -24,16 +25,32 @@ BK72XX_BOARDS = { "name": "WB2L_M1 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, "cbu": { "name": "CBU Wi-Fi Module", "family": FAMILY_BK7231N, }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya QFN32)", + "name": "Generic - BK7231T (Tuya)", "family": FAMILY_BK7231T, }, "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya QFN32)", + "name": "Generic - BK7231N (Tuya)", "family": FAMILY_BK7231N, }, "cb1s": { @@ -64,6 +81,10 @@ BK72XX_BOARDS = { "name": "Generic - BK7252", "family": FAMILY_BK7251, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2l": { "name": "WB2L Wi-Fi Module", "family": FAMILY_BK7231T, @@ -80,6 +101,10 @@ BK72XX_BOARDS = { "name": "CB2S Wi-Fi Module", "family": FAMILY_BK7231N, }, + "generic-bk7238": { + "name": "Generic - BK7238", + "family": FAMILY_BK7238, + }, "wa2": { "name": "WA2 Wi-Fi Module", "family": FAMILY_BK7231Q, @@ -100,6 +125,10 @@ BK72XX_BOARDS = { "name": "WB3L Wi-Fi Module", "family": FAMILY_BK7231T, }, + "t1-2s": { + "name": "T1-2S Wi-Fi Module", + "family": FAMILY_BK7238, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, @@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, "cbu": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = { "D18": 21, "A0": 23, }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "generic-bk7231t-qfn32-tuya": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "t1-3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 20, + "D1": 22, + "D2": 6, + "D3": 8, + "D4": 9, + "D5": 23, + "D6": 0, + "D7": 1, + "D8": 24, + "D9": 26, + "D10": 10, + "D11": 11, + "D12": 17, + "D13": 16, + "D14": 15, + "D15": 14, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = { "D10": 21, "A0": 23, }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "wa2": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "wb2s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c69163b1f74..c3461f9c519 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -30,19 +30,6 @@ void BluetoothProxy::setup() { this->configured_scan_active_ = this->parent_->get_scan_active(); this->parent_->add_scanner_state_listener(this); - - this->set_interval(100, [this]() { - if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { - this->flush_pending_advertisements_(); - return; - } - for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { - connection->disconnect(); - } - } - }); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { @@ -133,6 +120,25 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connection_count_); } +void BluetoothProxy::loop() { + // Run advertisement flush / connection cleanup every 100ms + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_advertisement_flush_time_ < 100) + return; + this->last_advertisement_flush_time_ = now; + + if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { + this->flush_pending_advertisements_(); + return; + } + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0 && !connection->disconnect_pending()) { + connection->disconnect(); + } + } +} + esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } @@ -201,7 +207,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - uint64_to_bd_addr(msg.address, connection->remote_bda_); connection->set_remote_addr_type(static_cast(msg.address_type)); connection->set_state(espbt::ClientState::DISCOVERED); this->send_connections_free(); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 6680ab0e840..10449f21f1e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -65,6 +65,7 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; + void loop() override; esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -176,6 +177,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; + // Group 3: 4-byte types + uint32_t last_advertisement_flush_time_{0}; + // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index e3cd80de004..b599d64c0da 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -78,43 +78,43 @@ void BME680Component::setup() { } // Read calibration - uint8_t cal1[25]; - if (!this->read_bytes(BME680_REGISTER_COEFF1, cal1, 25)) { + uint8_t coeff1[25]; + if (!this->read_bytes(BME680_REGISTER_COEFF1, coeff1, 25)) { this->mark_failed(); return; } - uint8_t cal2[16]; - if (!this->read_bytes(BME680_REGISTER_COEFF2, cal2, 16)) { + uint8_t coeff2[16]; + if (!this->read_bytes(BME680_REGISTER_COEFF2, coeff2, 16)) { this->mark_failed(); return; } - this->calibration_.t1 = cal2[9] << 8 | cal2[8]; - this->calibration_.t2 = cal1[2] << 8 | cal1[1]; - this->calibration_.t3 = cal1[3]; + this->calibration_.t1 = coeff2[9] << 8 | coeff2[8]; + this->calibration_.t2 = coeff1[2] << 8 | coeff1[1]; + this->calibration_.t3 = coeff1[3]; - this->calibration_.h1 = cal2[2] << 4 | (cal2[1] & 0x0F); - this->calibration_.h2 = cal2[0] << 4 | cal2[1] >> 4; - this->calibration_.h3 = cal2[3]; - this->calibration_.h4 = cal2[4]; - this->calibration_.h5 = cal2[5]; - this->calibration_.h6 = cal2[6]; - this->calibration_.h7 = cal2[7]; + this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F); + this->calibration_.h2 = coeff2[0] << 4 | coeff2[1] >> 4; + this->calibration_.h3 = coeff2[3]; + this->calibration_.h4 = coeff2[4]; + this->calibration_.h5 = coeff2[5]; + this->calibration_.h6 = coeff2[6]; + this->calibration_.h7 = coeff2[7]; - this->calibration_.p1 = cal1[6] << 8 | cal1[5]; - this->calibration_.p2 = cal1[8] << 8 | cal1[7]; - this->calibration_.p3 = cal1[9]; - this->calibration_.p4 = cal1[12] << 8 | cal1[11]; - this->calibration_.p5 = cal1[14] << 8 | cal1[13]; - this->calibration_.p6 = cal1[16]; - this->calibration_.p7 = cal1[15]; - this->calibration_.p8 = cal1[20] << 8 | cal1[19]; - this->calibration_.p9 = cal1[22] << 8 | cal1[21]; - this->calibration_.p10 = cal1[23]; + this->calibration_.p1 = coeff1[6] << 8 | coeff1[5]; + this->calibration_.p2 = coeff1[8] << 8 | coeff1[7]; + this->calibration_.p3 = coeff1[9]; + this->calibration_.p4 = coeff1[12] << 8 | coeff1[11]; + this->calibration_.p5 = coeff1[14] << 8 | coeff1[13]; + this->calibration_.p6 = coeff1[16]; + this->calibration_.p7 = coeff1[15]; + this->calibration_.p8 = coeff1[20] << 8 | coeff1[19]; + this->calibration_.p9 = coeff1[22] << 8 | coeff1[21]; + this->calibration_.p10 = coeff1[23]; - this->calibration_.gh1 = cal2[14]; - this->calibration_.gh2 = cal2[12] << 8 | cal2[13]; - this->calibration_.gh3 = cal2[15]; + this->calibration_.gh1 = coeff2[14]; + this->calibration_.gh2 = coeff2[12] << 8 | coeff2[13]; + this->calibration_.gh3 = coeff2[15]; uint8_t temp_var = 0; if (!this->read_byte(0x02, &temp_var)) { diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index 2c19ea69b1a..dd4fde5705e 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( 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, ) @@ -101,7 +102,7 @@ async def setup_button_core_(var, config): async def register_button(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_button(var)) + queue_entity_register("button", config) CORE.register_platform_component("button", var) await setup_button_core_(var, config) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index df77fa5c1c9..0fdb18a92c8 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -49,7 +49,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True @@ -442,7 +446,7 @@ async def setup_climate_core_(var, config): async def register_climate(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_climate(var)) + queue_entity_register("climate", config) CORE.register_platform_component("climate", var) await setup_climate_core_(var, config) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index fdfca55f0f8..41efd2ba7a5 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -39,6 +39,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, ) @@ -232,7 +233,7 @@ async def setup_cover_core_(var, config): async def register_cover(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_cover(var)) + queue_entity_register("cover", config) CORE.register_platform_component("cover", var) await setup_cover_core_(var, config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 895ac4e243e..87997daa3d9 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -22,7 +22,11 @@ from esphome.const import ( CONF_YEAR, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -160,7 +164,7 @@ async def register_datetime(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() - cg.add(getattr(cg.App, f"register_{entity_type}")(var)) + queue_entity_register(entity_type, config) CORE.register_platform_component(entity_type, var) await setup_datetime_core_(var, config) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 0ca557bd6d8..9666c8e5071 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -193,11 +193,14 @@ def _validate_ex1_wakeup_mode(value): def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: - if not CORE.is_bk72xx: - return value - max_duration = core.TimePeriod(hours=36) - if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + if CORE.is_bk72xx: + max_duration = core.TimePeriod(hours=36) + if value > max_duration: + raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + elif CORE.using_zephyr: + max_duration = core.TimePeriod(days=49) + if value > max_duration: + raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr") return value diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index d2c5db54b39..d5e34b1f1c8 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -9,18 +9,11 @@ static const char *const TAG = "deep_sleep"; // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000; -bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -std::atomic global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +bool global_has_deep_sleep = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void DeepSleepComponent::setup() { -#ifdef USE_ZEPHYR - k_sem_init(&this->wakeup_sem_, 0, 1); -#endif global_has_deep_sleep = true; this->schedule_sleep_(); - // It can be used from another thread for waking up the device. - // It should be called as last item in setup. - global_deep_sleep.store(this); } void DeepSleepComponent::schedule_sleep_() { diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 854ab152a16..59381eeabeb 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -4,8 +4,6 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -#include - #ifdef USE_ESP32 #include #endif @@ -15,10 +13,6 @@ #include "esphome/core/time.h" #endif -#ifdef USE_ZEPHYR -#include -#endif - #include namespace esphome { @@ -125,9 +119,6 @@ class DeepSleepComponent : public Component { void prevent_deep_sleep(); void allow_deep_sleep(); -#ifdef USE_ZEPHYR - void wakeup(); -#endif protected: // Returns nullopt if no run duration is set. Otherwise, returns the run @@ -167,9 +158,6 @@ class DeepSleepComponent : public Component { optional run_duration_; bool next_enter_deep_sleep_{false}; bool prevent_{false}; -#ifdef USE_ZEPHYR - k_sem wakeup_sem_; -#endif }; extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -256,8 +244,5 @@ template class AllowDeepSleepAction : public Action, publ void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; -extern std::atomic - global_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - } // namespace deep_sleep } // namespace esphome diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index 82d6d8c7ded..f77b73cd586 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -1,17 +1,13 @@ #include "deep_sleep_component.h" #ifdef USE_ZEPHYR #include "esphome/core/log.h" +#include "esphome/core/wake.h" #include -#include -#include -#include namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; -void DeepSleepComponent::wakeup() { k_sem_give(&this->wakeup_sem_); } - optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} @@ -19,9 +15,8 @@ void DeepSleepComponent::dump_config_platform_() {} bool DeepSleepComponent::prepare_to_sleep_() { return true; } void DeepSleepComponent::deep_sleep_() { - k_timeout_t sleep_duration = K_FOREVER; if (this->sleep_duration_.has_value()) { - sleep_duration = K_USEC(*this->sleep_duration_); + esphome::internal::wakeable_delay(static_cast(*this->sleep_duration_ / 1000)); } else { #ifndef USE_ZIGBEE // the device can be woken up through one of the following signals: @@ -33,11 +28,12 @@ void DeepSleepComponent::deep_sleep_() { // // The system is reset when it wakes up from System OFF mode. sys_poweroff(); +#else + esphome::internal::wakeable_delay(UINT32_MAX); #endif } - // It might wake up immediately if k_sem_give was called again after wake up - int ret = k_sem_take(&this->wakeup_sem_, sleep_duration); - if (ret == 0) { + const bool woke = esphome::wake_request_take(); + if (woke) { ESP_LOGD(TAG, "Woken up by another thread"); } else { ESP_LOGD(TAG, "Timeout expired (normal sleep)"); diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 9976e5c7f06..5f0725dd4bb 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -29,7 +29,7 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { protected: void control(const AlarmControlPanelCall &call) override { auto state = call.get_state().value_or(ACP_STATE_DISARMED); - auto code = call.get_code(); + const auto &code = call.get_code(); switch (state) { case ACP_STATE_ARMED_AWAY: if (this->get_requires_code_to_arm()) { diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp index f47025698b3..98901bd353c 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp @@ -104,8 +104,9 @@ int8_t CircularCommandQueue::enqueue(std::unique_ptr cmd) { if (this->is_full()) { ESP_LOGE(TAG, "Command queue is full"); return -1; - } else if (this->is_empty()) + } else if (this->is_empty()) { front_++; + } rear_ = (rear_ + 1) % COMMAND_QUEUE_SIZE; commands_[rear_] = std::move(cmd); // Transfer ownership using std::move return 1; diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 1a7ae700c78..eb023ce32c3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1724,6 +1724,11 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + # Both ESP-IDF and ESP32 Arduino builds generate IDF app metadata. Keep + # volatile build path/time data out of the binary so equivalent projects can + # produce reproducible outputs and downstream tooling can reuse artifacts. + add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 925c4e76624..09835385ac2 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -18,6 +18,12 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// open() runs from app_main() before the logger is initialized, so any failure +// must be deferred until after global_logger is set. This is emitted from the +// first make_preference() call, which runs from the generated setup() after +// log->pre_setup() has run at EARLY_INIT priority. +static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { // try find in pending saves and update that for (auto &obj : s_pending_save) { @@ -70,12 +76,14 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } void ESP32Preferences::open() { + // Runs from app_main() before the logger is initialized; any logging here + // must be deferred. See s_open_err and make_preference() below. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) return; - ESP_LOGW(TAG, "nvs_open failed: %s - erasing NVS", esp_err_to_name(err)); + s_open_err = err; nvs_flash_deinit(); nvs_flash_erase(); nvs_flash_init(); @@ -87,6 +95,14 @@ void ESP32Preferences::open() { } ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { + if (s_open_err != ESP_OK) { + if (this->nvs_handle == 0) { + ESP_LOGW(TAG, "nvs_open failed: %s - NVS unavailable", esp_err_to_name(s_open_err)); + } else { + ESP_LOGW(TAG, "nvs_open failed: %s - erased NVS", esp_err_to_name(s_open_err)); + } + s_open_err = ESP_OK; + } auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index 334780e3b82..886f8237adb 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -104,7 +104,7 @@ ESPBTUUID ESPBTUUID::as_128bit() const { } else { uuid32 = this->uuid_.uuid.uuid16; } - for (uint8_t i = 0; i < this->uuid_.len; i++) { + for (uint16_t i = 0; i < this->uuid_.len; i++) { data[12 + i] = ((uuid32 >> i * 8) & 0xFF); } return ESPBTUUID::from_raw(data); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index c7f2319d69d..f57cb7f5dc5 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -166,8 +166,9 @@ void ESP32BLETracker::loop() { ClientStateCounts counts = this->count_client_states_(); if (counts != this->client_state_counts_) { this->client_state_counts_ = counts; - ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d", this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + ESP_LOGD(TAG, "connecting: %d, discovered: %d, disconnecting: %d, active: %d", + this->client_state_counts_.connecting, this->client_state_counts_.discovered, + this->client_state_counts_.disconnecting, this->client_state_counts_.active); } // Scanner failure: reached when set_scanner_state_(FAILED) or scan_set_param_failed_ set @@ -190,10 +191,18 @@ void ESP32BLETracker::loop() { */ // Start scan: reached when scanner_state_ becomes IDLE (via set_scanner_state_()) and - // all clients are idle (their state changes increment version when they finish) + // no clients are in the transient CONNECTING / DISCOVERED / DISCONNECTING states + // (their state changes increment version when they finish). CONNECTED / ESTABLISHED + // clients do NOT block this branch — the coex revert below has its own active-count gate. if (this->scanner_state_ == ScannerState::IDLE && !counts.connecting && !counts.disconnecting && !counts.discovered) { #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - this->update_coex_preference_(false); + // Only revert to BALANCE when no connections are active. Established connections + // continue to need PREFER_BT so peer GATT responses can reach us while WiFi traffic + // (advertisement upload, log streaming) competes for the shared radio. Reverting too + // early causes Bluedroid to time out at ~20s and synthesize status=133. + if (!counts.active) { + this->update_coex_preference_(false); + } #endif if (this->scan_continuous_) { this->start_scan_(false); // first = false @@ -701,9 +710,10 @@ void ESP32BLETracker::dump_config() { this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); ESP_LOGCONFIG(TAG, " Scanner State: %s\n" - " Connecting: %d, discovered: %d, disconnecting: %d", + " Connecting: %d, discovered: %d, disconnecting: %d, active: %d", this->scanner_state_to_string_(this->scanner_state_), this->client_state_counts_.connecting, - this->client_state_counts_.discovered, this->client_state_counts_.disconnecting); + this->client_state_counts_.discovered, this->client_state_counts_.disconnecting, + this->client_state_counts_.active); if (this->scan_start_fail_count_) { ESP_LOGCONFIG(TAG, " Scan Start Fail Count: %d", this->scan_start_fail_count_); } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 43405b02b7f..78ff60f3741 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -160,9 +160,13 @@ struct ClientStateCounts { uint8_t connecting = 0; uint8_t discovered = 0; uint8_t disconnecting = 0; + // CONNECTED + ESTABLISHED clients. Tracked so coex stays at PREFER_BT + // while active connections may still need to send/receive GATT traffic. + uint8_t active = 0; bool operator==(const ClientStateCounts &other) const { - return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting; + return connecting == other.connecting && discovered == other.discovered && disconnecting == other.disconnecting && + active == other.active; } bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } @@ -381,6 +385,10 @@ class ESP32BLETracker : public Component, case ClientState::CONNECTING: counts.connecting++; break; + case ClientState::CONNECTED: + case ClientState::ESTABLISHED: + counts.active++; + break; default: break; } diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index e44bc807e9a..54bbbe52ed8 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -216,6 +216,7 @@ void ESP32TouchComponent::setup() { // Do initial oneshot scans to populate baseline values for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); + App.feed_wdt(); // 3 scans with 2s timeout might exceed WDT, so feed it here to be safe if (err != ESP_OK) { ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index bef7e36470a..34540bd48da 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -314,6 +314,11 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR + # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply + # implementation in the Arduino ESP8266 core. + cg.add_build_flag("-Wl,--wrap=millis") + cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] diff --git a/esphome/components/esp8266/core.cpp b/esphome/components/esp8266/core.cpp index 389560cb1a1..ed280274457 100644 --- a/esphome/components/esp8266/core.cpp +++ b/esphome/components/esp8266/core.cpp @@ -16,8 +16,74 @@ extern "C" { namespace esphome { // yield(), micros(), millis_64() inlined in hal.h. -uint32_t IRAM_ATTR HOT millis() { return ::millis(); } -void HOT delay(uint32_t ms) { ::delay(ms); } +// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit +// multiplies on the LX106). Tracks a running ms counter from 32-bit +// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis +// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g. +// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static +// state against ISR re-entry; the critical section is bounded (≤10 while-loop +// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on +// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level +// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis(). +// +// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles +// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a +// >71 min block would trip the watchdog long before it could matter here. +static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000; +static constexpr uint32_t US_PER_MS = 1000; + +uint32_t IRAM_ATTR HOT millis() { + // Struct packs the three statics so the compiler loads one base address + // instead of three separate literal pool entries (saves ~8 bytes IRAM). + static struct { + uint32_t cache; + uint32_t remainder; + uint32_t last_us; + } state = {0, 0, 0}; + uint32_t ps = xt_rsil(15); + uint32_t now_us = system_get_time(); + uint32_t delta = now_us - state.last_us; + state.last_us = now_us; + state.remainder += delta; + if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) { + // Rare path: large gap (WiFi scan, boot, long block). Constant-time + // conversion keeps the critical section bounded. + uint32_t ms = state.remainder / US_PER_MS; + state.cache += ms; + // Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a + // second __umodsi3 call on the LX106 (no hardware divide). + state.remainder -= ms * US_PER_MS; + } else { + // Common path: small gap. At most ~10 iterations since remainder was + // < threshold (10 ms) on entry and delta adds at most one more threshold + // before exiting this branch. + while (state.remainder >= US_PER_MS) { + state.cache++; + state.remainder -= US_PER_MS; + } + } + uint32_t result = state.cache; + xt_wsr_ps(ps); + return result; +} +// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object +// call to the original millis() that --wrap can't intercept, so calling ::delay() +// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still +// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and +// WiFi run correctly. Theoretically less power-efficient than Arduino's +// os_timer-based delay() for long waits, but nearly all ESPHome delays are short +// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is +// negligible. +void HOT delay(uint32_t ms) { + if (ms == 0) { + optimistic_yield(1000); + return; + } + uint32_t start = millis(); + while (millis() - start < ms) { + optimistic_yield(1000); + } +} void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } void arch_restart() { system_restart(); @@ -76,4 +142,12 @@ extern "C" void resetPins() { // NOLINT } // namespace esphome +// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator. +// Requires -Wl,--wrap=millis in build flags (added by __init__.py). +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); } +// Note: Arduino's init() registers a 60-second overflow timer for micros64(). +// We leave it running — wrapping init() as a no-op would break micros64()'s +// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s). + #endif // USE_ESP8266 diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 47f661a8eaa..be771eb6899 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -292,6 +292,7 @@ void ESPHomeOTAComponent::handle_data_() { bool update_started = false; size_t total = 0; uint32_t last_progress = 0; + uint32_t last_data_ms = 0; uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); size_t ota_size; @@ -350,8 +351,18 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge MD5 OK - 1 byte this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); + // Track when we last received data so a silently-vanished peer (no FIN/RST + // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) + // can't wedge the device indefinitely. Without this, the loop only exits + // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP + // TCP keepalive isn't enabled here. + last_data_ms = millis(); while (total < ota_size) { - // TODO: timeout check + if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } size_t remaining = ota_size - total; size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; ssize_t read = this->client_->read(buf, requested); @@ -369,6 +380,7 @@ void ESPHomeOTAComponent::handle_data_() { goto error; // NOLINT(cppcoreguidelines-avoid-goto) } + last_data_ms = millis(); error_code = this->backend_->write(buf, read); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Flash write err %d", error_code); diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 9c9dd025b18..4cab1bff9bb 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -19,6 +19,7 @@ from esphome.const import ( 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, ) @@ -108,7 +109,7 @@ async def setup_event_core_(var, config, *, event_types: list[str]): async def register_event(var, config, *, event_types: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_event(var)) + queue_entity_register("event", config) CORE.register_platform_component("event", var) await setup_event_core_(var, config, event_types=event_types) diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index ceb402c5b73..6eb577e5ade 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from typing import Any from esphome import git, loader import esphome.config_validation as cv @@ -17,7 +18,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, ) -from esphome.core import CORE +from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) @@ -35,17 +36,15 @@ CONFIG_SCHEMA = cv.ensure_list( ) -async def to_code(config): +async def to_code(config: dict[str, Any]) -> None: pass -def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str: - # When skip_update is True, use NEVER_REFRESH to prevent updates - actual_refresh = git.NEVER_REFRESH if skip_update else refresh +def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> Path: repo_dir, _ = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=refresh, domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -72,12 +71,12 @@ def _process_git_config(config: dict, refresh, skip_update: bool = False) -> str return components_dir -def _process_single_config(config: dict, skip_update: bool = False): +def _process_single_config(config: dict[str, Any]) -> None: conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: with cv.prepend_path([CONF_SOURCE]): components_dir = _process_git_config( - config[CONF_SOURCE], config[CONF_REFRESH], skip_update + config[CONF_SOURCE], config[CONF_REFRESH] ) elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) @@ -107,7 +106,7 @@ def _process_single_config(config: dict, skip_update: bool = False): loader.install_meta_finder(components_dir, allowed_components=allowed_components) -def do_external_components_pass(config: dict, skip_update: bool = False) -> None: +def do_external_components_pass(config: dict[str, Any]) -> None: conf = config.get(DOMAIN) if conf is None: return @@ -115,4 +114,4 @@ def do_external_components_pass(config: dict, skip_update: bool = False) -> None conf = CONFIG_SCHEMA(conf) for i, c in enumerate(conf): with cv.prepend_path(i): - _process_single_config(c, skip_update) + _process_single_config(c) diff --git a/esphome/components/ezo/ezo.cpp b/esphome/components/ezo/ezo.cpp index 2dc65b7d14c..bb8fb92f21a 100644 --- a/esphome/components/ezo/ezo.cpp +++ b/esphome/components/ezo/ezo.cpp @@ -35,7 +35,7 @@ void EZOSensor::update() { } if (!found) { - std::unique_ptr ezo_command(new EzoCommand); + auto ezo_command = make_unique(); ezo_command->command = "R"; ezo_command->command_type = EzoCommandType::EZO_READ; ezo_command->delay_ms = 900; @@ -162,7 +162,7 @@ void EZOSensor::loop() { } void EZOSensor::add_command_(const char *command, EzoCommandType command_type, uint16_t delay_ms) { - std::unique_ptr ezo_command(new EzoCommand); + auto ezo_command = make_unique(); ezo_command->command = command; ezo_command->command_type = command_type; ezo_command->delay_ms = delay_ms; diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index ce1e55d36b1..713f20fb95e 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -32,7 +32,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) IS_PLATFORM_COMPONENT = True @@ -292,7 +296,7 @@ async def setup_fan_core_(var, config): async def register_fan(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_fan(var)) + queue_entity_register("fan", config) CORE.register_platform_component("fan", var) await setup_fan_core_(var, config) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 1dff210cd6b..672e99949b7 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -3,11 +3,12 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { static const char *const TAG = "feedback.cover"; +static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1; + using namespace esphome::cover; void FeedbackCover::setup() { @@ -37,7 +38,7 @@ void FeedbackCover::setup() { } #endif - this->last_recompute_time_ = this->start_dir_time_ = millis(); + this->last_recompute_time_ = this->start_dir_time_ = App.get_loop_component_start_time(); } CoverTraits FeedbackCover::get_traits() { @@ -135,7 +136,7 @@ void FeedbackCover::set_close_endstop(binary_sensor::BinarySensor *close_endstop #endif void FeedbackCover::endstop_reached_(bool open_endstop) { - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->position = open_endstop ? COVER_OPEN : COVER_CLOSED; @@ -174,7 +175,7 @@ void FeedbackCover::set_current_operation_(cover::CoverOperation operation, bool if (!is_triggered || (this->open_feedback_ == nullptr || this->close_feedback_ == nullptr)) #endif { - auto now = millis(); + const uint32_t now = App.get_loop_component_start_time(); this->current_operation = operation; this->start_dir_time_ = this->last_recompute_time_ = now; this->publish_state(); @@ -306,7 +307,7 @@ void FeedbackCover::control(const CoverCall &call) { void FeedbackCover::stop_prev_trigger_() { if (this->direction_change_waittime_.has_value()) { - this->cancel_timeout("direction_change"); + this->cancel_timeout(DIRECTION_CHANGE_TIMEOUT_ID); } if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); @@ -377,7 +378,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) { ESP_LOGD(TAG, "'%s' - Reversing direction.", this->name_.c_str()); this->start_direction_(COVER_OPERATION_IDLE); - this->set_timeout("direction_change", *this->direction_change_waittime_, + this->set_timeout(DIRECTION_CHANGE_TIMEOUT_ID, *this->direction_change_waittime_, [this, dir]() { this->start_direction_(dir); }); } else { @@ -395,7 +396,7 @@ void FeedbackCover::recompute_position_() { if (this->current_operation == COVER_OPERATION_IDLE) return; - const uint32_t now = millis(); + const uint32_t now = App.get_loop_component_start_time(); float dir; float action_dur; float min_pos; @@ -451,5 +452,4 @@ void FeedbackCover::recompute_position_() { this->last_recompute_time_ = now; } -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 6be8939413d..ed6f7490f8b 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -8,8 +8,7 @@ #endif #include "esphome/components/cover/cover.h" -namespace esphome { -namespace feedback { +namespace esphome::feedback { class FeedbackCover : public cover::Cover, public Component { public: @@ -85,5 +84,4 @@ class FeedbackCover : public cover::Cover, public Component { uint32_t update_interval_{1000}; }; -} // namespace feedback -} // namespace esphome +} // namespace esphome::feedback diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index 2be5d130504..200cac25575 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -210,8 +210,9 @@ void Smartair2Climate::process_phase(std::chrono::steady_clock::time_point now) #ifdef USE_WIFI else if (this->send_wifi_signal_ && (std::chrono::duration_cast(now - this->last_signal_request_).count() > - SIGNAL_LEVEL_UPDATE_INTERVAL_MS)) + SIGNAL_LEVEL_UPDATE_INTERVAL_MS)) { this->set_phase(ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST); + } #endif } break; default: diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index 0ade4274feb..b067ebbf6ef 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include namespace { @@ -22,9 +21,7 @@ void HOT yield() { ::sched_yield(); } uint32_t IRAM_ATTR HOT millis() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t ms = round(spec.tv_nsec / 1e6); - return ((uint32_t) seconds) * 1000U + ms; + return static_cast(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000); } uint64_t millis_64() { struct timespec spec; @@ -43,9 +40,7 @@ void HOT delay(uint32_t ms) { uint32_t IRAM_ATTR HOT micros() { struct timespec spec; clock_gettime(CLOCK_MONOTONIC, &spec); - time_t seconds = spec.tv_sec; - uint32_t us = round(spec.tv_nsec / 1e3); - return ((uint32_t) seconds) * 1000000U + us; + return static_cast(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000); } void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { struct timespec ts; diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index f37bf776333..2477e26bc12 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -462,7 +462,7 @@ template class HttpRequestSendAction : public Action { this->request_headers_.push_back({key, value}); } - void add_collect_header(const char *value) { this->lower_case_collect_headers_.push_back(value); } + void add_collect_header(const char *value) { this->lower_case_collect_headers_.emplace_back(value); } void init_json(size_t count) { this->json_.init(count); } void add_json(const char *key, TemplatableValue value) { this->json_.push_back({key, value}); } diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 49cac5b9921..3c4aa9ed1d8 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -5,7 +5,7 @@ #include "i2c_bus.h" #include "esphome/core/component.h" -struct device; +struct device; // NOLINT(readability-identifier-naming) - forward decl of Zephyr's device type namespace esphome::i2c { diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 8375ab91d3e..365554f7d2d 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -744,21 +744,28 @@ async def write_image(config, all_frames=False): if frame_count <= 1: _LOGGER.warning("Image file %s has no animation frames", path) - total_rows = height * frame_count - encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha) - if byte_order := config.get(CONF_BYTE_ORDER): - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None for frame_index in range(frame_count): image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") pixels = encoder.convert(image.resize((width, height)), path).getdata() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) encoder.end_row() - encoder.end_image() + encoder.end_image() + combined_data.extend(encoder.data) - rhs = [HexInt(x) for x in encoder.data] + rhs = [HexInt(x) for x in combined_data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) image_type = get_image_type_enum(type) trans_value = get_transparency_enum(encoder.transparency) diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index 6a2a72fa5d7..f8e77209b24 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -54,8 +54,8 @@ async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: """Register an infrared device with the core.""" cg.add_define("USE_IR_RF") await cg.register_component(var, config) + queue_entity_register("infrared", config) await setup_infrared_core_(var, config) - cg.add(cg.App.register_infrared(var)) CORE.register_platform_component("infrared", var) diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index 94c22ae84de..c53d8e50298 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -41,12 +41,12 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic ESP_LOGVV(TAG, "parse_device(): service_data is expected to be empty"); return false; } - auto mnf_datas = device.get_manufacturer_datas(); + const auto &mnf_datas = device.get_manufacturer_datas(); if (mnf_datas.size() != 1) { ESP_LOGVV(TAG, "parse_device(): manufacturer_datas is expected to have a single element"); return false; } - auto mnf_data = mnf_datas[0]; + const auto &mnf_data = mnf_datas[0]; if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) { ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16"); return false; diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index 5239a4667c0..60b0cd513bb 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -1,13 +1,73 @@ #include "ir_rf_proxy.h" + +#include + #include "esphome/core/log.h" namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; +// ========== Shared transmit helper ========== +// Static template: all instantiations occur in this translation unit. + +template +static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, + const CallT &call) { + if (transmitter == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return; + } + + auto transmit_call = transmitter->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency); + + if (call.is_packed()) { + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { + ESP_LOGE(TAG, "Invalid base64url data"); + return; + } + constexpr int32_t max_timing_us = 500000; + for (int32_t timing : transmit_data->get_data()) { + int32_t abs_timing = timing < 0 ? -timing : timing; + if (abs_timing > max_timing_us) { + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); + return; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + + transmit_call.perform(); +} + +// ========== IrRfProxy (Infrared platform) ========== + +#ifdef USE_IR_RF + void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, - "IR/RF Proxy '%s'\n" + "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), @@ -20,4 +80,54 @@ void IrRfProxy::dump_config() { } } +void IrRfProxy::control(const infrared::InfraredCall &call) { + uint32_t carrier = call.get_carrier_frequency().value_or(0); + transmit_raw_timings(this->transmitter_, carrier, call); +} + +#endif // USE_IR_RF + +// ========== RfProxy (Radio Frequency platform) ========== + +#ifdef USE_RADIO_FREQUENCY + +void RfProxy::setup() { + this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); + this->traits_.set_supports_receiver(this->receiver_ != nullptr); + + // remote_transmitter/receiver always uses OOK (on-off keying) + this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); + + if (this->receiver_ != nullptr) { + this->receiver_->register_listener(this); + } +} + +void RfProxy::dump_config() { + ESP_LOGCONFIG(TAG, + "RF Proxy '%s'\n" + " Backend: remote_transmitter/receiver\n" + " Supports Transmitter: %s\n" + " Supports Receiver: %s", + this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), + YESNO(this->traits_.get_supports_receiver())); + + const auto &traits = this->traits_; + if (traits.get_frequency_min_hz() > 0) { + if (traits.get_frequency_min_hz() == traits.get_frequency_max_hz()) { + ESP_LOGCONFIG(TAG, " Frequency: %.3f MHz (fixed)", traits.get_frequency_min_hz() / 1e6f); + } else { + ESP_LOGCONFIG(TAG, " Frequency Range: %.3f - %.3f MHz", traits.get_frequency_min_hz() / 1e6f, + traits.get_frequency_max_hz() / 1e6f); + } + } +} + +void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { + // RF: no IR carrier modulation + transmit_raw_timings(this->transmitter_, 0, call); +} + +#endif // USE_RADIO_FREQUENCY + } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 05b988f2877..973e9e20514 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -4,10 +4,19 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. +#include "esphome/components/remote_base/remote_base.h" + +#ifdef USE_IR_RF #include "esphome/components/infrared/infrared.h" +#endif + +#ifdef USE_RADIO_FREQUENCY +#include "esphome/components/radio_frequency/radio_frequency.h" +#endif namespace esphome::ir_rf_proxy { +#ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy : public infrared::Infrared { public: @@ -26,8 +35,36 @@ class IrRfProxy : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: + void control(const infrared::InfraredCall &call) override; + // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; +#endif // USE_IR_RF + +#ifdef USE_RADIO_FREQUENCY +/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend +class RfProxy : public radio_frequency::RadioFrequency { + public: + RfProxy() = default; + + void setup() override; + void dump_config() override; + + /// Set the remote transmitter component + void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } + /// Set the remote receiver component + void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } + + /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) + void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } + + protected: + void control(const radio_frequency::RadioFrequencyCall &call) override; + + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + remote_base::RemoteReceiverBase *receiver_{nullptr}; +}; +#endif // USE_RADIO_FREQUENCY } // namespace esphome::ir_rf_proxy diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py new file mode 100644 index 00000000000..9982f5e4d10 --- /dev/null +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -0,0 +1,68 @@ +"""Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" + +import esphome.codegen as cg +from esphome.components import radio_frequency, remote_receiver, remote_transmitter +import esphome.config_validation as cv +from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID, ir_rf_proxy_ns + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["radio_frequency"] + +RfProxy = ir_rf_proxy_ns.class_("RfProxy", radio_frequency.RadioFrequency) + +CONFIG_SCHEMA = cv.All( + radio_frequency.radio_frequency_schema(RfProxy).extend( + { + cv.Optional(CONF_FREQUENCY): cv.frequency, + cv.Optional(CONF_REMOTE_RECEIVER_ID): cv.use_id( + remote_receiver.RemoteReceiverComponent + ), + cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( + remote_transmitter.RemoteTransmitterComponent + ), + } + ), + cv.has_exactly_one_key(CONF_REMOTE_RECEIVER_ID, CONF_REMOTE_TRANSMITTER_ID), +) + + +def _final_validate(config: ConfigType) -> None: + """Validate that RF transmitters have carrier duty set to 100%.""" + if CONF_REMOTE_TRANSMITTER_ID not in config: + return + + transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID] + full_config = fv.full_config.get() + transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1] + transmitter_config = full_config.get_config_for_path(transmitter_path) + + duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT) + if duty_percent is not None and duty_percent != 100: + raise cv.Invalid( + f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' " + "set to 100% for RF transmission. Dedicated RF hardware handles modulation; " + "applying a carrier duty cycle would corrupt the signal" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + """Code generation for remote_base radio frequency platform.""" + var = await radio_frequency.new_radio_frequency(config) + + if CONF_FREQUENCY in config: + cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) + + if CONF_REMOTE_TRANSMITTER_ID in config: + transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) + cg.add(var.set_transmitter(transmitter)) + + if CONF_REMOTE_RECEIVER_ID in config: + receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) + cg.add(var.set_receiver(receiver)) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index edcd23f922e..ec1490be1ff 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -39,7 +39,8 @@ bool parse_json(const uint8_t *data, size_t len, const json_parse_t &f) { } JsonDocument parse_json(const uint8_t *data, size_t len) { - // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson + // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) false positives with + // ArduinoJson if (data == nullptr || len == 0) { ESP_LOGE(TAG, "No data to parse"); return JsonObject(); // return unbound object @@ -63,7 +64,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { } ESP_LOGE(TAG, "Parse error: %s", err.c_str()); return JsonObject(); // return unbound object - // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) + // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } SerializationBuffer<> JsonBuilder::serialize() { diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 332be0de1dd..5de4a164b57 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -58,6 +58,7 @@ COMPONENT_RTL87XX = "rtl87xx" FAMILY_BK7231N = "BK7231N" FAMILY_BK7231Q = "BK7231Q" FAMILY_BK7231T = "BK7231T" +FAMILY_BK7238 = "BK7238" FAMILY_BK7251 = "BK7251" FAMILY_LN882H = "LN882H" FAMILY_RTL8710B = "RTL8710B" @@ -66,6 +67,7 @@ FAMILIES = [ FAMILY_BK7231N, FAMILY_BK7231Q, FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_BK7251, FAMILY_LN882H, FAMILY_RTL8710B, @@ -75,6 +77,7 @@ FAMILY_FRIENDLY = { FAMILY_BK7231N: "BK7231N", FAMILY_BK7231Q: "BK7231Q", FAMILY_BK7231T: "BK7231T", + FAMILY_BK7238: "BK7238", FAMILY_BK7251: "BK7251", FAMILY_LN882H: "LN882H", FAMILY_RTL8710B: "RTL8710B", @@ -84,6 +87,7 @@ FAMILY_COMPONENT = { FAMILY_BK7231N: COMPONENT_BK72XX, FAMILY_BK7231Q: COMPONENT_BK72XX, FAMILY_BK7231T: COMPONENT_BK72XX, + FAMILY_BK7238: COMPONENT_BK72XX, FAMILY_BK7251: COMPONENT_BK72XX, FAMILY_LN882H: COMPONENT_LN882X, FAMILY_RTL8710B: COMPONENT_RTL87XX, diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 5925afb472d..9540c644860 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -40,7 +40,11 @@ from esphome.const import ( CONF_WHITE, ) from esphome.core import CORE, ID, CoroPriority, HexInt, Lambda, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass import esphome.final_validate as fv from esphome.types import ConfigType @@ -405,7 +409,7 @@ async def setup_light_core_(light_var, config, output_var): async def register_light(output_var, config): light_var = cg.new_Pvariable(config[CONF_ID], output_var) - cg.add(cg.App.register_light(light_var)) + queue_entity_register("light", config) CORE.register_platform_component("light", light_var) await cg.register_component(light_var, config) await setup_light_core_(light_var, config, output_var) diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index a36d52a5d82..0a8ad58bc2d 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -13,7 +13,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@esphome/core"] @@ -112,7 +116,7 @@ async def _setup_lock_core(var, config): async def register_lock(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_lock(var)) + queue_entity_register("lock", config) CORE.register_platform_component("lock", var) await _setup_lock_core(var, config) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index f9df7d23fa3..aa68e764211 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -4,15 +4,25 @@ from esphome.components.binary_sensor import ( new_binary_sensor, ) import esphome.config_validation as cv +from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET -from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lvgl_static -from ..types import LV_EVENT, lv_pseudo_button_t +from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static +from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t from ..widgets import Widget, get_widgets, wait_for_widgets +STATE_PRESSED = "PRESSED" +STATE_CHECKED = "CHECKED" + +BS_STATE = LvConstant( + "LV_STATE_", + STATE_PRESSED, + STATE_CHECKED, +) CONFIG_SCHEMA = binary_sensor_schema(BinarySensor).extend( { cv.Required(CONF_WIDGET): cv.use_id(lv_pseudo_button_t), + cv.Optional(CONF_STATE, default=STATE_PRESSED): BS_STATE.one_of, } ) @@ -22,16 +32,23 @@ async def to_code(config): widget = await get_widgets(config, CONF_WIDGET) widget = widget[0] assert isinstance(widget, Widget) + state = await BS_STATE.process(config[CONF_STATE]) await wait_for_widgets() - async with LambdaContext(EVENT_ARG) as pressed_ctx: - pressed_ctx.add(sensor.publish_state(widget.is_pressed())) + is_pressed = str(state) == str(LV_STATE.PRESSED) + test_expr = widget.is_pressed() if is_pressed else widget.is_checked() + async with LambdaContext(EVENT_ARG) as test_ctx: + test_ctx.add(sensor.publish_state(test_expr)) async with LvContext() as ctx: - ctx.add(sensor.publish_initial_state(widget.is_pressed())) + ctx.add(sensor.publish_initial_state(test_expr)) + if is_pressed: + events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] + widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + else: + events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( lvgl_static.add_event_cb( widget.obj, - await pressed_ctx.get_lambda(), - LV_EVENT.PRESSED, - LV_EVENT.RELEASED, + await test_ctx.get_lambda(), + *events, ) ) diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index c4a3c8f2cb6..e075433d03e 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -1,3 +1,5 @@ +from operator import itemgetter + from esphome import config_validation as cv import esphome.codegen as cg from esphome.const import ( @@ -11,6 +13,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import CONF_GRADIENTS, CONF_OPA, LV_DITHER, add_define, add_warning +from .helpers import add_lv_use from .lv_validation import lv_color, lv_percentage, opacity from .lvcode import lv from .types import lv_color_t, lv_gradient_t, lv_opa_t @@ -50,6 +53,7 @@ GRADIENT_SCHEMA = cv.ensure_list( async def gradients_to_code(config): + add_lv_use("gradient") max_stops = 2 if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())): add_warning( @@ -58,7 +62,7 @@ async def gradients_to_code(config): for gradient in config.get(CONF_GRADIENTS, ()): var = MockObj(cg.new_Pvariable(gradient[CONF_ID]), "->") idbase = gradient[CONF_ID].id - stops = gradient[CONF_STOPS] + stops = sorted(gradient[CONF_STOPS], key=itemgetter(CONF_POSITION)) max_stops = max(max_stops, len(stops)) if gradient[CONF_DIRECTION].startswith("VER"): lv.grad_vertical_init(var) diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 46026852af0..32304276d3e 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -1,3 +1,4 @@ +import math import re import textwrap @@ -85,6 +86,22 @@ def grid_free_space(value): grid_spec = cv.Any(size, LvConstant("LV_GRID_", "CONTENT").one_of, grid_free_space) + +def grid_dimension(value): + """ + Validator for a grid `rows` or `columns` value. + Accepts either a positive integer (interpreted as that many cells of equal + `LV_GRID_FR(1)` size) or a non-empty list of grid specs. + """ + if isinstance(value, int): + value = cv.int_range(min=1)(value) + return ["LV_GRID_FR(1)"] * value + result = cv.Schema([grid_spec])(value) + if not result: + raise cv.Invalid("Grid dimension list must contain at least one entry") + return result + + GRID_CELL_SCHEMA = { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, @@ -184,7 +201,16 @@ class DirectionalLayout(FlexLayout): class GridLayout(Layout): - _GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)\s*x\s*(\d+)\s*$") + # Match shorthand grid layout strings: "NxM", "Nx" or "xM". + # At least one of the two numbers must be present; this is enforced after matching. + _GRID_LAYOUT_REGEX = re.compile(r"^\s*(\d+)?\s*x\s*(\d+)?\s*$") + + @staticmethod + def _match_shorthand(layout): + match = GridLayout._GRID_LAYOUT_REGEX.match(layout) + if match is None or (match.group(1) is None and match.group(2) is None): + return None + return match def get_type(self): return TYPE_GRID @@ -192,7 +218,7 @@ class GridLayout(Layout): def get_layout_schemas(self, config: dict) -> tuple: layout = config.get(CONF_LAYOUT) if isinstance(layout, str): - if GridLayout._GRID_LAYOUT_REGEX.match(layout): + if GridLayout._match_shorthand(layout): return ( cv.string, { @@ -213,59 +239,107 @@ class GridLayout(Layout): if not isinstance(layout, dict) or layout.get(CONF_TYPE).lower() != TYPE_GRID: return None, {} + x_default = ( + "center" if isinstance(layout.get(CONF_GRID_ROWS), int) else cv.UNDEFINED + ) + y_default = ( + "center" if isinstance(layout.get(CONF_GRID_COLUMNS), int) else cv.UNDEFINED + ) + x_align = layout.get(CONF_GRID_CELL_X_ALIGN, x_default) + y_align = layout.get(CONF_GRID_CELL_Y_ALIGN, y_default) return ( { cv.Required(CONF_TYPE): cv.one_of(TYPE_GRID, lower=True), - cv.Required(CONF_GRID_ROWS): [grid_spec], - cv.Required(CONF_GRID_COLUMNS): [grid_spec], + cv.Optional(CONF_GRID_ROWS): grid_dimension, + cv.Optional(CONF_GRID_COLUMNS): grid_dimension, cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments, cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, cv.Optional(CONF_PAD_ROW): padding, cv.Optional(CONF_PAD_COLUMN): padding, cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean, + cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, }, { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_COLUMN_POS): cv.positive_int, cv.Optional(CONF_GRID_CELL_ROW_SPAN): cv.int_range(min=1), cv.Optional(CONF_GRID_CELL_COLUMN_SPAN): cv.int_range(min=1), - cv.Optional(CONF_GRID_CELL_X_ALIGN): grid_alignments, - cv.Optional(CONF_GRID_CELL_Y_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_CELL_X_ALIGN, default=x_align): grid_alignments, + cv.Optional(CONF_GRID_CELL_Y_ALIGN, default=y_align): grid_alignments, }, ) def validate(self, config: dict): """ Validate the grid layout. - The `layout:` key may be a dictionary with `rows` and `columns` keys, or a string in the format "rows x columns". + The `layout:` key may be a dictionary with `rows` and/or `columns` keys, or a + shorthand string in the format "x", "x" or "x". + Either dimension may be omitted, in which case it will be calculated from the + other dimension and the number of configured widgets. Either all cells must have a row and column, or none, in which case the grid layout is auto-generated. :param config: :return: The config updated with auto-generated values """ layout = config.get(CONF_LAYOUT) + widgets = config.get(CONF_WIDGETS, []) + num_widgets = len(widgets) if isinstance(layout, str): - # If the layout is a string, assume it is in the format "rows x columns", implying - # a grid layout with the specified number of rows and columns each with CONTENT sizing. + # Shorthand string: "x", "x" or "x". + # Each dimension defaults to LV_GRID_FR(1). A missing dimension is + # calculated from the other dimension and the number of widgets. layout = layout.strip() - match = GridLayout._GRID_LAYOUT_REGEX.match(layout) - if match: - rows = int(match.group(1)) - cols = int(match.group(2)) - layout = { - CONF_TYPE: TYPE_GRID, - CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows, - CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols, - } - config[CONF_LAYOUT] = layout - else: + match = GridLayout._match_shorthand(layout) + if not match: raise cv.Invalid( - f"Invalid grid layout format: {config}, expected 'rows x columns'", + f"Invalid grid layout format: {layout!r}, expected " + "'x', 'x' or 'x'", [CONF_LAYOUT], ) + rows_int = int(match.group(1)) if match.group(1) is not None else None + cols_int = int(match.group(2)) if match.group(2) is not None else None + for label, val in (("row", rows_int), ("column", cols_int)): + if val is not None and val < 1: + raise cv.Invalid( + f"Invalid grid layout {layout!r}: {label} count must be " + "at least 1", + [CONF_LAYOUT], + ) + if rows_int is not None and cols_int is not None: + rows = rows_int + cols = cols_int + elif rows_int is not None: + rows = rows_int + cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1 + else: + cols = cols_int + rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1 + layout = { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: ["LV_GRID_FR(1)"] * rows, + CONF_GRID_COLUMNS: ["LV_GRID_FR(1)"] * cols, + } + config[CONF_LAYOUT] = layout # should be guaranteed to be a dict at this point assert isinstance(layout, dict) assert layout.get(CONF_TYPE).lower() == TYPE_GRID + rows_list = layout.get(CONF_GRID_ROWS) + cols_list = layout.get(CONF_GRID_COLUMNS) + if rows_list is None and cols_list is None: + raise cv.Invalid( + "Grid layout requires at least one of 'rows' or 'columns' to be " + "specified", + [CONF_LAYOUT], + ) + if rows_list is None: + cols = len(cols_list) + rows = max(1, math.ceil(num_widgets / cols)) if num_widgets else 1 + layout[CONF_GRID_ROWS] = ["LV_GRID_FR(1)"] * rows + elif cols_list is None: + rows = len(rows_list) + cols = max(1, math.ceil(num_widgets / rows)) if num_widgets else 1 + layout[CONF_GRID_COLUMNS] = ["LV_GRID_FR(1)"] * cols allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False) rows = len(layout[CONF_GRID_ROWS]) columns = len(layout[CONF_GRID_COLUMNS]) @@ -379,7 +453,8 @@ def append_layout_schema(schema, config: dict): textwrap.dedent( """ Invalid 'layout' value - layout choices are 'horizontal', 'vertical', 'x', + layout choices are 'horizontal', 'vertical', + 'x', 'x', 'x', or a dictionary with a 'type' key """ ), diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index d8248e4aa4e..0308e6b783f 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -864,6 +864,32 @@ void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_en } #endif // USE_LVGL_SCALE +#ifdef USE_LVGL_GRADIENT +/** + * + * @param dsc The gradient descriptor containing the color stops + * @param pos The current position to calculate the color for + * @return The color for the given position + */ + +lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) { + if (dsc->stops_count == 0) + return lv_color_black(); + if (dsc->stops_count == 1 || pos <= dsc->stops[0].frac) + return dsc->stops[0].color; + if (pos >= dsc->stops[dsc->stops_count - 1].frac) + return dsc->stops[dsc->stops_count - 1].color; + int i = 1; + while (i < dsc->stops_count && dsc->stops[i].frac < pos) + i++; + auto *stop1 = &dsc->stops[i - 1]; + auto *stop2 = &dsc->stops[i]; + int32_t range = stop2->frac - stop1->frac; + int32_t offset = pos - stop1->frac; + return lv_color_mix(stop2->color, stop1->color, range == 0 ? 0 : (offset * 255) / range); +} +#endif + static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); LV_UNUSED(class_p); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 146866f5bd7..83cf9cc0995 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -115,6 +115,16 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value); #endif +#ifdef USE_LVGL_GRADIENT +/** + * + * @param dsc The gradient descriptor containing the color stops + * @param pos The current position to calculate the color for + * @return The color for the given position + */ + +lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos); +#endif // Parent class for things that wrap an LVGL object class LvCompound { public: diff --git a/esphome/components/mapping/__init__.py b/esphome/components/mapping/__init__.py index a36b414fd51..3c7d78a27bc 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -1,18 +1,27 @@ +from collections.abc import Callable import difflib import esphome.codegen as cg +from esphome.components.const import KEY_METADATA import esphome.config_validation as cv from esphome.const import CONF_FROM, CONF_ID, CONF_TO -from esphome.core import CORE -from esphome.cpp_generator import MockObj, VariableDeclarationExpression, add_global +from esphome.core import CORE, ID +from esphome.cpp_generator import ( + MockObj, + MockObjClass, + VariableDeclarationExpression, + add_global, +) from esphome.loader import get_component CODEOWNERS = ["@clydebarrow"] MULTI_CONF = True +DOMAIN = "mapping" mapping_ns = cg.esphome_ns.namespace("mapping") mapping_class = mapping_ns.class_("Mapping") +CONF_DEFAULT_VALUE = "default_value" CONF_ENTRIES = "entries" CONF_CLASS = "class" @@ -22,11 +31,18 @@ class IndexType: Represents a type of index in a map. """ - def __init__(self, validator, data_type, conversion): + def __init__( + self, validator: Callable, data_type: MockObj, conversion: Callable = None + ) -> None: self.validator = validator self.data_type = data_type self.conversion = conversion + async def convert_value(self, value): + if self.conversion: + return self.conversion(value) + return await cg.get_variable(value) + INDEX_TYPES = { "int": IndexType(cv.int_, cg.int_, int), @@ -38,6 +54,12 @@ INDEX_TYPES = { } +class MappingMetaData: + def __init__(self, from_: IndexType, to_: IndexType) -> None: + self.from_ = from_ + self.to_ = to_ + + def to_schema(value): """ Generate a schema for the 'to' field of a map. This can be either one of the index types or a class name. @@ -60,7 +82,7 @@ BASE_SCHEMA = cv.Schema( ) -def get_object_type(to_): +def get_object_type(to_) -> MockObjClass | None: """ Get the object type from a string. Possible formats: xxx The name of a component which defines INSTANCE_TYPE @@ -81,25 +103,60 @@ def get_object_type(to_): return None +def get_all_mapping_metadata() -> dict[str, MappingMetaData]: + """Get all mapping metadata.""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + + +def get_mapping_metadata(mapping_id: str) -> MappingMetaData: + """Get mapping metadata by ID for use by other components.""" + return get_all_mapping_metadata()[mapping_id] + + +def add_metadata( + mapping_id: ID, + from_: IndexType, + to_: IndexType, +) -> None: + get_all_mapping_metadata()[mapping_id.id] = MappingMetaData(from_, to_) + + def map_schema(config): config = BASE_SCHEMA(config) if CONF_ENTRIES not in config or not isinstance(config[CONF_ENTRIES], dict): - raise cv.Invalid("an entries list is required for a map") + raise cv.Invalid("an entries dictionary is required for a mapping") entries = config[CONF_ENTRIES] if len(entries) == 0: - raise cv.Invalid("Map must have at least one entry") + raise cv.Invalid("A mapping must have at least one entry") to_ = config[CONF_TO] if to_ in INDEX_TYPES: - value_type = INDEX_TYPES[to_].validator + value_type = INDEX_TYPES[to_] else: - value_type = get_object_type(to_) - if value_type is None: + object_type = get_object_type(to_) + if object_type is None: matches = difflib.get_close_matches(to_, CORE.id_classes) raise cv.Invalid( f"No known mappable class name matches '{to_}'; did you mean one of {', '.join(matches)}?" ) - value_type = cv.use_id(value_type) - config[CONF_ENTRIES] = {k: value_type(v) for k, v in entries.items()} + validator = cv.use_id(object_type) + value_type = IndexType(validator, object_type) + config[CONF_ENTRIES] = {k: value_type.validator(v) for k, v in entries.items()} + if (default_value := config.get(CONF_DEFAULT_VALUE)) is not None: + config[CONF_DEFAULT_VALUE] = value_type.validator(default_value) + unexpected_keys = config.keys() - { + CONF_ENTRIES, + CONF_TO, + CONF_FROM, + CONF_ID, + CONF_DEFAULT_VALUE, + } + if unexpected_keys: + errors = [ + cv.Invalid(f"Unexpected key '{k}'", path=[k]) for k in unexpected_keys + ] + raise cv.MultipleInvalid(errors) + + add_metadata(config[CONF_ID], INDEX_TYPES[config[CONF_FROM]], value_type) return config @@ -107,29 +164,19 @@ CONFIG_SCHEMA = map_schema async def to_code(config): - entries = config[CONF_ENTRIES] - from_ = config[CONF_FROM] - to_ = config[CONF_TO] - index_conversion = INDEX_TYPES[from_].conversion - index_type = INDEX_TYPES[from_].data_type - if to_ in INDEX_TYPES: - value_conversion = INDEX_TYPES[to_].conversion - value_type = INDEX_TYPES[to_].data_type - entries = { - index_conversion(key): value_conversion(value) - for key, value in entries.items() - } - else: - entries = { - index_conversion(key): await cg.get_variable(value) - for key, value in entries.items() - } - value_type = get_object_type(to_) - if list(entries.values())[0].op != ".": - value_type = value_type.operator("ptr") varid = config[CONF_ID] + metadata = get_mapping_metadata(varid.id) + entries = { + metadata.from_.conversion(key): await metadata.to_.convert_value(value) + for key, value in config[CONF_ENTRIES].items() + } + value_type = metadata.to_.data_type + # entries guaranteed to be non-empty here. + value_0 = list(entries.values())[0] + if isinstance(value_0, MockObj) and value_0.op != ".": + value_type = value_type.operator("ptr") varid.type = mapping_class.template( - index_type, + metadata.from_.data_type, value_type, ) var = MockObj(varid, ".") @@ -139,4 +186,6 @@ async def to_code(config): for key, value in entries.items(): cg.add(var.set(key, value)) + if (default_value := config.get(CONF_DEFAULT_VALUE)) is not None: + cg.add(var.set_default_value(await metadata.to_.convert_value(default_value))) return var diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index 2b8f0d39b2a..d6790caa35d 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -40,6 +40,9 @@ template class Mapping { if (it != this->map_.end()) { return V{it->second}; } + if (this->default_value_.has_value()) { + return this->default_value_.value(); + } if constexpr (std::is_pointer_v) { esph_log_e(TAG, "Key '%p' not found in mapping", key); } else if constexpr (std::is_same_v) { @@ -69,11 +72,17 @@ template class Mapping { if (it != this->map_.end()) { return it->second.c_str(); // safe since value remains in map } + if (this->default_value_.has_value()) { + return this->default_value_.value(); + } return ""; } + void set_default_value(const V &default_value) { this->default_value_ = default_value; } + protected: std::map, RAMAllocator>> map_; + std::optional default_value_{}; }; } // namespace esphome::mapping diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 7c36295e8d9..2b25cf243d7 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( from esphome.core import CORE, Lambda, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import LambdaExpression +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -61,6 +62,28 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config +def _require_network_interface(config: ConfigType) -> ConfigType: + """Require a network interface for mDNS on Arduino/LEAmDNS platforms. + + On ESP8266 and RP2040 the C++ implementation needs at least one IP state + listener (WiFi on ESP8266; WiFi or Ethernet on RP2040) to arm its polling + window. Reject at config time rather than silently producing a component + that never initializes. + """ + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2040): + return config + full_config = fv.full_config.get() + has_wifi = "wifi" in full_config + has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + if not (has_wifi or has_ethernet): + options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" + raise cv.Invalid( + "mdns on this platform requires a network interface — " + f"add a {options} component to your configuration." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -74,6 +97,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = _require_network_interface + + def mdns_txt_record(key: str, value: str) -> cg.RawExpression: """Create a mDNS TXT record. @@ -169,6 +195,19 @@ async def to_code(config): elif CORE.is_rp2040: cg.add_library("LEAmDNS", None) + # Subscribe to the network IP state listener(s) so MDNS.update() is only + # scheduled during the probe+announce phase. Same on_ip_state() override + # serves both WiFi and Ethernet (signatures match). + if CORE.is_esp8266 or CORE.is_rp2040: + if "wifi" in CORE.config: + from esphome.components import wifi + + wifi.request_wifi_ip_state_listener() + if CORE.is_rp2040 and "ethernet" in CORE.config: + from esphome.components import ethernet + + ethernet.request_ethernet_ip_state_listener() + if CORE.is_esp32: add_idf_component(name="espressif/mdns", ref="1.11.0") diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index adf88a9cf16..798af0e0bf5 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,6 +5,22 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +// On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by +// IP state listener events on whichever network interface is configured. +#if (defined(USE_ESP8266) || defined(USE_RP2040)) && \ + ((defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) || \ + (defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS))) +#include "esphome/components/network/ip_address.h" +#define USE_MDNS_EVENT_DRIVEN_POLLING +#if defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS) +#include "esphome/components/wifi/wifi_component.h" +#define USE_MDNS_WIFI_LISTENER +#endif +#if defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS) +#include "esphome/components/ethernet/ethernet_component.h" +#define USE_MDNS_ETHERNET_LISTENER +#endif +#endif namespace esphome::mdns { @@ -40,33 +56,40 @@ struct MDNSService { FixedVector txt_records; }; -class MDNSComponent final : public Component { +class MDNSComponent final : public Component +#ifdef USE_MDNS_WIFI_LISTENER + , + public wifi::WiFiIPStateListener +#endif +#ifdef USE_MDNS_ETHERNET_LISTENER + , + public ethernet::EthernetIPStateListener +#endif +{ public: void setup() override; void dump_config() override; - // Polling interval for MDNS.update() on platforms that require it (ESP8266, RP2040). - // - // On these platforms, MDNS.update() calls _process(true) which only manages timer-driven - // state machines (probe/announce timeouts and service query cache TTLs). Incoming mDNS - // packets are handled independently via the lwIP onRx UDP callback and are NOT affected - // by how often update() is called. - // - // The shortest internal timer is the 250ms probe interval (RFC 6762 Section 8.1). - // Announcement intervals are 1000ms and cache TTL checks are on the order of seconds - // to minutes. A 50ms polling interval provides sufficient resolution for all timers - // while completely removing mDNS from the per-iteration loop list. - // - // In steady state (after the ~8 second boot probe/announce phase completes), update() - // checks timers that are set to never expire, making every call pure overhead. - // - // Tasmota uses a 50ms main loop cycle with mDNS working correctly, confirming this - // interval is safe in production. - // - // By using set_interval() instead of overriding loop(), the component is excluded from - // the main loop list via has_overridden_loop(), eliminating all per-iteration overhead - // including virtual dispatch. +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + // LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes + + // 8×1000ms announces, ~9s). Afterwards every internal timer is resetToNeverExpires() + // and update() becomes pure overhead. We arm a bounded polling window from IP state + // listener events so update() runs only during that phase. static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; + // Must exceed LEAmDNS's longest restart-to-announce-complete path: + // MDNS_PROBE_DELAY (250ms) × MDNS_PROBE_COUNT (3) = 750ms probing + // + MDNS_ANNOUNCE_DELAY (1000ms) × MDNS_ANNOUNCE_COUNT (8) = 8000ms announcing + // + rand() % MDNS_PROBE_DELAY jitter on first probe (0–250ms) + // + debounced schedule_function() hop when statusChangeCB fires on ESP8266 + // ≈ 9s nominal. 15s gives ~6s margin to absorb main-loop blocking (long + // component setup, WiFi scan, flash writes) that could stretch the deadlines + // between our polls. If LEAmDNS ever extends its phase (upstream library + // update) this constant needs to grow. Constants defined in LEAmDNS_Priv.h + // (ESP8266 core 3.1.2 / arduino-pico 5.5.1). + static constexpr uint32_t MDNS_POLL_WINDOW_MS = 15000; + static constexpr uint32_t MDNS_POLL_ID = 0; + static constexpr uint32_t MDNS_POLL_STOP_ID = 1; +#endif float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES @@ -87,7 +110,17 @@ class MDNSComponent final : public Component { } #endif +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; +#endif + protected: +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + /// Arm a fresh MDNS_POLL_WINDOW_MS polling window. Idempotent — re-arming replaces + /// the previous window via the scheduler's atomic cancel-and-add on matching IDs. + void start_polling_window_(); +#endif /// Helper to set up services and MAC buffers, then call platform-specific registration using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); @@ -130,8 +163,8 @@ class MDNSComponent final : public Component { #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif -#ifdef USE_RP2040 - bool was_connected_{false}; +#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) + // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 70c614f8d34..f6d57866756 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,6 +8,8 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" +// wifi_component.h is pulled in transitively by mdns_component.h when +// USE_MDNS_WIFI_LISTENER is defined. namespace esphome::mdns { @@ -36,15 +38,36 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} +#endif + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); - // Schedule MDNS.update() via set_interval() instead of overriding loop(). - // This removes the component from the per-iteration loop list entirely, - // eliminating virtual dispatch overhead on every main loop cycle. - // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +#ifdef USE_MDNS_WIFI_LISTENER + // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just + // arm the window around the initial probe/announce and each reconnect. Unconditional + // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. + wifi::global_wifi_component->add_ip_state_listener(this); + this->start_polling_window_(); +#endif } +#ifdef USE_MDNS_WIFI_LISTENER +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + // IP listener only fires on acquisition (not loss), so any notification is a fresh + // IP worth re-arming for. start_polling_window_() is idempotent. + if (ips[0].is_set()) { + this->start_polling_window_(); + } +} +#endif + void MDNSComponent::on_shutdown() { MDNS.close(); delay(10); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 64b603030c8..f5848893a34 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -6,9 +6,10 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "mdns_component.h" +// wifi_component.h / ethernet_component.h are pulled in transitively by +// mdns_component.h when their respective listener defines are active. // Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. -// Save and restore our definition around the include to avoid a redefinition warning. #pragma push_macro("IRAM_ATTR") #undef IRAM_ATTR #include @@ -20,10 +21,7 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_UPDATE_INTERVAL_MS, [this]() { - bool connected = network::is_connected(); - if (connected && !this->was_connected_) { - if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); - this->initialized_ = true; - } else { - MDNS.notifyAPChange(); - } - } - this->was_connected_ = connected; - if (this->initialized_) { - MDNS.update(); - } - }); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +void MDNSComponent::start_polling_window_() { + // uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add. + this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } +#endif + +void MDNSComponent::setup() { + // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses + // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our + // own IP state listener. Both WiFi and Ethernet have the same listener signature — + // one on_ip_state() override serves both. +#ifdef USE_MDNS_WIFI_LISTENER + wifi::global_wifi_component->add_ip_state_listener(this); + // AFTER_CONNECTION priority means the network may already be up; the listener only + // fires on subsequent changes, so seed the current state. + { + const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); + if (ips[0].is_set()) { + this->on_ip_state(ips, wifi::global_wifi_component->get_dns_address(0), + wifi::global_wifi_component->get_dns_address(1)); + } + } +#endif +#ifdef USE_MDNS_ETHERNET_LISTENER + ethernet::global_eth_component->add_ip_state_listener(this); + if (ethernet::global_eth_component->is_connected()) { + const auto ips = ethernet::global_eth_component->get_ip_addresses(); + if (ips[0].is_set()) { + this->on_ip_state(ips, network::IPAddress{}, network::IPAddress{}); + } + } +#endif +} + +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + // Listener only fires on IP acquisition (not loss); every event is a fresh IP. + if (!ips[0].is_set()) { + return; + } + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + this->start_polling_window_(); +} +#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d1db868ace4..0024e3b9658 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -21,6 +21,7 @@ from esphome.core import CORE from esphome.core.entity_helpers import ( entity_duplicate_validator, inherit_property_from, + queue_entity_register, setup_entity, ) from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -262,7 +263,7 @@ async def setup_media_player_core_(var, config): async def register_media_player(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_media_player(var)) + queue_entity_register("media_player", config) CORE.register_platform_component("media_player", var) await setup_media_player_core_(var, config) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 1a354956183..f04a5906c16 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -352,7 +352,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { ESP_LOGD(TAG, "Setting temperature out-of-range: %.1f", target_temperature); return; } - this->status_.target_temperature = std::round(target_temperature); + this->status_.target_temperature = target_temperature; this->pending_updates_.set(UpdateFlag::TEMPERATURE); } @@ -387,9 +387,9 @@ void MitsubishiCN105::apply_settings_() { if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) { payload[1] |= 0x04; if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(this->status_.target_temperature * 2.0f + 128.0f); + payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); } else { - payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - this->status_.target_temperature); + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); } } diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 2af58a96beb..67e5757397c 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -3,11 +3,8 @@ import binascii from esphome import automation import esphome.codegen as cg from esphome.components import modbus -from esphome.components.const import CONF_ENABLED from esphome.components.modbus.helpers import ( - CPP_TYPE_REGISTER_MAP, MODBUS_REGISTER_TYPE, - SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP, ModbusRegisterType, ) @@ -29,11 +26,10 @@ from .const import ( CONF_ON_OFFLINE, CONF_ON_ONLINE, CONF_REGISTER_COUNT, - CONF_REGISTER_LAST_ADDRESS, CONF_REGISTER_TYPE, - CONF_REGISTER_VALUE, CONF_RESPONSE_SIZE, CONF_SERVER_COURTESY_RESPONSE, + CONF_SERVER_REGISTERS, CONF_SKIP_UPDATES, CONF_VALUE_TYPE, ) @@ -42,9 +38,6 @@ CODEOWNERS = ["@martgras"] AUTO_LOAD = ["modbus"] -CONF_READ_LAMBDA = "read_lambda" -CONF_WRITE_LAMBDA = "write_lambda" -CONF_SERVER_REGISTERS = "server_registers" MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") @@ -53,30 +46,9 @@ ModbusController = modbus_controller_ns.class_( ) SensorItem = modbus_controller_ns.struct("SensorItem") -ServerCourtesyResponse = modbus_controller_ns.struct("ServerCourtesyResponse") -ServerRegister = modbus_controller_ns.struct("ServerRegister") _LOGGER = logging.getLogger(__name__) -SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( - { - cv.Optional(CONF_ENABLED, default=False): cv.boolean, - cv.Optional(CONF_REGISTER_LAST_ADDRESS, default=0xFFFF): cv.hex_uint16_t, - cv.Optional(CONF_REGISTER_VALUE, default=0): cv.hex_uint16_t, - } -) - -ModbusServerRegisterSchema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, - cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), - cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, - } -) - - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -85,12 +57,16 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_COMMAND_THROTTLE, default="0ms" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_SERVER_COURTESY_RESPONSE): SERVER_COURTESY_RESPONSE_SCHEMA, + cv.Optional(CONF_SERVER_COURTESY_RESPONSE): cv.invalid( + "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" + ), cv.Optional(CONF_MAX_CMD_RETRIES, default=4): cv.positive_int, cv.Optional(CONF_OFFLINE_SKIP_UPDATES, default=0): cv.positive_int, cv.Optional( CONF_SERVER_REGISTERS, - ): cv.ensure_list(ModbusServerRegisterSchema), + ): cv.invalid( + "This option has been removed. Use modbus_server component instead: https://esphome.io/components/modbus_server/" + ), cv.Optional(CONF_ON_COMMAND_SENT): automation.validate_automation({}), cv.Optional(CONF_ON_ONLINE): automation.validate_automation({}), cv.Optional(CONF_ON_OFFLINE): automation.validate_automation({}), @@ -142,11 +118,9 @@ def validate_modbus_register(config): def _final_validate(config): - if CONF_SERVER_COURTESY_RESPONSE in config or CONF_SERVER_REGISTERS in config: - return modbus.final_validate_modbus_device("modbus_controller", role="server")( - config - ) - return config + return modbus.final_validate_modbus_device("modbus_controller", role="client")( + config + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -228,53 +202,8 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_allow_duplicate_commands(config[CONF_ALLOW_DUPLICATE_COMMANDS])) cg.add(var.set_command_throttle(config[CONF_COMMAND_THROTTLE])) - if server_courtesy_response := config.get(CONF_SERVER_COURTESY_RESPONSE): - cg.add( - var.set_server_courtesy_response( - cg.StructInitializer( - ServerCourtesyResponse, - ("enabled", server_courtesy_response[CONF_ENABLED]), - ( - "register_last_address", - server_courtesy_response[CONF_REGISTER_LAST_ADDRESS], - ), - ("register_value", server_courtesy_response[CONF_REGISTER_VALUE]), - ) - ) - ) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) - if CONF_SERVER_REGISTERS in config: - for server_register in config[CONF_SERVER_REGISTERS]: - server_register_var = cg.new_Pvariable( - server_register[CONF_ID], - server_register[CONF_ADDRESS], - server_register[CONF_VALUE_TYPE], - TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]], - ) - cpp_type = CPP_TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]] - cg.add( - server_register_var.set_read_lambda( - cg.TemplateArguments(cpp_type), - await cg.process_lambda( - server_register[CONF_READ_LAMBDA], - [(cg.uint16, "address")], - return_type=cpp_type, - ), - ) - ) - if CONF_WRITE_LAMBDA in server_register: - cg.add( - server_register_var.set_write_lambda( - cg.TemplateArguments(cpp_type), - await cg.process_lambda( - server_register[CONF_WRITE_LAMBDA], - parameters=[(cg.uint16, "address"), (cpp_type, "x")], - return_type=cg.bool_, - ), - ) - ) - cg.add(var.add_server_register(server_register_var)) await register_modbus_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/modbus_controller/const.py b/esphome/components/modbus_controller/const.py index c689d845765..0149a3cc499 100644 --- a/esphome/components/modbus_controller/const.py +++ b/esphome/components/modbus_controller/const.py @@ -18,6 +18,7 @@ CONF_REGISTER_TYPE = "register_type" CONF_REGISTER_VALUE = "register_value" CONF_RESPONSE_SIZE = "response_size" CONF_SERVER_COURTESY_RESPONSE = "server_courtesy_response" +CONF_SERVER_REGISTERS = "server_registers" CONF_SKIP_UPDATES = "skip_updates" CONF_USE_WRITE_MULTIPLE = "use_write_multiple" CONF_VALUE_TYPE = "value_type" diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 5c3b39c9548..dabed7136b9 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -112,167 +112,6 @@ void ModbusController::on_modbus_error(uint8_t function_code, uint8_t exception_ } } -void ModbusController::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { - ESP_LOGD(TAG, - "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - std::vector sixteen_bit_response; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool found = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - if (!server_register->read_lambda) { - break; - } - int64_t value = server_register->read_lambda(); - ESP_LOGD(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", - server_register->address, static_cast(server_register->value_type), - server_register->register_count, server_register->format_value(value).c_str()); - - std::vector payload; - payload.reserve(server_register->register_count * 2); - modbus::helpers::number_to_payload(payload, value, server_register->value_type); - sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); - current_address += server_register->register_count; - found = true; - break; - } - } - - if (!found) { - if (this->server_courtesy_response_.enabled && - (current_address <= this->server_courtesy_response_.register_last_address)) { - ESP_LOGD(TAG, - "Could not match any register to address 0x%02X, but default allowed. " - "Returning default value: %d.", - current_address, this->server_courtesy_response_.register_value); - sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); - current_address += 1; // Just increment by 1, as the default response is a single register - } else { - ESP_LOGW(TAG, - "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", - current_address); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - } - } - - std::vector response; - for (auto v : sixteen_bit_response) { - auto decoded_value = decode_value(v); - response.push_back(decoded_value[0]); - response.push_back(decoded_value[1]); - } - - this->send(function_code, start_address, number_of_registers, response.size(), response.data()); -} - -void ModbusController::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { - uint16_t number_of_registers; - uint16_t payload_offset; - - if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { - if (data.size() < 5) { - ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); - if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - uint16_t payload_size = data[4]; - if (payload_size != number_of_registers * 2) { - ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", - payload_size, number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - if (data.size() < 5 + payload_size) { - ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), - 5 + payload_size); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - payload_offset = 5; - } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { - if (data.size() < 4) { - ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; - } - number_of_registers = 1; - payload_offset = 2; - } else { - ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); - ESP_LOGD(TAG, - "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); - - auto for_each_register = [this, start_address, number_of_registers, payload_offset]( - const std::function &callback) -> bool { - uint16_t offset = payload_offset; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool ok = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - ok = callback(server_register, offset); - current_address += server_register->register_count; - offset += server_register->register_count * sizeof(uint16_t); - break; - } - } - - if (!ok) { - return false; - } - } - return true; - }; - - // check all registers are writable before writing to any of them: - if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { - return server_register->write_lambda != nullptr; - })) { - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; - } - - // Actually write to the registers: - if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); - return server_register->write_lambda(number); - })) { - this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; - } - - std::vector response; - response.reserve(6); - response.push_back(this->address_); - response.push_back(function_code); - response.insert(response.end(), data.begin(), data.begin() + 4); - this->send_raw(response); -} - SensorSet ModbusController::find_sensors_(ModbusRegisterType register_type, uint16_t start_address) const { auto reg_it = std::find_if( std::begin(this->register_ranges_), std::end(this->register_ranges_), @@ -472,14 +311,8 @@ void ModbusController::dump_config() { "ModbusController:\n" " Address: 0x%02X\n" " Max Command Retries: %d\n" - " Offline Skip Updates: %d\n" - " Server Courtesy Response:\n" - " Enabled: %s\n" - " Register Last Address: 0x%02X\n" - " Register Value: %d", - this->address_, this->max_cmd_retries_, this->offline_skip_updates_, - this->server_courtesy_response_.enabled ? "true" : "false", - this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value); + " Offline Skip Updates: %d\n", + this->address_, this->max_cmd_retries_, this->offline_skip_updates_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGCONFIG(TAG, "sensormap"); @@ -493,11 +326,6 @@ void ModbusController::dump_config() { ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), it.start_address, it.register_count, it.skip_updates); } - ESP_LOGCONFIG(TAG, "server registers"); - for (auto &r : this->server_registers_) { - ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, - static_cast(r->value_type), r->register_count); - } #endif } diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 6c6c748b73f..40139f055b4 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -120,82 +120,6 @@ class SensorItem { bool force_new_range{false}; }; -struct ServerCourtesyResponse { - bool enabled{false}; - uint16_t register_last_address{0xFFFF}; - uint16_t register_value{0}; -}; - -class ServerRegister { - using ReadLambda = std::function; - using WriteLambda = std::function; - - public: - ServerRegister(uint16_t address, SensorValueType value_type, uint8_t register_count) { - this->address = address; - this->value_type = value_type; - this->register_count = register_count; - } - - template void set_read_lambda(const std::function &&user_read_lambda) { - this->read_lambda = [this, user_read_lambda]() -> int64_t { - T user_value = user_read_lambda(this->address); - if constexpr (std::is_same_v) { - return bit_cast(user_value); - } else { - return static_cast(user_value); - } - }; - } - - template - void set_write_lambda(const std::function &&user_write_lambda) { - this->write_lambda = [this, user_write_lambda](int64_t number) { - if constexpr (std::is_same_v) { - float float_value = bit_cast(static_cast(number)); - return user_write_lambda(this->address, float_value); - } - return user_write_lambda(this->address, static_cast(number)); - }; - } - - // Formats a raw value into a string representation based on the value type for debugging - std::string format_value(int64_t value) const { - // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) - // plus null terminator = 43, rounded to 44 for 4-byte alignment - char buf[44]; - switch (this->value_type) { - case SensorValueType::U_WORD: - case SensorValueType::U_DWORD: - case SensorValueType::U_DWORD_R: - case SensorValueType::U_QWORD: - case SensorValueType::U_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); - return buf; - case SensorValueType::S_WORD: - case SensorValueType::S_DWORD: - case SensorValueType::S_DWORD_R: - case SensorValueType::S_QWORD: - case SensorValueType::S_QWORD_R: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); - return buf; - case SensorValueType::FP32_R: - case SensorValueType::FP32: - buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); - return buf; - default: - buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); - return buf; - } - } - - uint16_t address{0}; - SensorValueType value_type{SensorValueType::RAW}; - uint8_t register_count{0}; - ReadLambda read_lambda; - WriteLambda write_lambda; -}; - // ModbusController::create_register_ranges_ tries to optimize register range // for this the sensors must be ordered by register_type, start_address and bitmask class SensorItemsComparator { @@ -367,16 +291,10 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void queue_command(const ModbusCommandItem &command); /// Registers a sensor with the controller. Called by esphomes code generator void add_sensor_item(SensorItem *item) { sensorset_.insert(item); } - /// Registers a server register with the controller. Called by esphomes code generator - void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } /// called when a modbus response was parsed without errors void on_modbus_data(const std::vector &data) override; /// called when a modbus error response was received void on_modbus_error(uint8_t function_code, uint8_t exception_code) override; - /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors - void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; - /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; /// default delegate called by process_modbus_data when a response has retrieved from the incoming queue void on_register_data(ModbusRegisterType register_type, uint16_t start_address, const std::vector &data); /// default delegate called by process_modbus_data when a response for a write response has retrieved from the @@ -413,12 +331,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void set_max_cmd_retries(uint8_t max_cmd_retries) { this->max_cmd_retries_ = max_cmd_retries; } /// get how many times a command will be (re)sent if no response is received uint8_t get_max_cmd_retries() { return this->max_cmd_retries_; } - /// Called by esphome generated code to set the server courtesy response object - void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { - this->server_courtesy_response_ = server_courtesy_response; - } - /// Get the server courtesy response object - ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } protected: /// parse sensormap_ and create range of sequential addresses @@ -435,8 +347,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { void dump_sensors_(); /// Collection of all sensors for this component SensorSet sensorset_; - /// Collection of all server registers for this component - std::vector server_registers_{}; /// Continuous range of modbus registers std::vector register_ranges_{}; /// Hold the pending requests to be sent @@ -461,9 +371,6 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { CallbackManager online_callback_{}; /// Server offline callback CallbackManager offline_callback_{}; - /// Server courtesy response - ServerCourtesyResponse server_courtesy_response_{ - .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; }; /** Convert vector response payload to float. diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py new file mode 100644 index 00000000000..5182bc05d12 --- /dev/null +++ b/esphome/components/modbus_server/__init__.py @@ -0,0 +1,124 @@ +import esphome.codegen as cg +from esphome.components import modbus +from esphome.components.const import CONF_ENABLED +from esphome.components.modbus.helpers import ( + CPP_TYPE_REGISTER_MAP, + SENSOR_VALUE_TYPE, + TYPE_REGISTER_MAP, +) +import esphome.config_validation as cv +from esphome.const import CONF_ADDRESS, CONF_ID + +from .const import ( + CONF_COURTESY_RESPONSE, + CONF_READ_LAMBDA, + CONF_REGISTER_LAST_ADDRESS, + CONF_REGISTER_VALUE, + CONF_REGISTERS, + CONF_VALUE_TYPE, + CONF_WRITE_LAMBDA, +) + +CODEOWNERS = ["@exciton"] + +AUTO_LOAD = ["modbus"] + +MULTI_CONF = True + +modbus_server_ns = cg.esphome_ns.namespace("modbus_server") +ModbusServer = modbus_server_ns.class_( + "ModbusServer", cg.Component, modbus.ModbusDevice +) + +ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") +ServerRegister = modbus_server_ns.struct("ServerRegister") + +SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=False): cv.boolean, + cv.Optional(CONF_REGISTER_LAST_ADDRESS, default=0xFFFF): cv.hex_uint16_t, + cv.Optional(CONF_REGISTER_VALUE, default=0): cv.hex_uint16_t, + } +) + +ModbusServerRegisterSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerRegister), + cv.Required(CONF_ADDRESS): cv.positive_int, + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ModbusServer), + cv.Optional(CONF_COURTESY_RESPONSE): SERVER_COURTESY_RESPONSE_SCHEMA, + cv.Optional( + CONF_REGISTERS, + ): cv.ensure_list(ModbusServerRegisterSchema), + } + ).extend(modbus.modbus_device_schema(0x01)), +) + + +def _final_validate(config): + return modbus.final_validate_modbus_device("modbus_server", role="server")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + if server_courtesy_response := config.get(CONF_COURTESY_RESPONSE): + cg.add( + var.set_server_courtesy_response( + cg.StructInitializer( + ServerCourtesyResponse, + ("enabled", server_courtesy_response[CONF_ENABLED]), + ( + "register_last_address", + server_courtesy_response[CONF_REGISTER_LAST_ADDRESS], + ), + ("register_value", server_courtesy_response[CONF_REGISTER_VALUE]), + ) + ) + ) + if CONF_REGISTERS in config: + for server_register in config[CONF_REGISTERS]: + server_register_var = cg.new_Pvariable( + server_register[CONF_ID], + server_register[CONF_ADDRESS], + server_register[CONF_VALUE_TYPE], + TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]], + ) + cpp_type = CPP_TYPE_REGISTER_MAP[server_register[CONF_VALUE_TYPE]] + cg.add( + server_register_var.set_read_lambda( + cg.TemplateArguments(cpp_type), + await cg.process_lambda( + server_register[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cpp_type, + ), + ) + ) + if CONF_WRITE_LAMBDA in server_register: + cg.add( + server_register_var.set_write_lambda( + cg.TemplateArguments(cpp_type), + await cg.process_lambda( + server_register[CONF_WRITE_LAMBDA], + parameters=[(cg.uint16, "address"), (cpp_type, "x")], + return_type=cg.bool_, + ), + ) + ) + cg.add(var.add_server_register(server_register_var)) + cg.add(var.set_address(config[CONF_ADDRESS])) + await cg.register_component(var, config) + return await modbus.register_modbus_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py new file mode 100644 index 00000000000..f83211c207b --- /dev/null +++ b/esphome/components/modbus_server/const.py @@ -0,0 +1,7 @@ +CONF_REGISTER_LAST_ADDRESS = "register_last_address" +CONF_REGISTER_VALUE = "register_value" +CONF_VALUE_TYPE = "value_type" +CONF_COURTESY_RESPONSE = "courtesy_response" +CONF_READ_LAMBDA = "read_lambda" +CONF_WRITE_LAMBDA = "write_lambda" +CONF_REGISTERS = "registers" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp new file mode 100644 index 00000000000..0063da3a1da --- /dev/null +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -0,0 +1,192 @@ +#include "modbus_server.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::modbus_server { +using modbus::ModbusFunctionCode; +using modbus::ModbusExceptionCode; + +static const char *const TAG = "modbus_server"; + +void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers) { + ESP_LOGD(TAG, + "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " + "0x%X.", + this->address_, function_code, start_address, number_of_registers); + + if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return; + } + + std::vector sixteen_bit_response; + for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + bool found = false; + for (auto *server_register : this->server_registers_) { + if (server_register->address == current_address) { + if (!server_register->read_lambda) { + break; + } + int64_t value = server_register->read_lambda(); + ESP_LOGD(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", + server_register->address, static_cast(server_register->value_type), + server_register->register_count, server_register->format_value(value).c_str()); + + std::vector payload; + payload.reserve(server_register->register_count * 2); + modbus::helpers::number_to_payload(payload, value, server_register->value_type); + sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); + current_address += server_register->register_count; + found = true; + break; + } + } + + if (!found) { + if (this->server_courtesy_response_.enabled && + (current_address <= this->server_courtesy_response_.register_last_address)) { + ESP_LOGD(TAG, + "Could not match any register to address 0x%02X, but default allowed. " + "Returning default value: %d.", + current_address, this->server_courtesy_response_.register_value); + sixteen_bit_response.push_back(this->server_courtesy_response_.register_value); + current_address += 1; // Just increment by 1, as the default response is a single register + } else { + ESP_LOGW(TAG, + "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", + current_address); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return; + } + } + } + + std::vector response; + for (auto v : sixteen_bit_response) { + auto decoded_value = decode_value(v); + response.push_back(decoded_value[0]); + response.push_back(decoded_value[1]); + } + + this->send(function_code, start_address, number_of_registers, response.size(), response.data()); +} + +void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { + uint16_t number_of_registers; + uint16_t payload_offset; + + if (function_code == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + if (data.size() < 5) { + ESP_LOGW(TAG, "Write multiple registers data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + number_of_registers = uint16_t(data[3]) | (uint16_t(data[2]) << 8); + if (number_of_registers == 0 || number_of_registers > modbus::MAX_NUM_OF_REGISTERS_TO_WRITE) { + ESP_LOGW(TAG, "Invalid number of registers %d. Sending exception response.", number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + uint16_t payload_size = data[4]; + if (payload_size != number_of_registers * 2) { + ESP_LOGW(TAG, "Payload size of %d bytes is not 2 times the number of registers (%d). Sending exception response.", + payload_size, number_of_registers); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (data.size() < 5 + payload_size) { + ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), + 5 + payload_size); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + payload_offset = 5; + } else if (function_code == ModbusFunctionCode::WRITE_SINGLE_REGISTER) { + if (data.size() < 4) { + ESP_LOGW(TAG, "Write single register data too short (%zu bytes)", data.size()); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + number_of_registers = 1; + payload_offset = 2; + } else { + ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + + uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); + ESP_LOGD(TAG, + "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " + "0x%X.", + this->address_, function_code, start_address, number_of_registers); + + auto for_each_register = [this, start_address, number_of_registers, payload_offset]( + const std::function &callback) -> bool { + uint16_t offset = payload_offset; + for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { + bool ok = false; + for (auto *server_register : this->server_registers_) { + if (server_register->address == current_address) { + ok = callback(server_register, offset); + current_address += server_register->register_count; + offset += server_register->register_count * sizeof(uint16_t); + break; + } + } + + if (!ok) { + return false; + } + } + return true; + }; + + // check all registers are writable before writing to any of them: + if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { + return server_register->write_lambda != nullptr; + })) { + this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + + // Actually write to the registers: + if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { + int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); + return server_register->write_lambda(number); + })) { + this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + return; + } + + std::vector response; + response.reserve(6); + response.push_back(this->address_); + response.push_back(function_code); + response.insert(response.end(), data.begin(), data.begin() + 4); + this->send_raw(response); +} + +void ModbusServer::dump_config() { + ESP_LOGCONFIG(TAG, + "ModbusServer:\n" + " Address: 0x%02X\n" + " Server Courtesy Response:\n" + " Enabled: %s\n" + " Register Last Address: 0x%02X\n" + " Register Value: %" PRIu16, + this->address_, this->server_courtesy_response_.enabled ? "true" : "false", + this->server_courtesy_response_.register_last_address, this->server_courtesy_response_.register_value); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGCONFIG(TAG, "server registers"); + for (auto &r : this->server_registers_) { + ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, + static_cast(r->value_type), r->register_count); + } +#endif +} + +} // namespace esphome::modbus_server diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h new file mode 100644 index 00000000000..0fc2e0bef5d --- /dev/null +++ b/esphome/components/modbus_server/modbus_server.h @@ -0,0 +1,119 @@ +#pragma once + +#include "esphome/core/component.h" + +#include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" +#include "esphome/core/automation.h" + +#include +#include + +namespace esphome::modbus_server { + +using modbus::helpers::SensorValueType; + +struct ServerCourtesyResponse { + bool enabled{false}; + uint16_t register_last_address{0xFFFF}; + uint16_t register_value{0}; +}; + +class ServerRegister { + using ReadLambda = std::function; + using WriteLambda = std::function; + + public: + ServerRegister(uint16_t address, SensorValueType value_type, uint8_t register_count) { + this->address = address; + this->value_type = value_type; + this->register_count = register_count; + } + + template void set_read_lambda(const std::function &&user_read_lambda) { + this->read_lambda = [this, user_read_lambda]() -> int64_t { + T user_value = user_read_lambda(this->address); + if constexpr (std::is_same_v) { + return bit_cast(user_value); + } else { + return static_cast(user_value); + } + }; + } + + template + void set_write_lambda(const std::function &&user_write_lambda) { + this->write_lambda = [this, user_write_lambda](int64_t number) { + if constexpr (std::is_same_v) { + float float_value = bit_cast(static_cast(number)); + return user_write_lambda(this->address, float_value); + } + return user_write_lambda(this->address, static_cast(number)); + }; + } + + // Formats a raw value into a string representation based on the value type for debugging + std::string format_value(int64_t value) const { + // max 44: float with %.1f can be up to 42 chars (3.4e38 → 39 integer digits + sign + decimal + 1 digit) + // plus null terminator = 43, rounded to 44 for 4-byte alignment + char buf[44]; + switch (this->value_type) { + case SensorValueType::U_WORD: + case SensorValueType::U_DWORD: + case SensorValueType::U_DWORD_R: + case SensorValueType::U_QWORD: + case SensorValueType::U_QWORD_R: + buf_append_printf(buf, sizeof(buf), 0, "%" PRIu64, static_cast(value)); + return buf; + case SensorValueType::S_WORD: + case SensorValueType::S_DWORD: + case SensorValueType::S_DWORD_R: + case SensorValueType::S_QWORD: + case SensorValueType::S_QWORD_R: + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; + case SensorValueType::FP32_R: + case SensorValueType::FP32: + buf_append_printf(buf, sizeof(buf), 0, "%.1f", bit_cast(static_cast(value))); + return buf; + default: + buf_append_printf(buf, sizeof(buf), 0, "%" PRId64, value); + return buf; + } + } + + uint16_t address{0}; + SensorValueType value_type{SensorValueType::RAW}; + uint8_t register_count{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + +class ModbusServer : public Component, public modbus::ModbusDevice { + public: + void dump_config() override; + + /// Not used for ModbusServer. + void on_modbus_data(const std::vector &data) override{}; + /// Registers a server register with the controller. Called by esphomes code generator + void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } + /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors + void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; + /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors + void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; + /// Called by esphome generated code to set the server courtesy response object + void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { + this->server_courtesy_response_ = server_courtesy_response; + } + /// Get the server courtesy response object + ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } + + protected: + /// Collection of all server registers for this component + std::vector server_registers_{}; + /// Server courtesy response + ServerCourtesyResponse server_courtesy_response_{ + .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; +}; + +} // namespace esphome::modbus_server diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index c79c68552ea..399f217a19f 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -16,6 +16,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -80,14 +87,14 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) { recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, this->upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_, EspClass::getFreeHeap()); this->upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; @@ -324,7 +331,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { #ifdef USE_ESP8266 WiFiClient *Nextion::get_wifi_client_() { - if (this->tft_url_.compare(0, 6, "https:") == 0) { + if (this->tft_url_.starts_with("https:")) { if (this->wifi_client_secure_ == nullptr) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) this->wifi_client_secure_ = new BearSSL::WiFiClientSecure(); diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 40a284dc46b..db4558e2fe2 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -19,6 +19,13 @@ namespace esphome::nextion { static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; +// Timeout for display acknowledgment during TFT upload (ms). +// A single value is used for all chunks; the happy path returns as soon as +// 0x05/0x08 arrives, so this only bounds failed-detection latency. Field +// reports showed the previous 500ms steady-state value was too tight for +// some firmware variants. +static constexpr uint32_t NEXTION_UPLOAD_ACK_TIMEOUT_MS = 5000; + // Followed guide // https://unofficialnextion.com/t/nextion-upload-protocol-v1-2-the-fast-one/1044/2 @@ -96,7 +103,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r recv_string.clear(); this->write_array(buffer, buffer_size); App.feed_wdt(); - this->recv_ret_string_(recv_string, upload_first_chunk_sent_ ? 500 : 5000, true); + this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true); this->content_length_ -= read_len; const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_; #ifdef USE_PSRAM @@ -109,7 +116,7 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r #endif upload_first_chunk_sent_ = true; if (recv_string.empty()) { - ESP_LOGW(TAG, "No response from display during upload"); + ESP_LOGW(TAG, "No response from display after %" PRIu32 "ms", NEXTION_UPLOAD_ACK_TIMEOUT_MS); allocator.deallocate(buffer, 4096); buffer = nullptr; return -1; diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 5d92a4fa801..d2ed3b15e9c 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -141,6 +141,22 @@ CONF_UICR_ERASE = "uicr_erase" VOLTAGE_LEVELS = [1.8, 2.1, 2.4, 2.7, 3.0, 3.3] +_DFU_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DeviceFirmwareUpdate), + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } +) + + +def _dfu_schema(value: bool | ConfigType) -> ConfigType: + if isinstance(value, bool): + if not value: + raise cv.Invalid("Use 'dfu: true' or specify a configuration dict") + return _DFU_SCHEMA({}) + return _DFU_SCHEMA(value) + + CONFIG_SCHEMA = cv.All( _detect_bootloader, set_core_data, @@ -150,12 +166,7 @@ CONFIG_SCHEMA = cv.All( cv.string_strict, cv.ByteLength(max=BOARD_MAX_LENGTH) ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), - cv.Optional(CONF_DFU): cv.Schema( - { - cv.GenerateID(): cv.declare_id(DeviceFirmwareUpdate), - cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, - } - ), + cv.Optional(CONF_DFU): _dfu_schema, cv.Optional(CONF_DCDC, default=True): cv.boolean, cv.Optional(CONF_REG0): cv.Schema( { @@ -321,8 +332,9 @@ async def to_code(config: ConfigType) -> None: async def _dfu_to_code(dfu_config): cg.add_define("USE_NRF52_DFU") var = cg.new_Pvariable(dfu_config[CONF_ID]) - pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) - cg.add(var.set_reset_pin(pin)) + if CONF_RESET_PIN in dfu_config: + pin = await cg.gpio_pin_expression(dfu_config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(pin)) zephyr_add_prj_conf("CDC_ACM_DTE_RATE_CALLBACK_SUPPORT", True) await cg.register_component(var, dfu_config) diff --git a/esphome/components/nrf52/dfu.cpp b/esphome/components/nrf52/dfu.cpp index c2017248d20..24dee997269 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -2,24 +2,34 @@ #ifdef USE_NRF52_DFU +#include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/components/zephyr/cdc_acm.h" -namespace esphome { -namespace nrf52 { +#include + +namespace esphome::nrf52 { static const char *const TAG = "dfu"; static const uint32_t DFU_DBL_RESET_MAGIC = 0x5A1AD5; // SALADS +static const uint8_t DFU_MAGIC_UF2_RESET = 0x57; // Adafruit nRF52 bootloader UF2 magic void DeviceFirmwareUpdate::setup() { - this->reset_pin_->setup(); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + } #if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) zephyr::global_cdc_acm->add_on_rate_callback([this](const device *, uint32_t rate) { if (rate == 1200) { volatile uint32_t *dbl_reset_mem = (volatile uint32_t *) 0x20007F7C; (*dbl_reset_mem) = DFU_DBL_RESET_MAGIC; - this->reset_pin_->digital_write(true); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(true); + } else { + NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + App.reboot(); + } } }); #endif @@ -27,10 +37,13 @@ void DeviceFirmwareUpdate::setup() { void DeviceFirmwareUpdate::dump_config() { ESP_LOGCONFIG(TAG, "DFU:"); - LOG_PIN(" RESET Pin: ", this->reset_pin_); + if (this->reset_pin_ != nullptr) { + LOG_PIN(" RESET Pin: ", this->reset_pin_); + } else { + ESP_LOGCONFIG(TAG, " Method: GPREGRET"); + } } -} // namespace nrf52 -} // namespace esphome +} // namespace esphome::nrf52 #endif diff --git a/esphome/components/nrf52/dfu.h b/esphome/components/nrf52/dfu.h index 71060e43c18..82c7d9f54eb 100644 --- a/esphome/components/nrf52/dfu.h +++ b/esphome/components/nrf52/dfu.h @@ -5,8 +5,7 @@ #include "esphome/core/component.h" #include "esphome/core/gpio.h" -namespace esphome { -namespace nrf52 { +namespace esphome::nrf52 { class DeviceFirmwareUpdate : public Component { public: void setup() override; @@ -14,10 +13,9 @@ class DeviceFirmwareUpdate : public Component { void dump_config() override; protected: - GPIOPin *reset_pin_; + GPIOPin *reset_pin_{nullptr}; }; -} // namespace nrf52 -} // namespace esphome +} // namespace esphome::nrf52 #endif diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index f13ccc4c36d..ee2d53c65a4 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -82,6 +82,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -301,7 +302,7 @@ async def register_number( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_number(var)) + queue_entity_register("number", config) CORE.register_platform_component("number", var) await setup_number_core_( var, config, min_value=min_value, max_value=max_value, step=step diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 24926aa4dcd..a5a3ea51041 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -28,7 +28,7 @@ bool OnlineImage::validate_url_(const std::string &url) { ESP_LOGE(TAG, "URL is too long"); return false; } - if (url.compare(0, 7, "http://") != 0 && url.compare(0, 8, "https://") != 0) { + if (!url.starts_with("http://") && !url.starts_with("https://")) { ESP_LOGE(TAG, "URL must start with http:// or https://"); return false; } diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 36798f2d7f0..4f6c8943f5e 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -54,10 +54,16 @@ async def setup_output_platform_(obj, config): power_supply_ = await cg.get_variable(config[CONF_POWER_SUPPLY]) cg.add(obj.set_power_supply(power_supply_)) if CONF_MAX_POWER in config: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_max_power(config[CONF_MAX_POWER])) if CONF_MIN_POWER in config: + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_min_power(config[CONF_MIN_POWER])) - if CONF_ZERO_MEANS_ZERO in config: + # Only emit when zero_means_zero is actually enabled. The schema defaults to False + # so this key is always present; emitting unconditionally would force + # USE_OUTPUT_FLOAT_POWER_SCALING on for every output, defeating the gate. + if config.get(CONF_ZERO_MEANS_ZERO): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") cg.add(obj.set_zero_means_zero(config[CONF_ZERO_MEANS_ZERO])) @@ -121,6 +127,7 @@ async def output_set_level_to_code(config, action_id, template_arg, args): synchronous=True, ) async def output_set_min_power_to_code(config, action_id, template_arg, args): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_MIN_POWER], args, cg.float_) @@ -140,6 +147,7 @@ async def output_set_min_power_to_code(config, action_id, template_arg, args): synchronous=True, ) async def output_set_max_power_to_code(config, action_id, template_arg, args): + cg.add_define("USE_OUTPUT_FLOAT_POWER_SCALING") paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_MAX_POWER], args, cg.float_) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 32793781292..537226a143d 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -2,6 +2,7 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" @@ -40,6 +41,7 @@ template class SetLevelAction : public Action { FloatOutput *output_; }; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING template class SetMinPowerAction : public Action { public: SetMinPowerAction(FloatOutput *output) : output_(output) {} @@ -63,6 +65,7 @@ template class SetMaxPowerAction : public Action { protected: FloatOutput *output_; }; +#endif // USE_OUTPUT_FLOAT_POWER_SCALING } // namespace output } // namespace esphome diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index 46014e0903a..35629c828af 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -7,13 +7,15 @@ namespace output { static const char *const TAG = "output.float"; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING void FloatOutput::set_max_power(float max_power) { - this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to MIN>=MAX>=1.0 + this->max_power_ = clamp(max_power, this->min_power_, 1.0f); // Clamp to min_power <= max <= 1.0 } void FloatOutput::set_min_power(float min_power) { - this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0>=MIN>=MAX + this->min_power_ = clamp(min_power, 0.0f, this->max_power_); // Clamp to 0.0 <= min <= max_power } +#endif void FloatOutput::set_level(float state) { state = clamp(state, 0.0f, 1.0f); @@ -26,8 +28,10 @@ void FloatOutput::set_level(float state) { } #endif +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING if (state != 0.0f || !this->zero_means_zero_) // regardless of min_power_, 0.0 means off state = (state * (this->max_power_ - this->min_power_)) + this->min_power_; +#endif if (this->is_inverted()) state = 1.0f - state; diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index 5225f88c669..3e1bd839686 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -1,11 +1,13 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "binary_output.h" namespace esphome { namespace output { +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING #define LOG_FLOAT_OUTPUT(this) \ LOG_BINARY_OUTPUT(this) \ if (this->max_power_ != 1.0f) { \ @@ -14,6 +16,9 @@ namespace output { if (this->min_power_ != 0.0f) { \ ESP_LOGCONFIG(TAG, " Min Power: %.1f%%", this->min_power_ * 100.0f); \ } +#else +#define LOG_FLOAT_OUTPUT(this) LOG_BINARY_OUTPUT(this) +#endif /** Base class for all output components that can output a variable level, like PWM. * @@ -22,14 +27,18 @@ namespace output { * makes using maths much easier and (in theory) supports all possible bit depths. * * If you want to create a FloatOutput yourself, you essentially just have to override write_state(float). - * That method will be called for you with inversion and max-min power and offset to min power already applied. + * That method will be called for you with inversion already applied. When USE_OUTPUT_FLOAT_POWER_SCALING is + * enabled (set automatically by Python codegen if any output uses min_power/max_power/zero_means_zero or the + * matching runtime actions), the value will additionally have max-min power scaling and offset to min_power + * applied; otherwise only inversion is applied. * * This interface is compatible with BinaryOutput (and will automatically convert the binary states to floating * point states for you). Additionally, this class provides a way for users to set a minimum and/or maximum power - * output + * output (gated on USE_OUTPUT_FLOAT_POWER_SCALING). */ class FloatOutput : public BinaryOutput { public: +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING /** Set the maximum power output of this component. * * All values are multiplied by max_power - min_power and offset to min_power to get the adjusted value. @@ -51,6 +60,32 @@ class FloatOutput : public BinaryOutput { * @param zero_means_zero True if a 0 state should mean 0 and not min_power. */ void set_zero_means_zero(bool zero_means_zero) { this->zero_means_zero_ = zero_means_zero; } +#else + // Compile-time guards for users calling these methods from lambdas (documented usage at + // https://esphome.io/components/output/#output-set_min_power_action). When power scaling + // is compiled out, these template stubs fail to compile with an actionable error pointing + // at the user's lambda. Templating on a default-false bool means static_assert only fires + // on instantiation (i.e. when the user actually calls the method), not on every parse. + template void set_max_power(float max_power) { + static_assert(_use_output_float_power_scaling, + "set_max_power() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'max_power: 100%' (or any value) to one output entry in your YAML — " + "the codegen will then keep the scaling fields. " + "See https://esphome.io/components/output/ for details."); + } + template void set_min_power(float min_power) { + static_assert(_use_output_float_power_scaling, + "set_min_power() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'min_power: 0%' (or any value) to one output entry in your YAML — " + "the codegen will then keep the scaling fields. " + "See https://esphome.io/components/output/ for details."); + } + template void set_zero_means_zero(bool zero_means_zero) { + static_assert(_use_output_float_power_scaling, + "set_zero_means_zero() requires USE_OUTPUT_FLOAT_POWER_SCALING. " + "To enable it, add 'zero_means_zero: true' to one output entry in your YAML."); + } +#endif /** Set the level of this float output, this is called from the front-end. * @@ -69,20 +104,30 @@ class FloatOutput : public BinaryOutput { // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING /// Get the maximum power output. float get_max_power() const { return this->max_power_; } /// Get the minimum power output. float get_min_power() const { return this->min_power_; } +#else + /// Get the maximum power output. + float get_max_power() const { return 1.0f; } + + /// Get the minimum power output. + float get_min_power() const { return 0.0f; } +#endif protected: /// Implement BinarySensor's write_enabled; this should never be called. void write_state(bool state) override; virtual void write_state(float state) = 0; +#ifdef USE_OUTPUT_FLOAT_POWER_SCALING float max_power_{1.0f}; float min_power_{0.0f}; - bool zero_means_zero_; + bool zero_means_zero_{false}; +#endif }; } // namespace output diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index b6ec0067c93..47a1fd20a75 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -205,7 +205,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: ) -def _process_remote_package(config: dict, skip_update: bool = False) -> dict: +def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: """Clone/update a git repo and load the YAML files listed in the package definition. Returns ``{"packages": {: , ...}}`` so the caller @@ -215,11 +215,10 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: If loading fails after cloning, attempts a revert and retry in case a prior cached checkout is stale. """ - actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( url=config[CONF_URL], ref=config.get(CONF_REF), - refresh=actual_refresh, + refresh=config[CONF_REFRESH], domain=DOMAIN, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), @@ -378,9 +377,8 @@ def _substitute_package_definition( Local package contents are left untouched — they will be substituted later during the main substitution pass. """ - if isinstance(package_config, str) or ( - isinstance(package_config, dict) and is_remote_package(package_config) - ): + + def do_substitute(package_config: dict | str) -> dict | str: # Collect undefined-variable errors (rather than raising strict) so the # path walked through a remote-package dict is preserved and the user # sees which field (url / path / ref / ...) referenced the undefined @@ -394,6 +392,22 @@ def _substitute_package_definition( errors=errors, ) raise_first_undefined(errors, "package definition") + return package_config + + if isinstance(package_config, str): + return do_substitute(package_config) + + if isinstance(package_config, dict) and is_remote_package(package_config): + # Mark vars as literal to avoid substituting variables in the vars block itself, since they are meant to be + # passed as-is to the package YAML and may contain their own substitution expressions that should not + # be prematurely evaluated here. + if CONF_FILES in package_config: + for file_def in package_config[CONF_FILES]: + if isinstance(file_def, dict) and CONF_VARS in file_def: + file_def[CONF_VARS] = yaml_util.make_literal(file_def[CONF_VARS]) + + package_config = do_substitute(package_config) + return package_config @@ -441,11 +455,9 @@ class _PackageProcessor: self, substitutions: UserDict, command_line_substitutions: dict[str, Any] | None, - skip_update: bool, ) -> None: self.substitutions = substitutions self.parent_context = UserDict(command_line_substitutions or {}) - self.skip_update = skip_update def resolve_package( self, @@ -493,7 +505,7 @@ class _PackageProcessor: ) if is_remote_package(package_config): - package_config = _process_remote_package(package_config, self.skip_update) + package_config = _process_remote_package(package_config) return package_config def collect_substitutions(self, package_config: dict) -> None: @@ -537,11 +549,10 @@ class _PackageProcessor: def do_packages_pass( - config: dict, + config: dict[str, Any], *, command_line_substitutions: dict[str, Any] | None = None, - skip_update: bool = False, -) -> dict: +) -> dict[str, Any]: """Load, validate, and flatten all packages in the config. Returns the config with all packages loaded in-place (but not yet merged) @@ -556,9 +567,7 @@ def do_packages_pass( config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions ) ) - processor = _PackageProcessor( - substitutions, command_line_substitutions, skip_update - ) + processor = _PackageProcessor(substitutions, command_line_substitutions) _update_substitutions_context(processor.parent_context, substitutions) context_vars = push_context( diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index c304d206c00..5123d8d9d33 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -433,13 +433,17 @@ void Pipsolar::handle_qpigs_(const char *message) { } void Pipsolar::handle_qmod_(const char *message) { - std::string mode; - char device_mode = char(message[1]); if (this->last_qmod_) { this->last_qmod_->publish_state(message); } + // QMOD response is "(M" where M is the device-mode character. Bail out if the + // message is shorter than 2 chars (e.g. empty error response from + // handle_poll_error_) — reading message[1] would otherwise be out of bounds. + if (message[0] == '\0' || message[1] == '\0') + return; if (this->device_mode_) { - mode = device_mode; + std::string mode; + mode = char(message[1]); this->device_mode_->publish_state(mode); } } diff --git a/esphome/components/pn532/pn532.cpp b/esphome/components/pn532/pn532.cpp index 199a44dacc1..3017b78414d 100644 --- a/esphome/components/pn532/pn532.cpp +++ b/esphome/components/pn532/pn532.cpp @@ -317,6 +317,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) { if (!this->rd_start_time_.has_value()) { this->rd_start_time_ = millis(); } + const uint32_t rd_start_time = *this->rd_start_time_; while (true) { if (this->is_read_ready()) { @@ -324,7 +325,7 @@ enum PN532ReadReady PN532::read_ready_(bool block) { break; } - if (millis() - *this->rd_start_time_ > 100) { + if (millis() - rd_start_time > 100) { ESP_LOGV(TAG, "Timed out waiting for readiness from PN532!"); this->rd_ready_ = TIMEOUT; break; diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index b00590ceb58..a54ab6e2492 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -12,7 +12,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import setup_entity +from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -55,8 +55,8 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non """Register a radio frequency device with the core.""" cg.add_define("USE_RADIO_FREQUENCY") await cg.register_component(var, config) + queue_entity_register("radio_frequency", config) await setup_radio_frequency_core_(var, config) - cg.add(cg.App.register_radio_frequency(var)) CORE.register_platform_component("radio_frequency", var) diff --git a/esphome/components/rotary_encoder/sensor.py b/esphome/components/rotary_encoder/sensor.py index 21239863e45..0e5a03523df 100644 --- a/esphome/components/rotary_encoder/sensor.py +++ b/esphome/components/rotary_encoder/sensor.py @@ -129,6 +129,6 @@ async def to_code(config): async def sensor_template_publish_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_VALUE], args, cg.int32) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.int_) cg.add(var.set_value(template_)) return var diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index c661aad972a..4880f9ac41a 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -93,7 +93,9 @@ async def to_code(config): cg.add(var.set_gain(config[CONF_GAIN])) - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_FINISHED_PLAYBACK): + cg.add_define("USE_RTTTL_FINISHED_PLAYBACK_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @automation.register_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 08d902b4be8..a5f8567c9da 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -424,7 +424,9 @@ void Rtttl::set_state_(State state) { // Clear loop_done when transitioning from `State::STOPPED` to any other state if (state == State::STOPPED) { this->disable_loop(); +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK this->on_finished_playback_callback_.call(); +#endif ESP_LOGD(TAG, "Playback finished"); } else if (old_state == State::STOPPED) { this->enable_loop(); diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 98ed9ba1bf4..9dac92be2ad 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -2,6 +2,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -45,9 +47,11 @@ class Rtttl : public Component { bool is_playing() { return this->state_ != State::STOPPED; } +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK template void add_on_finished_playback_callback(F &&callback) { this->on_finished_playback_callback_.add(std::forward(callback)); } +#endif protected: inline uint16_t get_integer_() { @@ -106,8 +110,10 @@ class Rtttl : public Component { uint32_t samples_gap_{0}; #endif // USE_SPEAKER +#ifdef USE_RTTTL_FINISHED_PLAYBACK_CALLBACK /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; +#endif }; template class PlayAction : public Action { diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 6df0ba78b1f..578376258a1 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -76,8 +76,9 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if config.get(CONF_ON_SAFE_MODE): + if on_safe_mode := config.get(CONF_ON_SAFE_MODE): cg.add_define("USE_SAFE_MODE_CALLBACK") + cg.add_define("ESPHOME_SAFE_MODE_CALLBACK_COUNT", len(on_safe_mode)) await automation.build_callback_automations( var, config, _CALLBACK_AUTOMATIONS ) diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 2733054962e..b458a9a3021 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -57,7 +57,7 @@ class SafeModeComponent final : public Component { // Larger objects at the end ESPPreferenceObject rtc_; #ifdef USE_SAFE_MODE_CALLBACK - CallbackManager safe_mode_callback_{}; + StaticCallbackManager safe_mode_callback_{}; #endif static const uint32_t ENTER_SAFE_MODE_MAGIC = diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index ba5214e550a..f561c030a49 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -19,7 +19,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.cpp_types import global_ns @@ -113,7 +117,7 @@ async def setup_select_core_(var, config, *, options: list[str]): async def register_select(var, config, *, options: list[str]): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_select(var)) + queue_entity_register("select", config) CORE.register_platform_component("select", var) await setup_select_core_(var, config, options=options) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 6f5ccddb86d..58687ae8389 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -193,7 +193,7 @@ async def to_code(config: ConfigType) -> None: ) # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1") cg.add_define("USE_SENDSPIN", True) # for MDNS diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 43fbc989531..48b7d25d4df 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -109,6 +109,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.config import UNIT_OF_MEASUREMENT_MAX_LENGTH from esphome.core.entity_helpers import ( entity_duplicate_validator, + queue_entity_register, setup_device_class, setup_entity, setup_unit_of_measurement, @@ -982,7 +983,7 @@ async def setup_sensor_core_(var, config): async def register_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_sensor(var)) + queue_entity_register("sensor", config) CORE.register_platform_component("sensor", var) await setup_sensor_core_(var, config) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 913d920c94e..001ec774546 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -110,7 +110,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { case STATE_INIT: { // While we were waiting for update to check for messages, this notifies a message // is available. - bool message_available = message.compare(0, 6, "+CMTI:") == 0; + bool message_available = message.starts_with("+CMTI:"); if (!message_available) { if (message == "RING") { // Incoming call... @@ -120,7 +120,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->call_state_ = 6; this->call_disconnected_callback_.call(); } - } else if (message.compare(0, 6, "+CUSD:") == 0) { + } else if (message.starts_with("+CUSD:")) { // Incoming USSD MESSAGE this->state_ = STATE_CHECK_USSD; } @@ -175,7 +175,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { break; case STATE_CHECK_USSD: ESP_LOGD(TAG, "Check ussd code: '%s'", message.c_str()); - if (message.compare(0, 6, "+CUSD:") == 0) { + if (message.starts_with("+CUSD:")) { this->state_ = STATE_RECEIVED_USSD; this->ussd_ = ""; size_t start = 10; @@ -196,8 +196,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { case STATE_CREG_WAIT: { // Response: "+CREG: 0,1" -- the one there means registered ok // "+CREG: -,-" means not registered ok - bool registered = - message.size() > 9 && message.compare(0, 6, "+CREG:") == 0 && (message[9] == '1' || message[9] == '5'); + bool registered = message.size() > 9 && message.starts_with("+CREG:") && (message[9] == '1' || message[9] == '5'); if (registered) { if (!this->registered_) { ESP_LOGD(TAG, "Registered OK"); @@ -223,7 +222,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->state_ = STATE_CSQ_RESPONSE; break; case STATE_CSQ_RESPONSE: - if (message.compare(0, 5, "+CSQ:") == 0) { + if (message.starts_with("+CSQ:")) { size_t comma = message.find(',', 6); if (comma != 6) { int rssi = parse_number(message.substr(6, comma - 6)).value_or(0); @@ -243,7 +242,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->state_ = STATE_CHECK_SMS; break; case STATE_PARSE_SMS_RESPONSE: - if (message.compare(0, 6, "+CMGL:") == 0 && this->parse_index_ == 0) { + if (message.starts_with("+CMGL:") && this->parse_index_ == 0) { size_t start = 7; size_t end = message.find(',', start); uint8_t item = 0; @@ -278,7 +277,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { } break; case STATE_CHECK_CALL: - if (message.compare(0, 6, "+CLCC:") == 0 && this->parse_index_ == 0) { + if (message.starts_with("+CLCC:") && this->parse_index_ == 0) { this->expect_ack_ = true; size_t start = 7; size_t end = message.find(',', start); @@ -324,7 +323,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { /* Our recipient is set and the message body is in message kick ESPHome callback now */ - if (ok || message.compare(0, 6, "+CMGL:") == 0) { + if (ok || message.starts_with("+CMGL:")) { ESP_LOGD(TAG, "Received SMS from: %s\n" " %s", @@ -360,7 +359,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { } break; case STATE_SENDING_SMS_3: - if (message.compare(0, 6, "+CMGS:") == 0) { + if (message.starts_with("+CMGS:")) { ESP_LOGD(TAG, "SMS Sent OK: %s", message.c_str()); this->send_pending_ = false; this->state_ = STATE_CHECK_SMS; @@ -383,7 +382,7 @@ void Sim800LComponent::parse_cmd_(std::string message) { this->state_ = STATE_INIT; break; case STATE_PARSE_CLIP: - if (message.compare(0, 6, "+CLIP:") == 0) { + if (message.starts_with("+CLIP:")) { std::string caller_id; size_t start = 7; size_t end = message.find(',', start); diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 930373c6fcd..ab11a89c3f4 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -502,7 +502,7 @@ void SpeakerMediaPlayer::control(const media_player::MediaPlayerCall &call) { media_command.announce = false; } - auto media_url = call.get_media_url(); + const auto &media_url = call.get_media_url(); if (media_url.has_value()) { media_command.url = new std::string(*media_url); // Must be manually deleted after receiving media_command from a queue diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 2caab828fb4..87fd4fe9ed5 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -698,7 +698,7 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call } } - auto media_url = call.get_media_url(); + const auto &media_url = call.get_media_url(); if (media_url.has_value()) { auto command = call.get_command(); bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE; diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index dc538f4c41f..e6f592c6e44 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -451,7 +451,7 @@ class SPIDevice : public SPIClient { uint8_t read_byte() { return this->delegate_->transfer(0); } - void read_array(uint8_t *data, size_t length) { return this->delegate_->read_array(data, length); } + void read_array(uint8_t *data, size_t length) { this->delegate_->read_array(data, length); } /** * Write a single data item, up to 32 bits. diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 0802cdec8e4..e977c05c48f 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -897,11 +897,12 @@ void Sprinkler::resume() { } if (this->paused_valve_.has_value() && (this->resume_duration_.has_value())) { + const size_t paused_valve = *this->paused_valve_; + const uint32_t resume_duration = *this->resume_duration_; // Resume only if valve has not been completed yet - if (!this->valve_cycle_complete_(this->paused_valve_.value())) { - ESP_LOGD(TAG, "Resuming valve %zu with %" PRIu32 " seconds remaining", this->paused_valve_.value_or(0), - this->resume_duration_.value_or(0)); - this->fsm_request_(this->paused_valve_.value(), this->resume_duration_.value()); + if (!this->valve_cycle_complete_(paused_valve)) { + ESP_LOGD(TAG, "Resuming valve %zu with %" PRIu32 " seconds remaining", paused_valve, resume_duration); + this->fsm_request_(paused_valve, resume_duration); } this->reset_resume(); } else { diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 9fa4a013ff8..1108652e993 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -23,6 +23,7 @@ from esphome.const import ( 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, ) @@ -166,7 +167,7 @@ async def setup_switch_core_(var, config): async def register_switch(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_switch(var)) + queue_entity_register("switch", config) CORE.register_platform_component("switch", var) await setup_switch_core_(var, config) diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 224f4580d4a..06b5a108926 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -14,7 +14,11 @@ from esphome.const import ( CONF_WEB_SERVER, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@mauritskorse"] @@ -122,7 +126,7 @@ async def register_text( ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text(var)) + queue_entity_register("text", config) CORE.register_platform_component("text", var) await setup_text_core_( var, config, min_length=min_length, max_length=max_length, pattern=pattern diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 94014e8d206..01a57cbaa1b 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -22,6 +22,7 @@ from esphome.const import ( 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, ) @@ -221,7 +222,7 @@ async def setup_text_sensor_core_(var, config): async def register_text_sensor(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_text_sensor(var)) + queue_entity_register("text_sensor", config) CORE.register_platform_component("text_sensor", var) await setup_text_sensor_core_(var, config) diff --git a/esphome/components/tlc5971/tlc5971.cpp b/esphome/components/tlc5971/tlc5971.cpp index be17780f8c9..8128dd90462 100644 --- a/esphome/components/tlc5971/tlc5971.cpp +++ b/esphome/components/tlc5971/tlc5971.cpp @@ -68,13 +68,8 @@ void TLC5971::transfer_(uint8_t send) { uint8_t startbit = 0x80; bool towrite, lastmosi = !(send & startbit); - uint8_t bitdelay_us = (1000000 / 1000000) / 2; for (uint8_t b = startbit; b != 0; b = b >> 1) { - if (bitdelay_us) { - delayMicroseconds(bitdelay_us); - } - towrite = send & b; if ((lastmosi != towrite)) { this->data_pin_->digital_write(towrite); @@ -82,11 +77,6 @@ void TLC5971::transfer_(uint8_t send) { } this->clock_pin_->digital_write(true); - - if (bitdelay_us) { - delayMicroseconds(bitdelay_us); - } - this->clock_pin_->digital_write(false); } } diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index db6c1445e34..ddb471be18f 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( 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, ) @@ -113,7 +114,7 @@ async def setup_update_core_(var, config): async def register_update(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_update(var)) + queue_entity_register("update", config) CORE.register_platform_component("update", var) await setup_update_core_(var, config) diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 1930a7ad0c9..a6808c9da7b 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -24,6 +24,7 @@ from esphome.const import ( 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, ) @@ -162,7 +163,7 @@ async def _setup_valve_core(var, config): async def register_valve(var, config): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) - cg.add(cg.App.register_valve(var)) + queue_entity_register("valve", config) CORE.register_platform_component("valve", var) await _setup_valve_core(var, config) diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index 2ce46756e44..545d83a6791 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -6,7 +6,6 @@ #include #include #ifdef USE_ESP32 -#include #include "esp_idf_version.h" #include "esp_task_wdt.h" #endif @@ -40,7 +39,7 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { #ifdef USE_ESP32 esp_task_wdt_config_t wdt_config = { .timeout_ms = timeout_ms, - .idle_core_mask = (1 << SOC_CPU_CORES_NUM) - 1, + .idle_core_mask = (1U << CONFIG_FREERTOS_NUMBER_OF_CORES) - 1U, .trigger_panic = true, }; esp_task_wdt_reconfigure(&wdt_config); diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index 58cf5a4054e..f3eec16a406 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -9,7 +9,11 @@ from esphome.const import ( CONF_VISUAL, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity +from esphome.core.entity_helpers import ( + entity_duplicate_validator, + queue_entity_register, + setup_entity, +) from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType @@ -90,7 +94,7 @@ async def register_water_heater(var: cg.Pvariable, config: ConfigType) -> cg.Pva cg.add_define("USE_WATER_HEATER") - cg.add(cg.App.register_water_heater(var)) + queue_entity_register("water_heater", config) CORE.register_platform_component("water_heater", var) await setup_water_heater_core_(var, config) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f7c70b1147a..1da2d630c10 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1099,9 +1099,9 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { } #ifdef USE_WIFI_WPA2_EAP - auto eap_opt = ap.get_eap(); + const auto &eap_opt = ap.get_eap(); if (eap_opt.has_value()) { - EAPAuth eap_config = *eap_opt; + const EAPAuth &eap_config = *eap_opt; // clang-format off ESP_LOGV( TAG, diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index bf3a0d29497..402ca051cde 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -313,10 +313,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // setup enterprise authentication if required #ifdef USE_WIFI_WPA2_EAP - auto eap_opt = ap.get_eap(); + const auto &eap_opt = ap.get_eap(); if (eap_opt.has_value()) { // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. - EAPAuth eap = *eap_opt; + const EAPAuth &eap = *eap_opt; ret = wifi_station_set_enterprise_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); if (ret) { ESP_LOGV(TAG, "esp_wifi_sta_wpa2_ent_set_identity failed: %d", ret); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 29d135ce900..4f39a3a4b12 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -179,7 +179,10 @@ void WiFiComponent::wifi_pre_setup_() { #endif // USE_WIFI_AP wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - // cfg.nvs_enable = false; + if (global_preferences->nvs_handle == 0) { + ESP_LOGW(TAG, "starting wifi without nvs"); + cfg.nvs_enable = false; + } err = esp_wifi_init(&cfg); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err)); @@ -404,10 +407,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // setup enterprise authentication if required #ifdef USE_WIFI_WPA2_EAP - auto eap_opt = ap.get_eap(); + const auto &eap_opt = ap.get_eap(); if (eap_opt.has_value()) { // note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0. - EAPAuth eap = *eap_opt; + const EAPAuth &eap = *eap_opt; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length()); #else diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 0bb5f95bb68..018dab73488 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -75,6 +75,13 @@ SENSOR_SCHEMA = cv.Schema({}).extend(zephyr_sensor) SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) + +def _validate_router_sleepy(config: ConfigType) -> ConfigType: + if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY): + raise cv.Invalid("router and sleepy are mutually exclusive") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -82,10 +89,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MODEL, default=CORE.name): cv.All( cv.string, cv.Length(max=31) ), - cv.OnlyWith(CONF_ROUTER, "esp32", default=False): cv.All( - cv.requires_component("esp32"), - cv.boolean, - ), + cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): cv.All( cv.requires_component("nrf52"), automation.validate_automation(single=True), @@ -113,6 +117,7 @@ CONFIG_SCHEMA = cv.All( ), } ).extend(cv.COMPONENT_SCHEMA), + _validate_router_sleepy, zigbee_require_vfs_select, zigbee_set_core_data, cv.Any( diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 90bb66c91d5..26bef8fb174 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -4,9 +4,7 @@ #include #include #include "esphome/core/hal.h" -#ifdef USE_DEEP_SLEEP -#include "esphome/components/deep_sleep/deep_sleep_component.h" -#endif +#include "esphome/core/wake.h" extern "C" { #include @@ -119,11 +117,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; -#ifdef USE_DEEP_SLEEP - if (auto *ds = deep_sleep::global_deep_sleep.load()) { - ds->wakeup(); - } -#endif + esphome::wake_loop_threadsafe(); // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { @@ -190,7 +184,9 @@ void ZigbeeComponent::setup() { ESP_LOGE(TAG, "Cannot load settings, err: %d", err); return; } +#ifdef CONFIG_ZIGBEE_ROLE_END_DEVICE zigbee_configure_sleepy_behavior(this->sleepy_); +#endif zigbee_enable(); } diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 7d904b6081d..b74074e50f8 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -52,6 +52,7 @@ from esphome.types import ConfigType from .const import ( CONF_ON_JOIN, CONF_POWER_SOURCE, + CONF_ROUTER, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -160,7 +161,10 @@ zephyr_number = cv.Schema( async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("ZIGBEE", True) zephyr_add_prj_conf("ZIGBEE_APP_UTILS", True) - zephyr_add_prj_conf("ZIGBEE_ROLE_END_DEVICE", True) + if config[CONF_ROUTER]: + zephyr_add_prj_conf("ZIGBEE_ROLE_ROUTER", True) + else: + zephyr_add_prj_conf("ZIGBEE_ROLE_END_DEVICE", True) zephyr_add_prj_conf("ZIGBEE_CHANNEL_SELECTION_MODE_MULTI", True) diff --git a/esphome/config.py b/esphome/config.py index 641b6ec1b48..6eb67af58b3 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -997,6 +997,8 @@ def validate_config( ) -> Config: result = Config() + CORE.skip_external_update = skip_external_update + loader.clear_component_meta_finders() loader.install_custom_components_meta_finder() @@ -1009,7 +1011,6 @@ def validate_config( config = do_packages_pass( config, command_line_substitutions=command_line_substitutions, - skip_update=skip_external_update, ) except vol.Invalid as err: result.update(config) @@ -1050,7 +1051,7 @@ def validate_config( result.add_output_path([CONF_EXTERNAL_COMPONENTS], CONF_EXTERNAL_COMPONENTS) try: - do_external_components_pass(config, skip_update=skip_external_update) + do_external_components_pass(config) except vol.Invalid as err: result.update(config) result.add_error(err) @@ -1341,7 +1342,9 @@ def strip_default_ids(config): return config -def read_config(command_line_substitutions, skip_external_update=False): +def read_config( + command_line_substitutions: dict[str, Any], skip_external_update: bool = False +) -> Config | None: _LOGGER.info("Reading configuration %s...", CORE.config_path) try: res = load_config(command_line_substitutions, skip_external_update) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 009fef2f863..4fecebcd8d2 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -615,6 +615,9 @@ class EsphomeCore: self.address_cache: AddressCache | None = None # Cached config hash (computed lazily) self._config_hash: int | None = None + # When True, skip network freshness checks for cached external files + # (e.g. for `esphome logs`, where remote downloads aren't needed) + self.skip_external_update: bool = False def reset(self): from esphome.pins import PIN_SCHEMA_REGISTRY @@ -644,6 +647,7 @@ class EsphomeCore: self.current_component = None self.address_cache = None self._config_hash = None + self.skip_external_update = False PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/core/application.h b/esphome/core/application.h index e9b386038ea..4a18714d0de 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -9,6 +9,10 @@ #include #include "esphome/core/component.h" #include "esphome/core/defines.h" + +#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) +#include // for std::atomic_thread_fence in Application::loop() +#endif #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -17,6 +21,10 @@ #include "esphome/core/string_ref.h" #include "esphome/core/version.h" +#ifdef USE_ESP32 +#include // for CONFIG_ESP_TASK_WDT_TIMEOUT_S (drives WDT_FEED_INTERVAL_MS) +#endif + #ifdef USE_DEVICES #include "esphome/core/device.h" #endif @@ -99,10 +107,19 @@ class Application { void set_current_component(Component *component) { this->current_component_ = component; } Component *get_current_component() { return this->current_component_; } -// Entity register methods (generated from entity_types.h) +// Entity register methods (generated from entity_types.h). +// Each entity type gets two overloads: +// - register_(obj) — bare push_back +// - register_(obj, name, hash, fields) — configure_entity_ + push_back +// The 4-arg form lets codegen collapse `App.register_(obj); obj->configure_entity_(...);` +// into a single call site, saving flash and a `main.cpp` line per entity. // NOLINTBEGIN(bugprone-macro-parentheses) #define ENTITY_TYPE_(type, singular, plural, count, upper) \ - void register_##singular(type *obj) { this->plural##_.push_back(obj); } + void register_##singular(type *obj) { this->plural##_.push_back(obj); } \ + void register_##singular(type *obj, const char *name, uint32_t object_id_hash, uint32_t entity_fields) { \ + obj->configure_entity_(name, object_id_hash, entity_fields); \ + this->plural##_.push_back(obj); \ + } #define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \ ENTITY_TYPE_(type, singular, plural, count, upper) #include "esphome/core/entity_types.h" @@ -216,16 +233,30 @@ class Application { /// loops and scheduler items still feed after every op, so any op exceeding /// this threshold triggers a real feed naturally. /// Safety margins vs. platform watchdog timeouts: - /// - ESP32 task WDT default (5 s): ~16x - /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change - /// must keep comfortable margin here - /// - ESP8266 HW WDT (~6 s): ~20x - /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// - ESP32 task WDT (user-configurable): ~5x <-- auto-scaled below + /// - ESP8266 soft WDT (~1.6 s): ~5x <-- floor case; any future change + /// must keep comfortable margin here + /// - ESP8266 HW WDT (~6 s): ~20x + /// - BK72xx HW WDT (10 s): ~5x <-- platform override below #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: // https://github.com/libretiny-eu/framework-beken-bdk/blob/44800e7451ea30fbcbd3bb6e905315de59349fee/beken378/driver/wdt/wdt.c#L75-L87 static constexpr uint32_t WDT_FEED_INTERVAL_MS = 2000; +#elif defined(USE_ESP32) + // Auto-scale to 1/5 of the configured ESP32 task WDT timeout so the safety + // margin stays constant when the user raises esp32.watchdog_timeout (default + // 5 s → 1000 ms feed; 10 s → 2000 ms; 60 s → 12000 ms). The esp32 component + // writes CONFIG_ESP_TASK_WDT_TIMEOUT_S into sdkconfig (range is validated + // to ≥ 5 s in esp32/__init__.py), giving us the value at compile time. + // esp_task_wdt_reset() takes a spinlock and walks the WDT task list, so + // each call costs tens of microseconds; longer intervals materially reduce + // the main-loop's wdt bucket. Component loops and scheduler items still + // feed after every op, so any op exceeding this threshold triggers a real + // feed naturally regardless of the rate-limit. + static_assert(CONFIG_ESP_TASK_WDT_TIMEOUT_S >= 5, + "CONFIG_ESP_TASK_WDT_TIMEOUT_S must be at least 5s for a safe WDT feed interval"); + static constexpr uint32_t WDT_FEED_INTERVAL_MS = (CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000U) / 5U; #else static constexpr uint32_t WDT_FEED_INTERVAL_MS = 300; #endif @@ -343,6 +374,9 @@ class Application { #elif defined(USE_ESP8266) /// Wake from ISR (ESP8266). No task_woken arg — no FreeRTOS. Caller must be IRAM_ATTR. static void IRAM_ATTR ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } +#elif defined(USE_ZEPHYR) + /// Wake from ISR (Zephyr). No task_woken arg — k_sem_give() handles ISR scheduling internally. + static void wake_loop_isrsafe() { esphome::wake_loop_isrsafe(); } #endif /// Wake from any context (ISR, thread, callback). @@ -350,12 +384,16 @@ class Application { protected: friend Component; + friend class Scheduler; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); + /// Freshen the cached loop component start time. Called by Scheduler before each dispatch. + void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; } + /// Walk all registered components looking for any whose component_state_ /// has the given flag set. Used by Component::status_clear_*_slow_path_() /// (which is a friend) to decide whether to clear the corresponding bit on @@ -364,7 +402,11 @@ class Application { /// Register a component, detecting loop() override at compile time. /// Uses HasLoopOverride which handles ambiguous &T::loop from multiple inheritance. - template void register_component_(T *comp) { + /// Optionally sets the component source index in the same call to avoid emitting + /// a separate set_component_source_() line in generated code. + template void register_component_(T *comp, uint8_t source_index = 0) { + if (source_index != 0) + comp->set_component_source_(source_index); this->register_component_impl_(comp, HasLoopOverride::value); } @@ -542,6 +584,15 @@ inline ESPHOME_ALWAYS_INLINE Application::ComponentPhaseGuard::ComponentPhaseGua } inline void ESPHOME_ALWAYS_INLINE Application::loop() { +#if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) + // Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so + // subsequent Socket::ready() checks in this iter observe the published state + // without a per-call memw. Wake is independent (xTaskNotifyGive/ + // ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. + // BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data() + // instead. + std::atomic_thread_fence(std::memory_order_acquire); +#endif #ifdef USE_RUNTIME_STATS // Capture the start of the active (non-sleeping) portion of this iteration. // Used to derive main-loop overhead = active time − Σ(component time) − diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 17f937d10d9..afd11c6867d 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -273,18 +273,32 @@ template class WhileLoopContinuation : public Action { WhileAction *parent_; }; +// Wraps a ContinuationAction when Enabled, empty otherwise. +// Lets IfAction elide the else continuation when HasElse is false. +template struct OptionalContinuation { + ContinuationAction action; + explicit OptionalContinuation(Action *parent) : action(parent) {} +}; +template struct OptionalContinuation { + explicit OptionalContinuation(Action * /*parent*/) {} +}; + template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} + // Precondition: add_then/add_else must be called at most once per instance. + // Codegen always batches the full action list into a single call. Calling + // twice would re-append the same inline continuation pointer and form a + // self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new ContinuationAction(this)); + this->then_.add_action(&this->then_continuation_); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(new ContinuationAction(this)); + this->else_.add_action(&this->else_continuation_.action); } void play_complex(const Ts &...x) override { @@ -316,17 +330,20 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; + ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; + [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new WhileLoopContinuation(this)); + this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -354,6 +371,7 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; + WhileLoopContinuation loop_continuation_{this}; }; // Implementation of WhileLoopContinuation::play @@ -386,9 +404,10 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) + // Precondition: must be called at most once per instance (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(new RepeatLoopContinuation(this)); + this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; @@ -409,6 +428,7 @@ template class RepeatAction : public Action { protected: ActionList then_; + RepeatLoopContinuation loop_continuation_{this}; }; // Implementation of RepeatLoopContinuation::play diff --git a/esphome/core/config.py b/esphome/core/config.py index bf210876dfb..b4e81ce49fa 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -242,6 +242,10 @@ PROJECT_MAX_LENGTH = 127 # Max board/model string length (must fit in single-byte varint for proto encoding) BOARD_MAX_LENGTH = 127 +# Keep in sync with ESPHOME_COMMENT_SIZE_MAX in esphome/core/application.h +# (C++ side includes the null terminator). +COMMENT_MAX_LEN = 255 + AREA_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(Area), @@ -275,7 +279,9 @@ CONFIG_SCHEMA = cv.All( cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN) ), cv.Optional(CONF_AREA): validate_area_config, - cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)), + cv.Optional(CONF_COMMENT): cv.All( + cv.string, cv.ByteLength(max=COMMENT_MAX_LEN) + ), cv.Required(CONF_BUILD_PATH): cv.string, cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( { @@ -786,6 +792,29 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + # Per-platform wake implementations — wake.h dispatches to exactly one of + # these based on USE_*, so the others can be skipped at the source level + # too. Header files next to each .cpp are always copied (the dispatcher + # #include's them) but compile to empty TUs on the wrong platform anyway. + "wake/wake_freertos.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "wake/wake_esp8266.cpp": { + PlatformFramework.ESP8266_ARDUINO, + }, + "wake/wake_rp2040.cpp": { + PlatformFramework.RP2040_ARDUINO, + }, + "wake/wake_host.cpp": { + PlatformFramework.HOST_NATIVE, + }, + "wake/wake_zephyr.cpp": { + PlatformFramework.NRF52_ZEPHYR, + }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da1..99ec936c12c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,8 +17,21 @@ #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API -// Default threading model for static analysis (ESP32 is multi-threaded with atomics) +// Threading model for static analysis. Match what the real codegen picks per +// platform (see esphome/components//__init__.py ThreadModel.*): +// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS +// everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS +// Without this the clang-tidy envs end up with USE_ +// + MULTI_ATOMICS simultaneously, a combination that can never occur in a +// real build. +#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#define ESPHOME_THREAD_SINGLE +#elif defined(USE_BK72XX) +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#else #define ESPHOME_THREAD_MULTI_ATOMICS +#endif // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE @@ -83,6 +96,7 @@ #define USE_LVGL_CHECKBOX #define USE_LVGL_DROPDOWN #define USE_LVGL_FONT +#define USE_LVGL_GRADIENT #define USE_LVGL_IMAGE #define USE_LVGL_IMAGEBUTTON #define USE_LVGL_KEY_LISTENER @@ -132,10 +146,12 @@ #define USE_NEXTION_WAVEFORM #define USE_NUMBER #define USE_OUTPUT +#define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK +#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT #define USE_SENSOR #define USE_SENSOR_FILTER @@ -185,6 +201,7 @@ #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_NETWORK +#define USE_RTTTL_FINISHED_PLAYBACK_CALLBACK #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 5a69c9dd09b..2726a92c97a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -238,6 +238,9 @@ class EntityBase { protected: friend void ::setup(); friend void ::original_setup(); + // Application's register_(obj, name, hash, fields) overloads call configure_entity_ + // before push_back, so codegen can emit a single combined call per entity. + friend class Application; /// Combined entity setup from codegen: set name, object_id hash, entity string indices, and flags. /// Bit layout of entity_fields is defined by the ENTITY_FIELD_*_SHIFT constants above. diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index f09dd013fe2..ff60260280a 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -23,6 +23,7 @@ from esphome.core.config import ( UNIT_OF_MEASUREMENT_MAX_LENGTH, ) from esphome.cpp_generator import MockObj, RawStatement, add, get_variable +from esphome.cpp_types import App import esphome.final_validate as fv from esphome.helpers import cpp_string_escape, fnv1_hash_object_id, sanitize, snake_case from esphome.types import ConfigType, EntityMetadata @@ -52,6 +53,12 @@ _KEY_INTERNAL = "_entity_internal" _KEY_DISABLED_BY_DEFAULT = "_entity_disabled_by_default" _KEY_ENTITY_CATEGORY = "_entity_category" +# Private config key for the App.register_ entry point. +# When set, finalize_entity_strings() emits a single combined call +# `App.register_(var, name, hash, packed)` instead of separate +# `App.register_(var)` and `var->configure_entity_(...)` calls. +_KEY_REGISTER_METHOD = "_entity_register_method" + # Maximum unique strings per category (8-bit index, 0 = not set) _MAX_DEVICE_CLASSES = 0xFF # 255 _MAX_UNITS = 0xFF # 255 @@ -271,11 +278,26 @@ def _describe_packed_flags(config: ConfigType, entity_category: int) -> str: return ", ".join(parts) +def queue_entity_register(method_name: str, config: ConfigType) -> None: + """Defer ``App.register_(var)`` emission to ``finalize_entity_strings``. + + When the deferred call is emitted, it is folded with ``configure_entity_`` into + a single ``App.register_(var, name, hash, packed)`` call site, + which removes one statement and one method dispatch per entity from the + generated ``main.cpp``. + """ + config[_KEY_REGISTER_METHOD] = method_name + + def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: - """Emit a single configure_entity_() call with name, hash, packed string indices, and flags. + """Emit the entity-registration / configure_entity_ tail. Call this at the end of each component's setup function, after setup_entity() and any register_device_class/register_unit_of_measurement calls. + + If queue_entity_register() was called for this entity, emits one combined call + ``App.register_(var, name, hash, packed)``. Otherwise falls back to a + standalone ``var->configure_entity_(name, hash, packed)``. """ entity_name = config[_KEY_ENTITY_NAME] object_id_hash = config[_KEY_OBJECT_ID_HASH] @@ -295,7 +317,13 @@ def finalize_entity_strings(var: MockObj, config: ConfigType) -> None: ) # Build inline comment describing the packed flags for readability comment = _describe_packed_flags(config, entity_category) - expr = var.configure_entity_(entity_name, object_id_hash, packed) + register_method = config.get(_KEY_REGISTER_METHOD) + if register_method is not None: + expr = getattr(App, f"register_{register_method}")( + var, entity_name, object_id_hash, packed + ) + else: + expr = var.configure_entity_(entity_name, object_id_hash, packed) if comment: add(RawStatement(f"{expr}; // {comment}")) else: diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 366ae6da9ee..355db6c7f4f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1633,7 +1633,7 @@ template struct Callback { void *ctx_{nullptr}; /// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances. - void call(Ts... args) const { this->fn_(this->ctx_, args...); } + void call(Ts... args) const { this->fn_(this->ctx_, std::forward(args)...); } /// Create from any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline in the ctx pointer without heap allocation. @@ -1709,7 +1709,7 @@ template class CallbackManager { template void add(F &&callback) { this->add_(CbType::create(std::forward(callback))); } /// Call all callbacks in this manager. - inline void ESPHOME_ALWAYS_INLINE call(Ts... args) { + inline void ESPHOME_ALWAYS_INLINE call(const Ts &...args) { if (this->size_ != 0) { for (auto *it = this->data_, *end = it + this->size_; it != end; ++it) { it->call(args...); @@ -1719,7 +1719,7 @@ template class CallbackManager { uint16_t size() const { return this->size_; } /// Call all callbacks in this manager. - void operator()(Ts... args) { this->call(args...); } + void operator()(const Ts &...args) { this->call(args...); } protected: template friend class LazyCallbackManager; diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 3b5e449148d..4ba2606d76c 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -26,25 +26,23 @@ extern "C" { struct lwip_sock *esphome_lwip_get_sock(int fd); /// Check if a cached LwIP socket has data ready via unlocked hint read of rcvevent. -/// This avoids lwIP core lock contention between the main loop (CPU0) and -/// streaming/networking work (CPU1). Correctness is preserved because callers -/// already handle EWOULDBLOCK on nonblocking sockets — a stale hint simply causes -/// a harmless retry on the next loop iteration. In practice, stale reads have not -/// been observed across multi-day testing, but the design does not depend on that. -/// -/// The sock pointer must have been obtained from esphome_lwip_get_sock() and must -/// remain valid (caller owns socket lifetime — no concurrent close). -/// Hot path: inlined volatile 16-bit load — no function call overhead. -/// Uses offset-based access because lwip/priv/sockets_priv.h conflicts with C++. +/// On ESPHOME_THREAD_MULTI_ATOMICS builds, the caller must run on the main +/// loop task after Application::loop's per-iter std::atomic_thread_fence +/// (memory_order_acquire); that fence pairs with the TCP/IP thread's +/// SYS_ARCH_UNPROTECT release, so a plain load suffices and avoids the +/// per-call `memw` that volatile would emit on Xtensa under default +/// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped +/// and the volatile load provides ordering on its own. +/// Stale reads are harmless either way: the hooked event_callback +/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and +/// ulTaskNotifyTake never loses a wake. /// The offset and size are verified at compile time in lwip_fast_select.c. static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { - // Unlocked hint read — no lwIP core lock needed. - // volatile prevents the compiler from caching/reordering this cross-thread read. - // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a - // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value - // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on - // Xtensa/RISC-V/ARM and cannot produce torn values. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS + return *(int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +#else return *(volatile int16_t *) ((char *) sock + (int) ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET) > 0; +#endif } /// Hook a socket's netconn callback to notify the main loop task on receive events. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e42..11884ce4ba9 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -772,6 +772,8 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() { // Helper to execute a scheduler item uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); + // Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time. + App.set_loop_component_start_time_(now); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); uint32_t end = guard.finish(); diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index cf651c3e91a..25076228d5e 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -22,8 +22,8 @@ static const char *const TAG = "time_64"; #ifdef ESPHOME_THREAD_SINGLE // Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. -uint32_t Millis64Impl::last_millis_{0}; -uint16_t Millis64Impl::millis_major_{0}; +uint32_t Millis64Impl::last_millis{0}; +uint16_t Millis64Impl::millis_major{0}; #else uint64_t Millis64Impl::compute(uint32_t now) { diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index b86070cc76b..f66f9afddb8 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -19,8 +19,8 @@ class Millis64Impl { #ifdef ESPHOME_THREAD_SINGLE // Storage defined in time_64.cpp — declared here so the inline body can access them. - static uint32_t last_millis_; - static uint16_t millis_major_; + static uint32_t last_millis; + static uint16_t millis_major; // Raw __attribute__((always_inline)) (not ESPHOME_ALWAYS_INLINE) so this // header does not need to pull helpers.h. @@ -30,17 +30,17 @@ class Millis64Impl { // Single-core platforms have no concurrency, so this is a simple implementation // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - uint16_t major = millis_major_; - uint32_t last = last_millis_; + uint16_t major = millis_major; + uint32_t last = last_millis; // Check for rollover if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major_++; + millis_major++; major++; - last_millis_ = now; + last_millis = now; } else if (now > last) { // Only update if time moved forward - last_millis_ = now; + last_millis = now; } // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 0cfca94a78e..5a5d27ceff9 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,10 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// The public API for callers lives here; the per-platform implementations +/// live under esphome/core/wake/ and are included at the bottom of this file +/// based on the active USE_* platform define. #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,21 +15,6 @@ #include #endif -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -#include "esphome/core/main_task.h" -#endif -#ifdef USE_ESP8266 -#include -#elif defined(USE_RP2040) -#include -#include -#endif - -#ifdef USE_HOST -#include -#include -#endif - namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -67,184 +56,21 @@ __attribute__((always_inline)) inline bool wake_request_take() { } #endif -// === ESP32 / LibreTiny (FreeRTOS) === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - -/// Wake the main loop from any context (ISR or task). -/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. -__attribute__((always_inline)) inline void wake_main_task_any_context() { - // Set the wake-requested flag BEFORE the task notification so the consumer - // (Application::loop() gate) is guaranteed to see it on its next gate check. - wake_request_set(); - if (in_isr_context()) { - BaseType_t px_higher_priority_task_woken = pdFALSE; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); -#ifdef portYIELD_FROM_ISR - portYIELD_FROM_ISR(px_higher_priority_task_woken); -#else - // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ - // exit sequence performs the context switch if one was requested. - (void) px_higher_priority_task_woken; -#endif - } else { - esphome_main_task_notify(); - } -} - -/// IRAM_ATTR entry points — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -void wake_loop_any_context(); - -inline void wake_loop_threadsafe() { - wake_request_set(); - esphome_main_task_notify(); -} - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the ms timeout. - if (ms == 0) [[unlikely]] { - yield(); - return; - } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); -} -} // namespace internal - -// === ESP8266 === -#elif defined(USE_ESP8266) - -/// Inline implementation — IRAM callers inline this directly. -inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { - // Set the wake-requested flag BEFORE esp_schedule so the consumer is - // guaranteed to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - esp_schedule(); -} - -/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp. -void wake_loop_any_context(); - -/// Non-ISR: always inline. -inline void wake_loop_threadsafe() { wake_loop_impl(); } - -/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. -inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - delay(0); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - esp_delay(ms, []() { return !g_main_loop_woke; }); -} -} // namespace internal - -// === RP2040 === -#elif defined(USE_RP2040) - -inline void wake_loop_any_context() { - // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed - // to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - __sev(); -} - -inline void wake_loop_threadsafe() { wake_loop_any_context(); } - -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp. -namespace internal { -void wakeable_delay(uint32_t ms); -} // namespace internal - -// === Host / Zephyr / other === -#else - -#ifdef USE_HOST -/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. -void wake_loop_threadsafe(); - -/// Register a socket file descriptor with the host select() loop. Not -/// thread-safe — main loop only. Returns false if fd is invalid or -/// >= FD_SETSIZE. -bool wake_register_fd(int fd); - -/// Unregister a socket file descriptor. Not thread-safe — main loop only. -void wake_unregister_fd(int fd); - -/// One-time setup of the loopback wake socket. Called from Application::setup(). -void wake_setup(); - -// wake_fd_ready() and wake_drain_notifications() are defined inline at the -// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in -// scope, which depend on USE_HOST-only includes pulled in above. -#else -/// Zephyr is currently the only platform without a wake mechanism. -/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). -/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. -inline void wake_loop_threadsafe() {} -#endif - -inline void wake_loop_any_context() { wake_loop_threadsafe(); } - -namespace internal { -#ifdef USE_HOST -/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. -void wakeable_delay(uint32_t ms); -#else -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - delay(ms); -} -#endif -} // namespace internal - -#endif - -#ifdef USE_HOST -namespace internal { -// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() -// and wake_fd_ready() so the hot path stays in the header. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern int g_wake_socket_fd; -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern fd_set g_read_fds; -} // namespace internal - -inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } - -// Small buffer for draining wake notification bytes (1 byte sent per wake). -// Sized to drain multiple notifications per recvfrom() without wasting stack. -inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { - // Called from main loop to drain any pending wake notifications. - // Must check wake_fd_ready() to avoid blocking on empty socket. - if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads. Multiple wake events - // may have triggered multiple writes, so drain until EWOULDBLOCK. We control - // both ends of this loopback socket (always 1 byte per wake), so no error - // checking — any error indicates catastrophic system failure. - while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - } - } -} -#endif // USE_HOST - } // namespace esphome + +// Per-platform implementations. Each header re-enters namespace esphome {} and +// guards its body with the matching USE_* check, so only one contributes code +// for the active target. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "esphome/core/wake/wake_freertos.h" +#elif defined(USE_ESP8266) +#include "esphome/core/wake/wake_esp8266.h" +#elif defined(USE_RP2040) +#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/wake/wake_host.h" +#elif defined(USE_ZEPHYR) +#include "esphome/core/wake/wake_zephyr.h" +#else +#error "wake.h: wake_loop_threadsafe() is not implemented for this platform" +#endif diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp new file mode 100644 index 00000000000..9ced43c6dff --- /dev/null +++ b/esphome/core/wake/wake_esp8266.cpp @@ -0,0 +1,21 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// ESP8266 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h new file mode 100644 index 00000000000..7eaaae52930 --- /dev/null +++ b/esphome/core/wake/wake_esp8266.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" + +#include + +namespace esphome { + +/// Inline implementation — IRAM callers inline this directly. +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + esp_schedule(); +} + +/// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. +void wake_loop_any_context(); + +/// Non-ISR: always inline. +inline void wake_loop_threadsafe() { wake_loop_impl(); } + +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + delay(0); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when ISRs + // keep re-setting g_main_loop_woke between iterations. + delay(0); + return; + } + esp_delay(ms, []() { return !g_main_loop_woke; }); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp new file mode 100644 index 00000000000..0bf700daa89 --- /dev/null +++ b/esphome/core/wake/wake_freertos.cpp @@ -0,0 +1,33 @@ +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// ESP32 is always MULTI_ATOMICS; LibreTiny is MULTI_ATOMICS on chips with +// proper atomics (e.g. RTL8720) and MULTI_NO_ATOMICS on others (e.g. BK72XX). +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + +void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); + esphome_main_task_notify_from_isr(px_higher_priority_task_woken); +} + +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h new file mode 100644 index 00000000000..167a422c614 --- /dev/null +++ b/esphome/core/wake/wake_freertos.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/main_task.h" + +namespace esphome { + +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake_freertos.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); + +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { + yield(); + return; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake.cpp b/esphome/core/wake/wake_host.cpp similarity index 74% rename from esphome/core/wake.cpp rename to esphome/core/wake/wake_host.cpp index cac88ae91ef..9d2a650ca24 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -1,12 +1,11 @@ -#include "esphome/core/wake.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP8266 -#include -#endif +#include "esphome/core/defines.h" #ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" + #include #include #include @@ -15,88 +14,19 @@ #include #include #include -#endif namespace esphome { // === Wake-requested flag storage === -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// Host is always ESPHOME_THREAD_MULTI_ATOMICS. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -#else -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile uint8_t g_wake_requested = 0; -#endif - -// === ESP32 / LibreTiny — IRAM_ATTR entry points === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { - // ISR-safe: set flag before notify so the wake is visible on the next gate - // check. wake_request_set() is just an aligned 8-bit store / atomic store - // and is safe from IRAM. - wake_request_set(); - esphome_main_task_notify_from_isr(px_higher_priority_task_woken); -} -void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } -#endif - -// === ESP8266 / RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile bool g_main_loop_woke = false; -#endif - -#ifdef USE_ESP8266 -void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } -#endif - -// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) === -#ifdef USE_RP2040 -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_delay_expired = false; - -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { - (void) id; - (void) user_data; - s_delay_expired = true; - __sev(); - return 0; -} - -namespace internal { -void wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); - if (alarm <= 0) { - delay(ms); - return; - } - while (!g_main_loop_woke && !s_delay_expired) { - __wfe(); - } - if (!s_delay_expired) - cancel_alarm(alarm); - g_main_loop_woke = false; -} -} // namespace internal -#endif // USE_RP2040 - -// === Host (UDP loopback socket + select() based fd watcher) === -#ifdef USE_HOST static const char *const TAG = "wake"; namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and -// wake_fd_ready() in wake.h, and by the bodies in this file. +// wake_fd_ready() in wake_host.h, and by the bodies in this file. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) int g_wake_socket_fd = -1; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -271,6 +201,7 @@ void wake_setup() { return; } } -#endif // USE_HOST } // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h new file mode 100644 index 00000000000..9756ed4c39b --- /dev/null +++ b/esphome/core/wake/wake_host.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_HOST + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +/// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. +void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +/// Host wakeable_delay uses select() over the registered fds — defined in wake_host.cpp. +void wakeable_delay(uint32_t ms); + +// File-scope state owned by wake_host.cpp. Accessed inline by +// wake_drain_notifications() and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp new file mode 100644 index 00000000000..bdcbb1ad00c --- /dev/null +++ b/esphome/core/wake/wake_rp2040.cpp @@ -0,0 +1,62 @@ +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include +#include + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// RP2040 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback_(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + __sev(); + return 0; +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when async + // wakes keep re-setting g_main_loop_woke between iterations. + yield(); + return; + } + s_delay_expired = false; + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + while (!g_main_loop_woke && !s_delay_expired) { + __wfe(); + } + if (!s_delay_expired) + cancel_alarm(alarm); + g_main_loop_woke = false; +} +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2040.h new file mode 100644 index 00000000000..ea1242f535c --- /dev/null +++ b/esphome/core/wake/wake_rp2040.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + __sev(); +} + +inline void wake_loop_threadsafe() { wake_loop_any_context(); } + +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +namespace internal { +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/wake/wake_zephyr.cpp b/esphome/core/wake/wake_zephyr.cpp new file mode 100644 index 00000000000..577d53f5d9d --- /dev/null +++ b/esphome/core/wake/wake_zephyr.cpp @@ -0,0 +1,41 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ZEPHYR + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include + +namespace esphome { + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +K_SEM_DEFINE(esphome_wake_sem, 0, 1); + +// === Wake-requested flag storage === +// Zephyr has preemptive threads and ISRs, so wake_loop_threadsafe() is genuinely +// called cross-context. volatile uint8_t is sufficient because: (1) Cortex-M +// 8-bit aligned store/load is a single non-tearing instruction, and (2) every +// producer pairs the store with k_sem_give() (release barrier) and the consumer +// pairs the load with k_sem_take() (acquire barrier). +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; + +void wake_loop_threadsafe() { + wake_request_set(); + k_sem_give(&esphome_wake_sem); +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + k_sem_take(&esphome_wake_sem, ms == UINT32_MAX ? K_FOREVER : K_MSEC(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ZEPHYR diff --git a/esphome/core/wake/wake_zephyr.h b/esphome/core/wake/wake_zephyr.h new file mode 100644 index 00000000000..c89cfc68e94 --- /dev/null +++ b/esphome/core/wake/wake_zephyr.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ZEPHYR + +#include "esphome/core/hal.h" + +namespace esphome { + +/// Zephyr: wakes the main loop via k_sem_give(). Thread- and ISR-safe. +/// Defined in wake_zephyr.cpp. +void wake_loop_threadsafe(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +/// ISR-safe: no task_woken arg because Zephyr's k_sem_give() does its own ISR +/// scheduling. Forwards to wake_loop_threadsafe(). +inline void wake_loop_isrsafe() { wake_loop_threadsafe(); } + +namespace internal { +/// Zephyr wakeable_delay uses k_sem_take() with a timeout — defined in wake_zephyr.cpp. +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_ZEPHYR diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index f2bd3b92a31..b035e28a7ad 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -197,9 +197,9 @@ async def register_component(var, config): ) if name is not None: idx = register_component_source(name) - add(var.set_component_source_(idx)) - - add(App.register_component_(var)) + add(App.register_component_(var, idx)) + else: + add(App.register_component_(var)) # Collect C++ type for compile-time looping component count comp_entries = CORE.data.setdefault("looping_component_entries", []) diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index b8e17244e53..d67245967c5 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -437,7 +437,11 @@ class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): class EsphomeLogsHandler(EsphomePortCommandWebSocket): async def build_command(self, json_message: dict[str, Any]) -> list[str]: """Build the command to run.""" - return await self.build_device_command(["logs"], json_message) + cmd = await self.build_device_command(["logs"], json_message) + if json_message.get("no_states"): + cmd.append("--no-states") + _LOGGER.debug("Built command: %s", cmd) + return cmd class EsphomeRenameHandler(EsphomeCommandWebSocket): diff --git a/esphome/external_files.py b/esphome/external_files.py index 55711e1b790..bd29dc93b1d 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,14 +1,17 @@ from __future__ import annotations +import contextlib from datetime import UTC, datetime import logging +import os from pathlib import Path import requests import esphome.config_validation as cv from esphome.const import __version__ -from esphome.core import CORE, TimePeriodSeconds +from esphome.core import CORE, EsphomeError, TimePeriodSeconds +from esphome.helpers import write_file _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] @@ -16,12 +19,72 @@ CODEOWNERS = ["@landonr"] NETWORK_TIMEOUT = 30 IF_MODIFIED_SINCE = "If-Modified-Since" +IF_NONE_MATCH = "If-None-Match" +ETAG = "ETag" CACHE_CONTROL = "Cache-Control" CACHE_CONTROL_MAX_AGE = "max-age=" CONTENT_DISPOSITION = "content-disposition" TEMP_DIR = "temp" +def _etag_sidecar_path(local_file_path: Path) -> Path: + return local_file_path.parent / f".{local_file_path.name}.etag" + + +def _mtime_seconds(path: Path) -> int: + """Return `path`'s mtime as integer seconds. + + Whole seconds is the common-denominator resolution across all + filesystems we run on (FAT/exFAT 2s, NTFS 100ns, APFS/ext4 ns), so + comparisons survive setting+reading round-trips that would lose + sub-second precision on lower-resolution filesystems. + """ + return int(path.stat().st_mtime) + + +def _read_etag(local_file_path: Path) -> str | None: + """Return the cached ETag if its sidecar's mtime still matches the cache + file's. A mismatch means the cache file was modified out-of-band, so the + ETag no longer describes its contents -- delete the stale sidecar and + return None. + """ + etag_path = _etag_sidecar_path(local_file_path) + try: + if _mtime_seconds(etag_path) != _mtime_seconds(local_file_path): + _LOGGER.debug( + "ETag sidecar mtime mismatch at %s; treating as stale", + local_file_path, + ) + etag_path.unlink() + return None + return etag_path.read_text().strip() or None + except OSError: + return None + + +def _write_etag(local_file_path: Path, etag: str | None) -> None: + etag_path = _etag_sidecar_path(local_file_path) + if not etag: + # ETag persistence is best-effort; matches `_read_etag`'s tolerance. + with contextlib.suppress(OSError): + etag_path.unlink() + return + try: + write_file(etag_path, etag) + except EsphomeError as e: + _LOGGER.debug("Could not save ETag for %s: %s", local_file_path, e) + return + # Pin the sidecar's mtime to the cache file's mtime. _read_etag relies on + # this match to detect out-of-band edits to the cache file. + try: + file_mtime = _mtime_seconds(local_file_path) + os.utime(etag_path, (file_mtime, file_mtime)) + except OSError as e: + _LOGGER.debug( + "Could not sync ETag sidecar mtime for %s: %s", local_file_path, e + ) + + def has_remote_file_changed(url: str, local_file_path: Path) -> bool: if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -35,14 +98,17 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: IF_MODIFIED_SINCE: local_modification_time_str, CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600", } + if etag := _read_etag(local_file_path): + headers[IF_NONE_MATCH] = etag response = requests.head( url, headers=headers, timeout=NETWORK_TIMEOUT, allow_redirects=True ) _LOGGER.debug( - "has_remote_file_changed: File %s, Local modified %s, response code %d", + "has_remote_file_changed: File %s, Local modified %s, ETag %s, response code %d", local_file_path, local_modification_time_str, + etag or "", response.status_code, ) @@ -51,6 +117,8 @@ def has_remote_file_changed(url: str, local_file_path: Path) -> bool: "has_remote_file_changed: File not modified since %s", local_modification_time_str, ) + if (new_etag := response.headers.get(ETAG)) and new_etag != etag: + _write_etag(local_file_path, new_etag) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -81,7 +149,10 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: +def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: + if CORE.skip_external_update and path.exists(): + _LOGGER.debug("Skipping update for %s (refresh disabled)", url) + return path.read_bytes() if not has_remote_file_changed(url, path): _LOGGER.debug("Remote file has not changed %s", url) return path.read_bytes() @@ -109,7 +180,7 @@ def download_content(url: str, path: Path, timeout=NETWORK_TIMEOUT) -> bytes: return path.read_bytes() raise cv.Invalid(f"Could not download from {url}: {e}") from e - path.parent.mkdir(parents=True, exist_ok=True) data = req.content - path.write_bytes(data) + write_file(path, data) + _write_etag(path, req.headers.get(ETAG)) return data diff --git a/esphome/git.py b/esphome/git.py index 096ff483a71..0106f248451 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -128,7 +128,10 @@ def clone_or_update( # We need to fetch the PR branch first, otherwise git will complain # about missing objects _LOGGER.info("Fetching %s", ref) - run_git_command(["git", "fetch", "--", "origin", ref], git_dir=repo_dir) + run_git_command( + ["git", "fetch", "--depth=1", "--", "origin", ref], + git_dir=repo_dir, + ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir ) @@ -138,7 +141,8 @@ def clone_or_update( "Initializing submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) except GitException: @@ -150,9 +154,7 @@ def clone_or_update( raise else: - # Check refresh needed - # Skip refresh if NEVER_REFRESH is specified - if refresh == NEVER_REFRESH: + if refresh == NEVER_REFRESH or CORE.skip_external_update: _LOGGER.debug("Skipping update for %s (refresh disabled)", key) return repo_dir, None @@ -181,8 +183,13 @@ def clone_or_update( git_dir=repo_dir, ) - # Fetch remote ref - cmd = ["git", "fetch", "--", "origin"] + # Fetch from the remote. --depth=1 keeps the clone shallow + # while still picking up new commits when the remote tip + # moves: a shallow fetch retrieves the current tip being + # fetched, whether that's an explicit ref or the remote's + # default branch, then reset --hard FETCH_HEAD updates the + # working tree to it. + cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) run_git_command(cmd, git_dir=repo_dir) @@ -231,7 +238,8 @@ def clone_or_update( "Updating submodules (%s) for %s", ", ".join(submodules), key ) run_git_command( - ["git", "submodule", "update", "--init"] + submodules, + ["git", "submodule", "update", "--init", "--depth=1", "--"] + + submodules, git_dir=repo_dir, ) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index f422d94097d..cb7f5903cfd 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -6,7 +6,7 @@ dependencies: esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: - version: 0.1.1 + version: 0.2.0 esphome/micro-flac: version: 0.1.1 esphome/micro-opus: @@ -92,6 +92,6 @@ dependencies: esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: - version: 0.3.0 + version: 0.3.1 lvgl/lvgl: version: 9.5.0 diff --git a/esphome/loader.py b/esphome/loader.py index 68664aaa265..2405fa6f884 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -14,6 +14,7 @@ from typing import Any from esphome.const import SOURCE_FILE_EXTENSIONS from esphome.core import CORE import esphome.core.config +from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -31,8 +32,9 @@ class FileResource: class ComponentManifest: - def __init__(self, module: ModuleType): + def __init__(self, module: ModuleType, recursive_sources: bool = False): self.module = module + self.recursive_sources = recursive_sources @property def package(self) -> str: @@ -92,7 +94,7 @@ class ComponentManifest: return getattr(self.module, "CODEOWNERS", []) @property - def instance_type(self) -> list[str]: + def instance_type(self) -> MockObjClass | None: return getattr(self.module, "INSTANCE_TYPE", None) @property @@ -108,8 +110,10 @@ class ComponentManifest: def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. - This will return all cpp source files that are located in the same folder as the - loaded .py file (does not look through subdirectories) + By default only files directly in the package directory are returned. Manifests + constructed with ``recursive_sources=True`` also descend into non-subpackage + subdirectories (subdirectories without an ``__init__.py``), so core code can + live under ``esphome/core//`` without every component paying the cost. """ ret: list[FileResource] = [] @@ -121,23 +125,30 @@ class ComponentManifest: set(filter_source_files_func()) if filter_source_files_func else set() ) - # Process all resources - for resource in ( - r.name - for r in importlib.resources.files(self.package).iterdir() - if r.is_file() - ): - if Path(resource).suffix not in SOURCE_FILE_EXTENSIONS: - continue - if not importlib.resources.files(self.package).joinpath(resource).is_file(): - # Not a resource = this is a directory (yeah this is confusing) - continue + root = importlib.resources.files(self.package) - # Skip excluded files - if resource in excluded_files: - continue + for child in root.iterdir(): + name = child.name + if child.is_file(): + if Path(name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + if name in excluded_files: + continue + ret.append(FileResource(self.package, name)) + elif self.recursive_sources and child.is_dir() and name != "__pycache__": + # Skip Python subpackages — they load as their own components. + if child.joinpath("__init__.py").is_file(): + continue + for sub in child.iterdir(): + if not sub.is_file(): + continue + if Path(sub.name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + resource = f"{name}/{sub.name}" + if resource in excluded_files: + continue + ret.append(FileResource(self.package, resource)) - ret.append(FileResource(self.package, resource)) return ret @@ -237,7 +248,9 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) +_COMPONENT_CACHE["esphome"] = ComponentManifest( + esphome.core.config, recursive_sources=True +) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: diff --git a/esphome/writer.py b/esphome/writer.py index 787ecac6f6e..816c57a0bc1 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -22,7 +22,6 @@ from esphome.helpers import ( read_file, rmtree, walk_files, - write_file, write_file_if_changed, ) from esphome.storage_json import StorageJSON, storage_path @@ -171,6 +170,7 @@ VERSION_H_FORMAT = """\ DEFINES_H_TARGET = "esphome/core/defines.h" 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" ESPHOME_README_TXT = """ THIS DIRECTORY IS AUTO-GENERATED, DO NOT MODIFY @@ -209,13 +209,22 @@ def copy_src_tree(): source_files_copy = source_files_map.copy() ignore_targets = [ - Path(x) for x in (DEFINES_H_TARGET, VERSION_H_TARGET, BUILD_INFO_DATA_H_TARGET) + Path(x) + for x in ( + DEFINES_H_TARGET, + VERSION_H_TARGET, + BUILD_INFO_DATA_H_TARGET, + BUILD_INFO_DATA_CPP_TARGET, + ) ] for t in ignore_targets: source_files_copy.pop(t, None) # Files to exclude from sources_changed tracking (generated files) - generated_files = {Path("esphome/core/build_info_data.h")} + generated_files = { + Path("esphome/core/build_info_data.h"), + Path("esphome/core/build_info_data.cpp"), + } sources_changed = False for fname in walk_files(CORE.relative_src_path("esphome")): @@ -268,12 +277,15 @@ def copy_src_tree(): build_info_data_h_path = CORE.relative_src_path( "esphome", "core", "build_info_data.h" ) + build_info_data_cpp_path = CORE.relative_src_path( + "esphome", "core", "build_info_data.cpp" + ) build_info_json_path = CORE.relative_build_path("build_info.json") config_hash, build_time, build_time_str, comment = get_build_info() # Defensively force a rebuild if the build_info files don't exist, or if # there was a config change which didn't actually cause a source change - if not build_info_data_h_path.exists(): + if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists(): sources_changed = True else: try: @@ -288,13 +300,19 @@ def copy_src_tree(): # Write build_info header and JSON metadata if sources_changed: - write_file( + # write_file_if_changed avoids bumping mtime on identical content, + # which is what makes the stable header actually isolate metadata churn. + write_file_if_changed( build_info_data_h_path, - generate_build_info_data_h( + generate_build_info_data_h(), + ) + write_file_if_changed( + build_info_data_cpp_path, + generate_build_info_data_cpp( config_hash, build_time, build_time_str, comment ), ) - write_file( + write_file_if_changed( build_info_json_path, json.dumps( { @@ -345,27 +363,60 @@ def get_build_info() -> tuple[int, int, str, str]: return config_hash, build_time, build_time_str, comment -def generate_build_info_data_h( - config_hash: int, build_time: int, build_time_str: str, comment: str -) -> str: - """Generate build_info_data.h header with config hash, build time, and comment.""" - # cpp_string_escape returns '"escaped"', slice off the quotes since template has them - escaped_comment = cpp_string_escape(comment)[1:-1] - # +1 for null terminator - comment_size = len(comment) + 1 - return f"""#pragma once -// Auto-generated build_info data -#define ESPHOME_CONFIG_HASH 0x{config_hash:08x}U // NOLINT -#define ESPHOME_BUILD_TIME {build_time} // NOLINT -#define ESPHOME_COMMENT_SIZE {comment_size} // NOLINT +def generate_build_info_data_h() -> str: + """Generate stable declarations for build info provided by generated C++.""" + return """#pragma once +// Auto-generated build_info declarations +#include +#include +#include #ifdef USE_ESP8266 #include -static const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; -#else -static const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; -static const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; #endif + +namespace esphome { +extern const uint32_t ESPHOME_CONFIG_HASH; +extern const time_t ESPHOME_BUILD_TIME; +extern const size_t ESPHOME_COMMENT_SIZE; +#ifdef USE_ESP8266 +extern const char ESPHOME_BUILD_TIME_STR[] PROGMEM; +extern const char ESPHOME_COMMENT_STR[] PROGMEM; +#else +extern const char ESPHOME_BUILD_TIME_STR[]; +extern const char ESPHOME_COMMENT_STR[]; +#endif +} // namespace esphome +""" + + +def generate_build_info_data_cpp( + config_hash: int, build_time: int, build_time_str: str, comment: str +) -> str: + """Generate build_info_data.cpp with config hash, build time, and comment.""" + from esphome.core.config import COMMENT_MAX_LEN + + # Defense-in-depth clamp; errors="ignore" drops a partial trailing UTF-8 + # sequence so the literal never decodes to a truncated codepoint. + encoded = comment.encode("utf-8")[:COMMENT_MAX_LEN] + comment = encoded.decode("utf-8", errors="ignore") + # cpp_string_escape wraps in quotes; strip them since the template has them. + escaped_comment = cpp_string_escape(comment)[1:-1] + comment_size = len(comment.encode("utf-8")) + 1 # +1 for NUL + return f"""// Auto-generated build_info data +#include "esphome/core/build_info_data.h" + +namespace esphome {{ +const uint32_t ESPHOME_CONFIG_HASH = 0x{config_hash:08x}U; // NOLINT +const time_t ESPHOME_BUILD_TIME = {build_time}; // NOLINT +const size_t ESPHOME_COMMENT_SIZE = {comment_size}; // NOLINT +#ifdef USE_ESP8266 +const char ESPHOME_BUILD_TIME_STR[] PROGMEM = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] PROGMEM = "{escaped_comment}"; +#else +const char ESPHOME_BUILD_TIME_STR[] = "{build_time_str}"; +const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; +#endif +}} // namespace esphome """ diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 42da27ec142..3cfc9c4b15d 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -113,6 +113,15 @@ def make_data_base( return value +def make_literal(value: Any) -> ESPLiteralValue | Any: + """Wrap a value in an ESPLiteralValue object.""" + try: + return add_class_to_obj(value, ESPLiteralValue) + except TypeError: + # Adding class failed, ignore error + return value + + def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any: """Tags a list/string/dict value with context vars that must be applied to it and its children during the substitution pass. If no vars are given, no tagging is done. @@ -525,7 +534,7 @@ class ESPHomeLoaderMixin: obj = self.construct_sequence(node) elif isinstance(node, yaml.MappingNode): obj = self.construct_mapping(node) - return add_class_to_obj(obj, ESPLiteralValue) + return make_literal(obj) @_add_data_ref def construct_extend(self, node: yaml.Node) -> Extend: diff --git a/requirements.txt b/requirements.txt index 90b06938408..abc8ac5dbb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -cryptography==46.0.7 +cryptography==47.0.0 voluptuous==0.16.0 PyYAML==6.0.3 paho-mqtt==1.6.1 @@ -6,13 +6,13 @@ colorama==0.4.6 icmplib==3.0.4 tornado==6.5.5 tzlocal==5.3.1 # from time -tzdata>=2026.1 # from time +tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 click==8.3.3 -esphome-dashboard==20260408.1 -aioesphomeapi==44.21.0 +esphome-dashboard==20260425.0 +aioesphomeapi==44.22.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import diff --git a/requirements_test.txt b/requirements_test.txt index bb98375cb65..568d79d6764 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.11 # also change in .pre-commit-config.yaml when updating +ruff==0.15.12 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit @@ -12,3 +12,6 @@ pytest-asyncio==1.3.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 + +# Used by the import-time regression check (.github/workflows/ci.yml → import-time job) +importtime-waterfall==1.0.0 diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index c10479a726e..bf672d05674 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -184,6 +184,11 @@ class TypeInfo(ABC): """Check if this field should always be encoded (skip zero/empty check).""" return get_field_opt(self._field, pb.force, False) + @property + def mac_address(self) -> bool: + """Check if this uint64 field is a 48-bit MAC address (use 7-byte fast path).""" + return get_field_opt(self._field, pb.mac_address, False) + @property def max_value(self) -> int | None: """Get the max_value option for this field, or None if not set.""" @@ -665,8 +670,22 @@ class UInt64Type(VarintTypeMixin, TypeInfo): return o def get_size_calculation(self, name: str, force: bool = False) -> str: + if self.mac_address and force: + field_id_size = self.calculate_field_id_size() + return ( + f"size += ProtoSize::calc_uint64_48bit_force({field_id_size}, {name});" + ) return self._get_simple_size_calculation(name, force, "uint64") + @property + def RAW_ENCODE_MAP(self) -> dict[str, str]: # noqa: N802 + if self.mac_address: + return { + **TypeInfo.RAW_ENCODE_MAP, + "encode_uint64": "ProtoEncode::encode_varint_raw_48bit(pos, {value});", + } + return TypeInfo.RAW_ENCODE_MAP + def get_estimated_size(self) -> int: return self.calculate_field_id_size() + 3 # field ID + 3 bytes typical varint @@ -3558,8 +3577,13 @@ static const char *const TAG = "api.service"; # Generate read_message_ as APIConnection method (not base class) so the compiler # can devirtualize and inline the on_* handler calls within the same class. # APIConnection declares this method in api_connection.h. + # Guard with #ifdef USE_API since APIConnection itself is only defined when + # USE_API is set; without this, builds that compile this .cpp without + # USE_API (e.g. C++ unit tests for api dependencies) fail to find the + # class declaration. - out = "void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {\n" + out = "#ifdef USE_API\n" + out += "void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {\n" # Auth check block before dispatch switch out += " // Check authentication/connection requirements\n" @@ -3604,6 +3628,7 @@ static const char *const TAG = "api.service"; out += " break;\n" out += " }\n" out += "}\n" + out += "#endif // USE_API\n" cpp += out hpp += "};\n" diff --git a/script/build_helpers.py b/script/build_helpers.py index 1cfae51fca9..4cf2f93fbb6 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -324,8 +324,23 @@ def compile_and_get_binary( domain_list.append({CONF_PLATFORM: component}) # Skip "core" — it's a pseudo-component handled by the build # system, not a real loadable component (get_component returns None) - elif get_component(component_name) is not None: - config.setdefault(component_name, []) + elif (component := get_component(component_name)) is not None: + # MULTI_CONF components store their config as a list of dicts, + # everything else stores a single dict. Run the component's + # schema with {} so defaults get populated -- code paths like + # socket.FILTER_SOURCE_FILES expect a fully-populated mapping. + if component.multi_conf: + config.setdefault(component_name, []) + elif component_name not in config: + schema = component.config_schema + try: + config[component_name] = schema({}) if schema is not None else {} + except Exception: # noqa: BLE001 + # Schema requires explicit input we can't synthesize; fall + # back to an empty mapping so subscripting at least returns + # KeyError on missing keys rather than crashing on the + # wrong type. + config[component_name] = {} # Register platforms from the extra config (benchmark.yaml) so # USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing diff --git a/script/check_import_time.py b/script/check_import_time.py new file mode 100755 index 00000000000..0d5362c9681 --- /dev/null +++ b/script/check_import_time.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Regression check for `import esphome.__main__` cost. + +Runs `python -m importtime_waterfall --har esphome.__main__` (which invokes +`-X importtime` in fresh subprocesses, best-of-N) and compares the root +cumulative import time against a checked-in budget +(`script/import_time_budget.json`). + +The CLI pays this cost on every invocation before the requested command even +runs, so a regression here hurts every user. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys +from typing import Any, TextIO + +SCRIPT_DIR = Path(__file__).parent +BUDGET_PATH = SCRIPT_DIR / "import_time_budget.json" + +TARGET_MODULE = "esphome.__main__" +DEFAULT_MARGIN_PCT = 15 +OFFENDERS_TOP_N = 15 + + +def run_waterfall(module: str) -> str: + """Run `importtime_waterfall --har ` and return the HAR JSON text. + + `importtime_waterfall` itself runs the target in 6 fresh subprocesses + under `-X importtime` and emits the HAR of the fastest run. + """ + result = subprocess.run( + [sys.executable, "-m", "importtime_waterfall", "--har", module], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return result.stdout + + +def measure(module: str, har_path: Path | None = None) -> dict[str, Any]: + """Return the parsed HAR for importing `module`. + + When `har_path` is given, also write the raw HAR JSON to that path so + callers can combine `--check` with `--har` without measuring twice. + """ + har_text = run_waterfall(module) + if har_path is not None: + har_path.write_text(har_text) + return json.loads(har_text) + + +def _entries(har: dict[str, Any]) -> list[dict[str, Any]]: + return har["log"]["entries"] + + +def root_cumulative_us(har: dict[str, Any], module: str) -> int: + """Return the cumulative import time (µs) of `module` from a HAR. + + The HAR `time` field is authored by importtime_waterfall using µs values + fed through `timedelta(milliseconds=...)`, so the number read back is the + original self/cumulative time in microseconds (labelled "ms" in HAR). + """ + for entry in _entries(har): + if entry["request"]["url"] == module: + return entry["time"] + raise RuntimeError( + f"No HAR entry for {module!r}. Is it importable with " + f"`python -c 'import {module}'`?" + ) + + +def top_offenders(har: dict[str, Any], n: int) -> list[tuple[str, int, int]]: + """Return up to `n` (name, self_us, cumulative_us), ranked by self_us desc. + + A module imported from multiple places is counted once (first entry wins, + matching importtime's own de-duplication). + """ + seen: dict[str, tuple[int, int]] = {} + for entry in _entries(har): + name = entry["request"]["url"] + if name in seen: + continue + self_us = entry["timings"]["receive"] + cumulative_us = entry["time"] + seen[name] = (self_us, cumulative_us) + ranked = sorted( + ((name, s, c) for name, (s, c) in seen.items()), + key=lambda row: row[1], + reverse=True, + ) + return ranked[:n] + + +def read_budget() -> dict[str, Any]: + if not BUDGET_PATH.exists(): + return {} + with BUDGET_PATH.open() as f: + return json.load(f) + + +def write_budget(cumulative_us: int, margin_pct: int) -> None: + payload = { + "target_module": TARGET_MODULE, + "margin_pct": margin_pct, + "cumulative_us": cumulative_us, + } + with BUDGET_PATH.open("w") as f: + json.dump(payload, f, indent=2) + f.write("\n") + + +def _format_us(us: int) -> str: + if us >= 1000: + return f"{us / 1000:.1f}ms" + return f"{us}us" + + +def _print_offenders_table( + offenders: list[tuple[str, int, int]], stream: TextIO +) -> None: + name_w = max(len(name) for name, _, _ in offenders) + print(f"\n{'module':<{name_w}} {'self':>10} {'cumulative':>12}", file=stream) + print(f"{'-' * name_w} {'-' * 10} {'-' * 12}", file=stream) + for name, self_us, cum_us in offenders: + print( + f"{name:<{name_w}} {_format_us(self_us):>10} {_format_us(cum_us):>12}", + file=stream, + ) + + +def cmd_check(args: argparse.Namespace) -> int: + budget = read_budget() + if not budget: + print( + f"ERROR: {BUDGET_PATH.name} missing. Run with --update first.", + file=sys.stderr, + ) + return 2 + + har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None) + measured = root_cumulative_us(har, TARGET_MODULE) + + baseline = budget["cumulative_us"] + margin_pct = budget.get("margin_pct", DEFAULT_MARGIN_PCT) + ceiling = int(baseline * (1 + margin_pct / 100)) + + summary = ( + f"measured {TARGET_MODULE}: {_format_us(measured)} " + f"(budget {_format_us(baseline)} + {margin_pct}% = {_format_us(ceiling)})" + ) + passed = measured <= ceiling + stream = sys.stdout if passed else sys.stderr + + if passed: + print(summary) + else: + print( + f"REGRESSION: `import {TARGET_MODULE}` took {_format_us(measured)}, " + f"exceeding the budget of {_format_us(baseline)} + {margin_pct}% " + f"({_format_us(ceiling)}).", + file=stream, + ) + + print("\nTop import-time offenders (by self time):", file=stream) + _print_offenders_table(top_offenders(har, OFFENDERS_TOP_N), stream) + + if not passed: + print( + "\nIf this regression is intentional, regenerate the budget with:\n" + " script/check_import_time.py --update\n" + "Otherwise, consider making the new import lazy " + "(import inside the function that uses it).", + file=stream, + ) + return 1 + return 0 + + +def cmd_update(args: argparse.Namespace) -> int: + har = measure(TARGET_MODULE, har_path=Path(args.har) if args.har else None) + measured = root_cumulative_us(har, TARGET_MODULE) + write_budget(measured, args.margin_pct) + print( + f"Wrote {BUDGET_PATH.name}: " + f"{TARGET_MODULE}={_format_us(measured)} " + f"(margin {args.margin_pct}%)" + ) + return 0 + + +def cmd_har_only(args: argparse.Namespace) -> int: + Path(args.har).write_text(run_waterfall(TARGET_MODULE)) + print(f"Wrote waterfall HAR to {args.har}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--margin-pct", + type=int, + default=DEFAULT_MARGIN_PCT, + help=(f"Margin over baseline for --update (default: {DEFAULT_MARGIN_PCT}%%)."), + ) + parser.add_argument( + "--har", + metavar="PATH", + help=( + "Write a waterfall HAR file at PATH. Can be combined with " + "--check or --update to reuse that run's measurement (avoids " + "measuring twice)." + ), + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--check", action="store_true", help="Fail if measured time exceeds budget." + ) + mode.add_argument( + "--update", + action="store_true", + help="Rewrite the budget from a fresh measurement.", + ) + args = parser.parse_args() + + if args.check: + return cmd_check(args) + if args.update: + return cmd_update(args) + if args.har: + return cmd_har_only(args) + parser.error("Specify at least one of --check, --update, or --har PATH.") + return 2 # unreachable; parser.error exits. Here to satisfy ruff RET503. + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/ci-custom.py b/script/ci-custom.py index 02ec08bc318..b257a3818bd 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -511,6 +511,40 @@ def lint_no_std_string_view(fname, match): ) +@lint_re_check( + r"(?:" + # `from esphome.components.const import ...` + r"from\s+esphome\.components\.const\s+import" + r"|" + # `import esphome.components.const` (with optional `as` alias) + r"import\s+esphome\.components\.const\b" + r"|" + # `from esphome.components import [(] ... const ... [)]` + # Handles parenthesized + multiline import lists by allowing newlines inside + # the parens via [^)]*. Single-line form falls back to the [^#\n]* branch. + r"from\s+esphome\.components\s+import\s*" + r"(?:\([^)]*\bconst\b[^)]*\)|(?:[^#\n]*[\s,])?\bconst\b)" + r")", + include=["*.py"], + exclude=[ + "esphome/components/*", + "tests/*", + "script/ci-custom.py", + ], +) +def lint_no_components_const_outside_components(fname, match): + return ( + f"Constants in {highlight('esphome/components/const/__init__.py')} are intended " + f"to be shared only between components in {highlight('esphome/components/')}. " + f"Code outside this folder must not import from " + f"{highlight('esphome.components.const')}.\n" + f"For core code (used outside {highlight('esphome/components/')}), define the " + f"constant in {highlight('esphome/const.py')} instead. When adding a new " + f"{highlight('CONF_')} constant there, bump {highlight('CONST_PY_MAX_CONF')} " + f"in this file accordingly (see {highlight('lint_const_py_frozen')})." + ) + + @lint_post_check def lint_constants_usage(): errs = [] @@ -837,7 +871,16 @@ def lint_no_std_to_string(fname, match): f"{highlight('std::to_string()')} (including unqualified {highlight('to_string()')}) " f"allocates heap memory. On long-running embedded devices, repeated heap allocations " f"fragment memory over time.\n" - f"Please use {highlight('snprintf()')} with a stack buffer instead.\n" + f"\n" + f"For plain integer formatting, prefer the dedicated helpers in helpers.h over " + f"{highlight('snprintf()')} — they avoid pulling in printf formatting code and are " + f"smaller and faster:\n" + f" int8_t: {highlight('int8_to_str(buf, val)')} (buf >= 5 bytes)\n" + f" uint8_t/uint16_t/uint32_t: {highlight('uint32_to_str(buf, val)')} (buf = UINT32_MAX_STR_SIZE; smaller types auto-widen)\n" + f"Example: {highlight('char buf[UINT32_MAX_STR_SIZE]; uint32_to_str(buf, value);')}\n" + f"For sensor values, use {highlight('value_accuracy_to_buf()')} from helpers.h.\n" + f"\n" + f"Otherwise use {highlight('snprintf()')} with a stack buffer.\n" f"\n" f"Buffer sizes and format specifiers (sizes include sign and null terminator):\n" f" uint8_t: 4 chars - %u (or PRIu8)\n" @@ -851,7 +894,6 @@ def lint_no_std_to_string(fname, match): f" float/double: 24 chars - %.8g (15 digits + sign + decimal + e+XXX)\n" f" 317 chars - %f (for DBL_MAX: 309 int digits + decimal + 6 frac + sign)\n" f"\n" - f"For sensor values, use value_accuracy_to_buf() from helpers.h.\n" f'Example: char buf[11]; snprintf(buf, sizeof(buf), "%" PRIu32, value);\n' f"(If strictly necessary, add `{highlight('// NOLINT')}` to the end of the line)" ) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index d94d472c9ed..6fd7ab297c8 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -349,6 +349,42 @@ def should_run_python_linters(branch: str | None = None) -> bool: return _any_changed_file_endswith(branch, PYTHON_FILE_EXTENSIONS) +# Files outside esphome/**/*.py whose changes can affect `import esphome.__main__` +# cost. requirements.txt / pyproject.toml change the dependency graph pulled in +# by top-level imports; check_import_time.py itself changes the check's behavior. +IMPORT_TIME_TRIGGER_FILES = frozenset( + { + "requirements.txt", + "requirements_dev.txt", + "requirements_test.txt", + "pyproject.toml", + "script/check_import_time.py", + "script/import_time_budget.json", + } +) + + +def should_run_import_time(branch: str | None = None) -> bool: + """Determine if the `import esphome.__main__` time regression check should run. + + Runs when any Python file under `esphome/` changes (those modules are + loaded transitively from `esphome.__main__`), when dependency + declarations change, or when the check script/budget itself changes. + + Args: + branch: Branch to compare against. If None, uses default. + + Returns: + True if the import-time check should run, False otherwise. + """ + for file in changed_files(branch): + if file.startswith("esphome/") and file.endswith(PYTHON_FILE_EXTENSIONS): + return True + if file in IMPORT_TIME_TRIGGER_FILES: + return True + return False + + def determine_cpp_unit_tests( branch: str | None = None, ) -> tuple[bool, list[str]]: @@ -402,8 +438,11 @@ def should_run_benchmarks(branch: str | None = None) -> bool: Benchmarks run when any of the following conditions are met: 1. Core C++ files changed (esphome/core/*) - 2. A directly changed component has benchmark files (no dependency expansion) - 3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, + 2. The host platform changed (esphome/components/host/*) — benchmarks + are built and run on the host platform, so its implementations of + ``millis()``/``micros()``/etc. affect every benchmark + 3. A directly changed component has benchmark files (no dependency expansion) + 4. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) Unlike unit tests, benchmarks do NOT expand to dependent components. @@ -420,6 +459,10 @@ def should_run_benchmarks(branch: str | None = None) -> bool: if core_changed(files): return True + # Host platform supplies the runtime that benchmarks execute on + if any(f.startswith("esphome/components/host/") for f in files): + return True + # Check if benchmark infrastructure changed if any( f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES @@ -773,6 +816,7 @@ def main() -> None: run_clang_tidy = should_run_clang_tidy(args.branch) run_clang_format = should_run_clang_format(args.branch) run_python_linters = should_run_python_linters(args.branch) + run_import_time = should_run_import_time(args.branch) changed_cpp_file_count = count_changed_cpp_files(args.branch) # Get changed components @@ -906,6 +950,7 @@ def main() -> None: "clang_tidy_mode": clang_tidy_mode, "clang_format": run_clang_format, "python_linters": run_python_linters, + "import_time": run_import_time, "changed_components": changed_components, "changed_components_with_tests": changed_components_with_tests, "directly_changed_components_with_tests": list(directly_changed_with_tests), diff --git a/script/import_time_budget.json b/script/import_time_budget.json new file mode 100644 index 00000000000..1e656dc9776 --- /dev/null +++ b/script/import_time_budget.json @@ -0,0 +1,5 @@ +{ + "target_module": "esphome.__main__", + "margin_pct": 15, + "cumulative_us": 123000 +} diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 4f41f2cc704..e1d999abc7c 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -31,7 +31,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->configure_entity_("test bs1",' in main_cpp + assert 'App.register_binary_sensor(bs_1, "test bs1",' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 544e748f913..f8881a832ce 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -29,7 +29,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->configure_entity_("wol_test_1",' in main_cpp + assert 'App.register_button(wol_1, "wol_test_1",' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 8c1278a3323..84128d75d7c 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -12,7 +12,7 @@ def test_deep_sleep_setup(generate_main): in main_cpp ) assert "new(deepsleep) deep_sleep::DeepSleepComponent();" in main_cpp - assert "App.register_component_(deepsleep);" in main_cpp + assert "App.register_component_(deepsleep, " in main_cpp def test_deep_sleep_sleep_duration(generate_main): diff --git a/tests/component_tests/esp32/config/reproducible_build.yaml b/tests/component_tests/esp32/config/reproducible_build.yaml new file mode 100644 index 00000000000..eb9721b4320 --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: esp-idf diff --git a/tests/component_tests/esp32/config/reproducible_build_arduino.yaml b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml new file mode 100644 index 00000000000..a5433a441d2 --- /dev/null +++ b/tests/component_tests/esp32/config/reproducible_build_arduino.yaml @@ -0,0 +1,8 @@ +esphome: + name: test + +esp32: + board: esp32dev + variant: esp32 + framework: + type: arduino diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index ac492e27529..203f4841072 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_IGNORE_PIN_VALIDATION_ERROR, CONF_NUMBER, + KEY_NATIVE_IDF, PlatformFramework, ) from esphome.core import CORE @@ -232,3 +233,41 @@ def test_execute_from_psram_disabled_sdkconfig( assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig assert "CONFIG_SPIRAM_RODATA" not in sdkconfig assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig + + +def test_platformio_idf_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test PlatformIO ESP-IDF builds enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_platformio_arduino_enables_reproducible_build( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Test PlatformIO Arduino builds enable reproducible app metadata.""" + generate_main(component_config_path("reproducible_build_arduino.yaml")) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True + + +def test_native_idf_enables_reproducible_build( + component_config_path: Callable[[str], Path], +) -> None: + """Test native ESP-IDF builds enable reproducible app metadata.""" + from esphome.__main__ import generate_cpp_contents + from esphome.config import read_config + + CORE.config_path = component_config_path("reproducible_build.yaml") + CORE.config = read_config({}) + CORE.data[KEY_NATIVE_IDF] = True + generate_cpp_contents(CORE.config) + + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True diff --git a/tests/component_tests/external_components/test_init.py b/tests/component_tests/external_components/test_init.py index 905c0afa8b3..d3813ecc759 100644 --- a/tests/component_tests/external_components/test_init.py +++ b/tests/component_tests/external_components/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the external_components skip_update functionality.""" +"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any @@ -12,25 +12,17 @@ from esphome.const import ( CONF_URL, TYPE_GIT, ) +from esphome.core import CORE, TimePeriodSeconds -def test_external_components_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components don't update when skip_update=True.""" - # Create a components directory structure +def _make_config(tmp_path: Path) -> dict[str, Any]: components_dir = tmp_path / "components" components_dir.mkdir() - - # Create a test component test_component_dir = components_dir / "test_component" test_component_dir.mkdir() (test_component_dir / "__init__.py").write_text("# Test component") - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { + return { CONF_EXTERNAL_COMPONENTS: [ { CONF_SOURCE: { @@ -43,92 +35,37 @@ def test_external_components_skip_update_true( ] } - # Call with skip_update=True - do_external_components_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH - - -def test_external_components_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock +def test_external_components_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, ) -> None: - """Test that external components update when skip_update=False.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) + + CORE.skip_external_update = True + do_external_components_pass(config) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_external_components_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_install_meta_finder: MagicMock, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + config = _make_config(tmp_path) - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call with skip_update=False - do_external_components_pass(config, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_external_components_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_install_meta_finder: MagicMock -) -> None: - """Test that external components update by default when skip_update not specified.""" - # Create a components directory structure - components_dir = tmp_path / "components" - components_dir.mkdir() - - # Create a test component - test_component_dir = components_dir / "test_component" - test_component_dir.mkdir() - (test_component_dir / "__init__.py").write_text("# Test component") - - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - config: dict[str, Any] = { - CONF_EXTERNAL_COMPONENTS: [ - { - CONF_SOURCE: { - "type": TYPE_GIT, - CONF_URL: "https://github.com/test/components", - }, - CONF_REFRESH: "1d", - "components": "all", - } - ] - } - - # Call without skip_update parameter do_external_components_pass(config) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/component_tests/helpers.py b/tests/component_tests/helpers.py index 568d1639d0c..2eb588c0ca3 100644 --- a/tests/component_tests/helpers.py +++ b/tests/component_tests/helpers.py @@ -8,12 +8,22 @@ INTERNAL_BIT = 1 << 24 def extract_packed_value(main_cpp: str, var_name: str) -> int: - """Extract the third (packed) argument from a configure_entity_ call.""" - pattern = ( - rf"{re.escape(var_name)}->configure_entity_\(" + """Extract the packed-fields argument from the entity's configure call. + + Matches both legacy form ``var->configure_entity_(name, hash, packed)`` and the + combined form ``App.register_(var, name, hash, packed)``. + """ + escaped_var = re.escape(var_name) + legacy_pattern = ( + rf"{escaped_var}->configure_entity_\(" r'"(?:\\.|[^"\\])*"' r",\s*\w+,\s*(\d+)\)" ) - match = re.search(pattern, main_cpp) - assert match, f"configure_entity_ call not found for {var_name}" + combined_pattern = ( + rf"App\.register_\w+\(\s*{escaped_var}\s*,\s*" + r'"(?:\\.|[^"\\])*"' + r",\s*\w+,\s*(\d+)\)" + ) + match = re.search(combined_pattern, main_cpp) or re.search(legacy_pattern, main_cpp) + assert match, f"configure call not found for {var_name}" return int(match.group(1)) diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index 6f73888c7d1..f7f60a1f4d5 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -7,10 +7,12 @@ from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch +from PIL import Image as PILImage import pytest from esphome import config_validation as cv from esphome.components.image import ( + CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, @@ -411,3 +413,70 @@ async def test_svg_with_mm_dimensions_succeeds( assert 30 < height < 50, ( f"Height should be around 39 pixels for 10mm at 100dpi, got {height}" ) + + +@pytest.mark.asyncio +async def test_rgb565_alpha_animation_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, +) -> None: + """RGB565+alpha animations must store each frame as a self-contained + [RGB plane | alpha plane] block. Animation::update_data_start_ steps frames + with a single per-frame stride, so any cross-frame layout (all RGB then all + alpha) makes the C++ alpha read land in the next frame's RGB bytes — that + was the regression behind issue #15999. + """ + # Build a 2-frame APNG where each frame is a solid color with a known + # alpha. APNG preserves full RGBA per pixel (GIF only has 1-bit alpha so + # round-tripping mid-range alpha values does not work). Frame 0 is fully + # opaque red, frame 1 is fully transparent blue. + width = 4 + height = 3 + frame0 = PILImage.new("RGBA", (width, height), (255, 0, 0, 0xFF)) + frame1 = PILImage.new("RGBA", (width, height), (0, 0, 255, 0x00)) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB565", + CONF_TRANSPARENCY: CONF_ALPHA_CHANNEL, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + # Recover the bytes handed to progmem_array. Signature is (id_, rhs). + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + rgb_size = width * height * 2 + alpha_size = width * height + frame_size = rgb_size + alpha_size + assert len(data) == frame_size * frame_count, ( + "RGB565+alpha animation buffer must be (RGB + alpha) per frame, not " + "all RGB followed by all alpha" + ) + + # Frame 0: RGB plane is red, alpha plane is 0xFF. Frame 1: alpha plane is + # 0x00. If the layout regresses to [all RGB | all alpha], the alpha bytes + # would all land at the tail of the buffer and the per-frame slices below + # would point at RGB565 noise instead. + frame0_alpha = data[rgb_size : rgb_size + alpha_size] + frame1_alpha = data[frame_size + rgb_size : frame_size + rgb_size + alpha_size] + assert all(a == 0xFF for a in frame0_alpha), ( + f"Frame 0 alpha plane should be opaque, got {frame0_alpha}" + ) + assert all(a == 0x00 for a in frame1_alpha), ( + f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" + ) diff --git a/tests/component_tests/lvgl/__init__.py b/tests/component_tests/lvgl/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/lvgl/test_grid_layout.py b/tests/component_tests/lvgl/test_grid_layout.py new file mode 100644 index 00000000000..dfd4b2460cb --- /dev/null +++ b/tests/component_tests/lvgl/test_grid_layout.py @@ -0,0 +1,239 @@ +"""Unit tests for the LVGL grid layout shorthand and rows/columns auto-sizing.""" + +from __future__ import annotations + +import pytest +from voluptuous import Invalid + +from esphome.components.lvgl.defines import ( + CONF_GRID_COLUMNS, + CONF_GRID_ROWS, + CONF_LAYOUT, + CONF_WIDGETS, + TYPE_GRID, +) +from esphome.components.lvgl.layout import GridLayout, grid_dimension +from esphome.const import CONF_TYPE + +FR1 = "LV_GRID_FR(1)" + + +def _widgets(n: int) -> list[dict]: + """Build a list of `n` placeholder widgets for the validate() input.""" + return [{"label": {}} for _ in range(n)] + + +# --------------------------------------------------------------------------- +# grid_dimension validator +# --------------------------------------------------------------------------- + + +def test_grid_dimension_int_expands_to_fr1_list() -> None: + """A positive integer should expand to a list of LV_GRID_FR(1) entries.""" + assert grid_dimension(1) == [FR1] + assert grid_dimension(3) == [FR1, FR1, FR1] + + +def test_grid_dimension_zero_or_negative_rejected() -> None: + """Non-positive integers must be rejected.""" + with pytest.raises(Invalid): + grid_dimension(0) + with pytest.raises(Invalid): + grid_dimension(-2) + + +def test_grid_dimension_list_passes_through() -> None: + """A list should be validated through the existing grid_spec list schema.""" + result = grid_dimension(["100px", "content", "fr(2)"]) + # `grid_spec` normalises each entry: pixel sizes become ints, the + # CONTENT keyword is uppercased and prefixed, and FR(n) is normalised. + assert result == [100, "LV_GRID_CONTENT", "LV_GRID_FR(2)"] + + +def test_grid_dimension_invalid_string_rejected() -> None: + """A string is not a valid grid dimension and should be rejected.""" + with pytest.raises(Invalid): + grid_dimension("not a list") + + +def test_grid_dimension_empty_list_rejected() -> None: + """An empty list of grid specs must be rejected.""" + with pytest.raises(Invalid, match="at least one entry"): + grid_dimension([]) + + +# --------------------------------------------------------------------------- +# Shorthand string layouts +# --------------------------------------------------------------------------- + + +def test_shorthand_full_form_unchanged() -> None: + """`x` continues to work and yields the exact dimensions.""" + config = {CONF_LAYOUT: "2x3", CONF_WIDGETS: _widgets(0)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert layout[CONF_TYPE] == TYPE_GRID + assert layout[CONF_GRID_ROWS] == [FR1, FR1] + assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1] + + +def test_shorthand_rows_only_calculates_columns_from_widgets() -> None: + """`x` derives the column count from the number of widgets.""" + config = {CONF_LAYOUT: "3x", CONF_WIDGETS: _widgets(7)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 7 widgets / 3 rows -> ceil = 3 columns. + assert len(layout[CONF_GRID_ROWS]) == 3 + assert len(layout[CONF_GRID_COLUMNS]) == 3 + + +def test_shorthand_columns_only_calculates_rows_from_widgets() -> None: + """`x` derives the row count from the number of widgets.""" + config = {CONF_LAYOUT: "x4", CONF_WIDGETS: _widgets(5)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 5 widgets / 4 cols -> ceil = 2 rows. + assert len(layout[CONF_GRID_ROWS]) == 2 + assert len(layout[CONF_GRID_COLUMNS]) == 4 + + +def test_shorthand_rows_only_no_widgets_defaults_columns_to_one() -> None: + """With no widgets and only rows specified, the column count defaults to 1.""" + config = {CONF_LAYOUT: "3x", CONF_WIDGETS: []} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert len(layout[CONF_GRID_ROWS]) == 3 + assert len(layout[CONF_GRID_COLUMNS]) == 1 + + +def test_shorthand_columns_only_no_widgets_defaults_rows_to_one() -> None: + """With no widgets and only columns specified, the row count defaults to 1.""" + config = {CONF_LAYOUT: "x4", CONF_WIDGETS: []} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert len(layout[CONF_GRID_ROWS]) == 1 + assert len(layout[CONF_GRID_COLUMNS]) == 4 + + +def test_shorthand_with_whitespace_accepted() -> None: + """The shorthand parser should tolerate whitespace around the components.""" + config = {CONF_LAYOUT: " 3 x ", CONF_WIDGETS: _widgets(6)} + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 6 widgets / 3 rows -> 2 columns. + assert len(layout[CONF_GRID_ROWS]) == 3 + assert len(layout[CONF_GRID_COLUMNS]) == 2 + + +def test_shorthand_bare_x_rejected() -> None: + """Pure `x` (no digits at all) is not a valid shorthand.""" + config = {CONF_LAYOUT: "x", CONF_WIDGETS: _widgets(2)} + with pytest.raises(Invalid): + GridLayout().validate(config) + + +@pytest.mark.parametrize( + "layout,bad_label", + [ + ("0x3", "row"), + ("3x0", "column"), + ("0x", "row"), + ("x0", "column"), + ("0x0", "row"), + ], +) +def test_shorthand_zero_dimension_rejected(layout: str, bad_label: str) -> None: + """Shorthand row/column counts must be >= 1.""" + config = {CONF_LAYOUT: layout, CONF_WIDGETS: _widgets(2)} + with pytest.raises(Invalid, match=f"{bad_label} count must be at least 1"): + GridLayout().validate(config) + + +def test_shorthand_get_layout_schemas_recognizes_partial_forms() -> None: + """`x` and `x` should be picked up by GridLayout.get_layout_schemas.""" + grid = GridLayout() + for layout in ("3x", "x4", "2x3"): + layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout}) + assert layout_schema is not None, f"{layout!r} should be recognised" + # Pure `x` and unrelated strings should not be picked up as a grid layout. + for layout in ("x", "horizontal"): + layout_schema, _ = grid.get_layout_schemas({CONF_LAYOUT: layout}) + assert layout_schema is None, f"{layout!r} should not be recognised" + + +# --------------------------------------------------------------------------- +# Dict-form layouts with rows/columns auto-sizing +# --------------------------------------------------------------------------- + + +def test_dict_rows_only_calculates_columns_from_widgets() -> None: + """A dict layout with only rows fills in the column count from widget count.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: [FR1, FR1], + }, + CONF_WIDGETS: _widgets(5), + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 5 widgets / 2 rows -> ceil = 3 columns. + assert len(layout[CONF_GRID_ROWS]) == 2 + assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1] + + +def test_dict_columns_only_calculates_rows_from_widgets() -> None: + """A dict layout with only columns fills in the row count from widget count.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_COLUMNS: [FR1, FR1, FR1], + }, + CONF_WIDGETS: _widgets(7), + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + # 7 widgets / 3 cols -> ceil = 3 rows. + assert layout[CONF_GRID_ROWS] == [FR1, FR1, FR1] + assert len(layout[CONF_GRID_COLUMNS]) == 3 + + +def test_dict_rows_only_no_widgets_defaults_columns_to_one() -> None: + """A dict layout with rows but no widgets defaults columns to 1.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: [FR1, FR1, FR1], + }, + CONF_WIDGETS: [], + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert len(layout[CONF_GRID_ROWS]) == 3 + assert layout[CONF_GRID_COLUMNS] == [FR1] + + +def test_dict_neither_rows_nor_columns_rejected() -> None: + """A grid layout dict without rows AND without columns must be rejected.""" + config = { + CONF_LAYOUT: {CONF_TYPE: TYPE_GRID}, + CONF_WIDGETS: _widgets(3), + } + with pytest.raises(Invalid): + GridLayout().validate(config) + + +def test_dict_both_rows_and_columns_unchanged() -> None: + """When both dimensions are present they are preserved as-is.""" + config = { + CONF_LAYOUT: { + CONF_TYPE: TYPE_GRID, + CONF_GRID_ROWS: [FR1, FR1], + CONF_GRID_COLUMNS: [FR1, FR1, FR1], + }, + CONF_WIDGETS: _widgets(0), + } + result = GridLayout().validate(config) + layout = result[CONF_LAYOUT] + assert layout[CONF_GRID_ROWS] == [FR1, FR1] + assert layout[CONF_GRID_COLUMNS] == [FR1, FR1, FR1] diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 4b3a4c705c1..4b8b7540e84 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -27,7 +27,7 @@ def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: assert "global_web_server_base" in main_cpp # Check component is registered - assert "App.register_component_(web_server_webserverotacomponent_id)" in main_cpp + assert "App.register_component_(web_server_webserverotacomponent_id" in main_cpp def test_web_server_ota_with_callbacks(generate_main: Callable[[str], str]) -> None: diff --git a/tests/component_tests/packages/test_init.py b/tests/component_tests/packages/test_init.py index fd30c2433f2..19c7bd36692 100644 --- a/tests/component_tests/packages/test_init.py +++ b/tests/component_tests/packages/test_init.py @@ -1,4 +1,4 @@ -"""Tests for the packages component skip_update functionality.""" +"""Tests for the packages skip-update behavior driven by CORE.skip_external_update.""" from pathlib import Path from typing import Any @@ -6,24 +6,12 @@ from unittest.mock import MagicMock from esphome.components.packages import do_packages_pass from esphome.const import CONF_FILES, CONF_PACKAGES, CONF_REFRESH, CONF_URL +from esphome.core import CORE, TimePeriodSeconds from esphome.util import OrderedDict -def test_packages_skip_update_true( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages don't update when skip_update=True.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { +def _make_config() -> dict[str, Any]: + return { CONF_PACKAGES: { "test_package": { CONF_URL: "https://github.com/test/repo", @@ -33,82 +21,47 @@ def test_packages_skip_update_true( } } - # Call with skip_update=True - do_packages_pass(config, skip_update=True) - # Verify clone_or_update was called with NEVER_REFRESH - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome import git - - assert call_args.kwargs["refresh"] == git.NEVER_REFRESH - - -def test_packages_skip_update_false( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock +def test_packages_skip_update_via_core_flag( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, ) -> None: - """Test that packages update when skip_update=False.""" - # Set up mock to return our tmp_path + """When CORE.skip_external_update is True, refresh is still passed through; + git.clone_or_update itself short-circuits the actual fetch.""" mock_clone_or_update.return_value = (tmp_path, None) - # Create the test yaml file test_file = tmp_path / "test.yaml" test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config mock_load_yaml.return_value = OrderedDict({"sensor": []}) - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } + config = _make_config() + + CORE.skip_external_update = True + do_packages_pass(config, command_line_substitutions={}) + + mock_clone_or_update.assert_called_once() + call_args = mock_clone_or_update.call_args + # Refresh is passed through verbatim — the global flag is enforced inside git.clone_or_update. + assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) + + +def test_packages_normal_refresh( + tmp_path: Path, + mock_clone_or_update: MagicMock, + mock_load_yaml: MagicMock, +) -> None: + """When CORE.skip_external_update is False, the configured refresh value is used.""" + mock_clone_or_update.return_value = (tmp_path, None) + + test_file = tmp_path / "test.yaml" + test_file.write_text("sensor: []") + mock_load_yaml.return_value = OrderedDict({"sensor": []}) + + config = _make_config() - # Call with skip_update=False (default) - do_packages_pass(config, command_line_substitutions={}, skip_update=False) - - # Verify clone_or_update was called with actual refresh value - mock_clone_or_update.assert_called_once() - call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) - - -def test_packages_default_no_skip( - tmp_path: Path, mock_clone_or_update: MagicMock, mock_load_yaml: MagicMock -) -> None: - """Test that packages update by default when skip_update not specified.""" - # Set up mock to return our tmp_path - mock_clone_or_update.return_value = (tmp_path, None) - - # Create the test yaml file - test_file = tmp_path / "test.yaml" - test_file.write_text("sensor: []") - - # Set mock_load_yaml to return some valid config - mock_load_yaml.return_value = OrderedDict({"sensor": []}) - - config: dict[str, Any] = { - CONF_PACKAGES: { - "test_package": { - CONF_URL: "https://github.com/test/repo", - CONF_FILES: ["test.yaml"], - CONF_REFRESH: "1d", - } - } - } - - # Call without skip_update parameter do_packages_pass(config, command_line_substitutions={}) - # Verify clone_or_update was called with actual refresh value mock_clone_or_update.assert_called_once() call_args = mock_clone_or_update.call_args - from esphome.core import TimePeriodSeconds - assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index af4b6db7961..13a6da9f2c6 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1491,3 +1491,133 @@ def test_substitute_package_definition_includes_source_location(tmp_path: Path) line, col = int(match.group(1)), int(match.group(2)) assert line == 2, f"expected 1-based line 2, got {line} (err={err!r})" assert col >= 1, f"expected 1-based column ≥ 1, got {col} (err={err!r})" + + +def test_substitute_package_definition_vars_preserved_literally() -> None: + """``vars:`` blocks in remote-package files are not substituted prematurely. + + Variable references inside ``vars:`` may resolve to substitutions + contributed by sibling packages that have not yet been loaded, so they + must be passed through untouched and resolved later by the package YAML. + """ + pkg = { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_REF: "main", + CONF_FILES: [ + { + CONF_PATH: "common/somefile.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + # Note: PIN is intentionally NOT in the context — it is meant to + # be resolved later, when the package YAML is processed. + result = _substitute_package_definition(pkg, ContextVars()) + + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_other_fields_still_substituted() -> None: + """Marking ``vars:`` literal does not stop substitution of url/ref/path.""" + ctx = ContextVars({"branch": "release", "org": "esphome"}) + pkg = { + CONF_URL: "https://github.com/${org}/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + { + CONF_PATH: "common/sensor.yaml", + CONF_VARS: {"pin": "${PIN}"}, + }, + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_URL] == "https://github.com/esphome/firmware" + assert result[CONF_REF] == "release" + # vars passed through unchanged + assert result[CONF_FILES][0][CONF_VARS] == {"pin": "${PIN}"} + + +def test_substitute_package_definition_without_vars_unaffected() -> None: + """Files entries without a ``vars:`` block continue to work.""" + ctx = ContextVars({"branch": "main"}) + pkg = { + CONF_URL: "https://github.com/esphome/firmware", + CONF_REF: "${branch}", + CONF_FILES: [ + {CONF_PATH: "file1.yaml"}, + "file2.yaml", + ], + } + result = _substitute_package_definition(pkg, ctx) + + assert result[CONF_REF] == "main" + assert result[CONF_FILES][0] == {CONF_PATH: "file1.yaml"} + assert result[CONF_FILES][1] == "file2.yaml" + + +@patch("esphome.yaml_util.load_yaml") +@patch("pathlib.Path.is_file") +@patch("esphome.git.clone_or_update") +def test_remote_package_vars_resolved_against_sibling_package_substitutions( + mock_clone_or_update, mock_is_file, mock_load_yaml +) -> None: + """A ``vars:`` reference in one remote package can resolve to a + substitution defined in a sibling remote package. + + A higher-priority package declares ``substitutions:`` (e.g. ``SENSOR_PIN: 5``) and a + lower-priority package's ``files: -> vars:`` references that substitution. + Because packages are processed highest-priority first and ``vars:`` is now + preserved literally during package-definition processing, the substitution + is resolved correctly when the package YAML itself is loaded. + """ + mock_clone_or_update.return_value = (Path("/tmp/noexists"), MagicMock()) + mock_is_file.return_value = True + + # Two YAML files mocked from the "remote" repo: + # - platform.yaml exports a substitution ``SENSOR_PIN`` + # - sensor.yaml uses ``${pin}`` (which is bound from ``vars:`` to + # ``${SENSOR_PIN}`` and resolved against the merged substitutions). + mock_load_yaml.side_effect = [ + # Order matches reverse-priority traversal (highest priority first). + OrderedDict( + { + CONF_SUBSTITUTIONS: {"SENSOR_PIN": "GPIO5"}, + } + ), + OrderedDict( + { + CONF_SENSOR: [ + { + CONF_PLATFORM: TEST_SENSOR_PLATFORM_1, + CONF_NAME: TEST_SENSOR_NAME_1, + "pin": "${pin}", + } + ], + } + ), + ] + + config = { + CONF_PACKAGES: { + "special_sensor": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: [ + { + CONF_PATH: "sensor.yaml", + CONF_VARS: {"pin": "${SENSOR_PIN}"}, + }, + ], + CONF_REFRESH: "1d", + }, + "platform": { + CONF_URL: "https://github.com/esphome/non-existant-repo", + CONF_FILES: ["platform.yaml"], + CONF_REFRESH: "1d", + }, + } + } + + actual = packages_pass(config) + + assert actual[CONF_SENSOR][0]["pin"] == "GPIO5" diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 63eb4f19515..f5ac07c1cd1 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -28,7 +28,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->configure_entity_("test 1 text",' in main_cpp + assert 'App.register_text(it_1, "test 1 text",' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index ae094fadf87..eb25af3095f 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -28,9 +28,9 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->configure_entity_("Template Text Sensor 1",' in main_cpp - assert 'ts_2->configure_entity_("Template Text Sensor 2",' in main_cpp - assert 'ts_3->configure_entity_("Template Text Sensor 3",' in main_cpp + assert 'App.register_text_sensor(ts_1, "Template Text Sensor 1",' in main_cpp + assert 'App.register_text_sensor(ts_2, "Template Text Sensor 2",' in main_cpp + assert 'App.register_text_sensor(ts_3, "Template Text Sensor 3",' in main_cpp def test_text_sensor_config_value_internal_set(generate_main): diff --git a/tests/components/api/test_proto_mac_varint.cpp b/tests/components/api/test_proto_mac_varint.cpp new file mode 100644 index 00000000000..317a6fb9d6e --- /dev/null +++ b/tests/components/api/test_proto_mac_varint.cpp @@ -0,0 +1,123 @@ +#include + +#include +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Generic varint decoder, used to verify the encoded bytes round-trip back to +// the original 48-bit MAC value, independent of the specialized encoder under +// test. +static uint64_t decode_varint(const uint8_t *buf, size_t len, size_t *consumed) { + uint64_t value = 0; + int shift = 0; + for (size_t i = 0; i < len; i++) { + value |= static_cast(buf[i] & 0x7F) << shift; + if ((buf[i] & 0x80) == 0) { + *consumed = i + 1; + return value; + } + shift += 7; + } + *consumed = 0; + return 0; +} + +// Reference encoder mirroring ProtoEncode::encode_varint_raw_64. +static size_t reference_encode(uint64_t value, uint8_t *out) { + uint8_t *p = out; + if (value < 128) { + *p++ = static_cast(value); + return p - out; + } + do { + *p++ = static_cast(value | 0x80); + value >>= 7; + } while (value > 0x7F); + *p++ = static_cast(value); + return p - out; +} + +// Encode `mac` via the 48-bit fast path and verify: +// - byte-identical output to the reference loop +// - encoded byte length matches `expected_bytes` +// - calc_uint64_48bit_force agrees on the size +// - the bytes round-trip through a generic varint decoder +static void verify_mac(uint64_t mac, size_t expected_bytes) { + ASSERT_LT(mac, 1ULL << 48) << "test fixture mac exceeds 48 bits"; + + uint8_t ref_buf[16] = {0}; + size_t ref_len = reference_encode(mac, ref_buf); + + APIBuffer api_buf; + api_buf.resize(16); + uint8_t *pos = api_buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size(); +#endif + ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, mac); + size_t new_len = pos - api_buf.data(); + + EXPECT_EQ(new_len, expected_bytes) << "mac=0x" << std::hex << mac << std::dec; + EXPECT_EQ(ref_len, expected_bytes) << "reference disagrees on length for mac=0x" << std::hex << mac << std::dec; + + for (size_t i = 0; i < new_len; i++) { + EXPECT_EQ(api_buf.data()[i], ref_buf[i]) + << "byte " << i << " differs for mac=0x" << std::hex << mac << " (got 0x" << static_cast(api_buf.data()[i]) + << ", expected 0x" << static_cast(ref_buf[i]) << ")" << std::dec; + } + + size_t consumed = 0; + uint64_t decoded = decode_varint(api_buf.data(), new_len, &consumed); + EXPECT_EQ(consumed, new_len) << "decoder did not consume all bytes for mac=0x" << std::hex << mac << std::dec; + EXPECT_EQ(decoded, mac) << "round-trip mismatch for mac=0x" << std::hex << mac << std::dec; + + // Verify the size helper agrees. field_id_size = 1 (typical 1-byte tag). + uint32_t calc_size = ProtoSize::calc_uint64_48bit_force(1, mac); + EXPECT_EQ(calc_size, 1 + expected_bytes) + << "calc_uint64_48bit_force size mismatch for mac=0x" << std::hex << mac << std::dec; +} + +// Compute the canonical varint byte length for a value < 1<<48. +static size_t expected_varint_len(uint64_t v) { + if (v < (1ULL << 7)) + return 1; + if (v < (1ULL << 14)) + return 2; + if (v < (1ULL << 21)) + return 3; + if (v < (1ULL << 28)) + return 4; + if (v < (1ULL << 35)) + return 5; + if (v < (1ULL << 42)) + return 6; + return 7; +} + +// --- Specific MACs requested for verification --- + +TEST(ProtoMacVarint, AllZeros) { verify_mac(0x000000000000ULL, 1); } // 00:00:00:00:00:00 +TEST(ProtoMacVarint, FirstByteOnly) { verify_mac(0x110000000000ULL, 7); } // 11:00:00:00:00:00 +TEST(ProtoMacVarint, SecondByteOnly) { verify_mac(0x00AA00000000ULL, 6); } // 00:AA:00:00:00:00 +TEST(ProtoMacVarint, ThirdByteOnly) { verify_mac(0x0000BB000000ULL, 5); } // 00:00:BB:00:00:00 +TEST(ProtoMacVarint, FourthByteOnly) { verify_mac(0x000000CC0000ULL, 4); } // 00:00:00:CC:00:00 +TEST(ProtoMacVarint, FifthByteOnly) { verify_mac(0x00000000DD00ULL, 3); } // 00:00:00:00:DD:00 +TEST(ProtoMacVarint, SixthByteOnly) { verify_mac(0x0000000000EEULL, 2); } // 00:00:00:00:00:EE +TEST(ProtoMacVarint, AllOnes) { verify_mac(0xFFFFFFFFFFFFULL, 7); } // FF:FF:FF:FF:FF:FF + +// 100 deterministic-random 48-bit MACs to catch regressions across the space. +TEST(ProtoMacVarint, RandomSample) { + // NOLINTNEXTLINE(cert-msc32-c,cert-msc51-cpp) -- intentional fixed seed for reproducibility. + std::mt19937_64 rng(0xC0FFEE); + for (int i = 0; i < 100; i++) { + uint64_t mac = rng() & 0xFFFFFFFFFFFFULL; + verify_mac(mac, expected_varint_len(mac)); + } +} + +} // namespace esphome::api::testing diff --git a/tests/components/json/__init__.py b/tests/components/json/__init__.py new file mode 100644 index 00000000000..40ec1f996ea --- /dev/null +++ b/tests/components/json/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # json's to_code calls cg.add_library("bblanchon/ArduinoJson", ...). C++ + # unit test builds that pull json in transitively (e.g. api) need that + # library registration to happen, otherwise json_util.cpp fails to find + # ArduinoJson.h. + manifest.enable_codegen() diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index d3565c6c59a..9c4ad4bbf85 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -16,10 +16,19 @@ binary_sensor: platform: template - id: left_sensor platform: template + - platform: lvgl + name: Button A pressed + widget: button_a + state: pressed + - platform: lvgl + name: Button A checked + widget: button_a + state: checked - platform: lvgl id: button_checker name: LVGL button widget: button_button + state: checked on_state: then: - lvgl.checkbox.update: @@ -29,6 +38,12 @@ binary_sensor: auto y = x; // block inlining of one line return return y; + - platform: lvgl + id: button_presser + name: Button pressed + widget: button_button + state: pressed + lvgl: id: lvgl_id rotation: 90 @@ -1098,6 +1113,8 @@ lvgl: pad_row: 6px pad_column: 0 multiple_widgets_per_cell: true + grid_cell_x_align: center + grid_cell_y_align: center widgets: - image: grid_cell_row_pos: 0 @@ -1290,6 +1307,87 @@ lvgl: hidden: true mode: text_lower + # Grid shorthand "x": 3 rows specified, columns derived + # from widget count (4 widgets / 3 rows -> 2 columns) + - obj: + id: grid_rows_only_shorthand + layout: 3x + widgets: + - label: + text: "r1" + - label: + text: "r2" + - label: + text: "r3" + - label: + text: "r4" + + # Grid shorthand "x": 4 columns specified, rows derived + # from widget count (5 widgets / 4 cols -> 2 rows) + - obj: + id: grid_cols_only_shorthand + layout: x4 + widgets: + - label: + text: "a" + - label: + text: "b" + - label: + text: "c" + - label: + text: "d" + - label: + text: "e" + + # Grid dict form with grid_rows as a plain integer; columns derived + - obj: + id: grid_rows_int + layout: + type: grid + grid_rows: 2 + widgets: + - label: + text: "1" + - label: + text: "2" + - label: + text: "3" + + # Grid dict form with grid_columns as a plain integer; rows derived + - obj: + id: grid_cols_int + layout: + type: grid + grid_columns: 3 + widgets: + - label: + text: "x" + - label: + text: "y" + - label: + text: "z" + - label: + text: "w" + - label: + text: "v" + + # Grid dict form with both grid_rows and grid_columns as plain integers + - obj: + id: grid_both_int + layout: + type: grid + grid_rows: 2 + grid_columns: 2 + widgets: + - label: + text: "1,1" + - label: + text: "1,2" + - label: + text: "2,1" + - label: + text: "2,2" + font: - file: "gfonts://Roboto" id: space16 diff --git a/tests/components/mapping/common.yaml b/tests/components/mapping/common.yaml index 7ffcfa4f67a..b3db9d54eb7 100644 --- a/tests/components/mapping/common.yaml +++ b/tests/components/mapping/common.yaml @@ -21,6 +21,7 @@ mapping: entries: clear-night: image_1 sunny: image_2 + default_value: image_1 - id: weather_map_2 from: string to: image @@ -35,6 +36,7 @@ mapping: 2: "two" 3: "three" 77: "seventy-seven" + default_value: unknown - id: string_map from: string to: int diff --git a/tests/components/mapping/test.esp32-idf.yaml b/tests/components/mapping/test.esp32-idf.yaml index a35b6940c74..93adcf9988b 100644 --- a/tests/components/mapping/test.esp32-idf.yaml +++ b/tests/components/mapping/test.esp32-idf.yaml @@ -4,7 +4,7 @@ packages: display: spi_id: spi_bus - platform: ili9xxx + platform: mipi_spi id: main_lcd model: ili9342 cs_pin: 12 diff --git a/tests/components/mapping/test.esp8266-ard.yaml b/tests/components/mapping/test.esp8266-ard.yaml index c59821a2119..6a308b67ddf 100644 --- a/tests/components/mapping/test.esp8266-ard.yaml +++ b/tests/components/mapping/test.esp8266-ard.yaml @@ -4,7 +4,7 @@ packages: display: spi_id: spi_bus - platform: ili9xxx + platform: mipi_spi id: main_lcd model: ili9342 cs_pin: 5 diff --git a/tests/components/mapping/test.rp2040-ard.yaml b/tests/components/mapping/test.rp2040-ard.yaml index fdfed5f6ab9..01b83c4ab82 100644 --- a/tests/components/mapping/test.rp2040-ard.yaml +++ b/tests/components/mapping/test.rp2040-ard.yaml @@ -4,7 +4,7 @@ packages: display: spi_id: spi_bus - platform: ili9xxx + platform: mipi_spi id: main_lcd model: ili9342 data_rate: 31.25MHz diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-enabled-ethernet.yaml new file mode 100644 index 00000000000..bfa9321d436 --- /dev/null +++ b/tests/components/mdns/common-enabled-ethernet.yaml @@ -0,0 +1,23 @@ +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + domain: .local + mac_address: "02:AA:BB:CC:DD:01" + +mdns: + disabled: false + services: + - service: _test_service + protocol: _tcp + port: 8888 + txt: + static_string: Anything diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml new file mode 100644 index 00000000000..f84a0bc276b --- /dev/null +++ b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-enabled-ethernet.yaml diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7846a31193f..86faaeac784 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -341,6 +341,17 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB4, 0x00, 0xC5)); } +TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { + auto ctx = TestContext{}; + + ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.set_target_temperature(26.5f); + ctx.sut.apply_settings(); + + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB5, 0x00, 0xC4)); +} + TEST(MitsubishiCN105Tests, ApplyModeCool) { auto ctx = TestContext{}; diff --git a/tests/components/modbus_controller/common.yaml b/tests/components/modbus_controller/common.yaml index ffaa1491c54..51951a45280 100644 --- a/tests/components/modbus_controller/common.yaml +++ b/tests/components/modbus_controller/common.yaml @@ -1,53 +1,11 @@ -modbus: - - id: mod_bus2 - uart_id: uart_bus - role: server - modbus_controller: - id: modbus_controller1 address: 0x2 modbus_id: modbus_bus - allow_duplicate_commands: false on_online: then: logger.log: "Module Online" - - id: modbus_controller2 - address: 0x2 - modbus_id: mod_bus2 - server_registers: - - address: 0x0000 - value_type: S_DWORD_R - read_lambda: |- - return 42.3; - max_cmd_retries: 0 - - id: modbus_controller3 - address: 0x3 - modbus_id: mod_bus2 - server_registers: - - address: 0x0009 - value_type: S_DWORD - read_lambda: |- - return 31; - write_lambda: |- - printf("address=%d, value=%d", x); - return true; - max_cmd_retries: 0 - - id: modbus_controller4 - modbus_id: mod_bus2 - address: 0x4 - server_courtesy_response: - enabled: true - register_last_address: 100 - register_value: 0 - server_registers: - - address: 0x0001 - value_type: U_WORD - read_lambda: |- - return 0x8; - - address: 0x0005 - value_type: U_WORD - read_lambda: |- - return (random_uint32() % 100); + binary_sensor: - platform: modbus_controller modbus_controller_id: modbus_controller1 diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml new file mode 100644 index 00000000000..3522c9248c9 --- /dev/null +++ b/tests/components/modbus_server/common.yaml @@ -0,0 +1,41 @@ +modbus: + - id: mod_bus2 + uart_id: uart_bus + role: server + +modbus_server: + - id: modbus_server2 + address: 0x2 + modbus_id: mod_bus2 + registers: + - address: 0x0 + value_type: S_DWORD_R + read_lambda: |- + return 42.3; + - id: modbus_server3 + address: 0x3 + modbus_id: mod_bus2 + registers: + - address: 0x9 + value_type: S_DWORD + read_lambda: |- + return 31; + write_lambda: |- + printf("address=%d, value=%d", x); + return true; + - id: modbus_server4 + modbus_id: mod_bus2 + address: 0x4 + courtesy_response: + enabled: true + register_last_address: 100 + register_value: 0 + registers: + - address: 0x1 + value_type: U_WORD + read_lambda: |- + return 0x8; + - address: 0x5 + value_type: U_WORD + read_lambda: |- + return (random_uint32() % 100); diff --git a/tests/components/modbus_server/test.esp32-idf.yaml b/tests/components/modbus_server/test.esp32-idf.yaml new file mode 100644 index 00000000000..ace2d95a0b5 --- /dev/null +++ b/tests/components/modbus_server/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/modbus_server/test.esp8266-ard.yaml b/tests/components/modbus_server/test.esp8266-ard.yaml new file mode 100644 index 00000000000..560629b0cd1 --- /dev/null +++ b/tests/components/modbus_server/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/modbus_server/test.rp2040-ard.yaml b/tests/components/modbus_server/test.rp2040-ard.yaml new file mode 100644 index 00000000000..eeebbd2a8aa --- /dev/null +++ b/tests/components/modbus_server/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/nrf52/test-dfu-pin.nrf52-xiao-ble.yaml b/tests/components/nrf52/test-dfu-pin.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..d53c6920017 --- /dev/null +++ b/tests/components/nrf52/test-dfu-pin.nrf52-xiao-ble.yaml @@ -0,0 +1,9 @@ +nrf52: + dfu: + reset_pin: + number: 14 + inverted: true + mode: + output: true + reg0: + voltage: 1.8V diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml index d53c6920017..de4c0c6e00f 100644 --- a/tests/components/nrf52/test.nrf52-xiao-ble.yaml +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -1,9 +1,4 @@ nrf52: - dfu: - reset_pin: - number: 14 - inverted: true - mode: - output: true + dfu: true reg0: voltage: 1.8V diff --git a/tests/components/radio_frequency/common-rx.yaml b/tests/components/radio_frequency/common-rx.yaml new file mode 100644 index 00000000000..bcfa1f10c71 --- /dev/null +++ b/tests/components/radio_frequency/common-rx.yaml @@ -0,0 +1,18 @@ +remote_receiver: + id: rf_receiver + pin: ${rx_pin} + +# Test radio_frequency platform with receiver +radio_frequency: + # RF 900MHz receiver + - platform: ir_rf_proxy + id: rf_900_rx + name: "RF 900 Receiver" + frequency: 900 MHz + remote_receiver_id: rf_receiver + + # RF receiver (no frequency specified) + - platform: ir_rf_proxy + id: rf_rx + name: "RF Receiver" + remote_receiver_id: rf_receiver diff --git a/tests/components/radio_frequency/common-tx.yaml b/tests/components/radio_frequency/common-tx.yaml new file mode 100644 index 00000000000..778dd68d1ef --- /dev/null +++ b/tests/components/radio_frequency/common-tx.yaml @@ -0,0 +1,19 @@ +remote_transmitter: + id: rf_transmitter + pin: ${tx_pin} + carrier_duty_percent: 100% + +# Test radio_frequency platform with transmitter +radio_frequency: + # RF 433MHz transmitter + - platform: ir_rf_proxy + id: rf_433_tx + name: "RF 433 Transmitter" + frequency: 433 MHz + remote_transmitter_id: rf_transmitter + + # RF transmitter (no frequency specified) + - platform: ir_rf_proxy + id: rf_tx + name: "RF Transmitter" + remote_transmitter_id: rf_transmitter diff --git a/tests/components/radio_frequency/common.yaml b/tests/components/radio_frequency/common.yaml new file mode 100644 index 00000000000..53a0cd379a9 --- /dev/null +++ b/tests/components/radio_frequency/common.yaml @@ -0,0 +1,7 @@ +network: + +wifi: + ssid: MySSID + password: password1 + +api: diff --git a/tests/components/radio_frequency/test.bk72xx-ard.yaml b/tests/components/radio_frequency/test.bk72xx-ard.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.bk72xx-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp32-idf.yaml b/tests/components/radio_frequency/test.esp32-idf.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.esp8266-ard.yaml b/tests/components/radio_frequency/test.esp8266-ard.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/radio_frequency/test.rp2040-ard.yaml b/tests/components/radio_frequency/test.rp2040-ard.yaml new file mode 100644 index 00000000000..a0e145f4762 --- /dev/null +++ b/tests/components/radio_frequency/test.rp2040-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + common: !include common.yaml + rx: !include common-rx.yaml + tx: !include common-tx.yaml diff --git a/tests/components/rtttl/common.yaml b/tests/components/rtttl/common.yaml index 529713583be..a4d8f951f42 100644 --- a/tests/components/rtttl/common.yaml +++ b/tests/components/rtttl/common.yaml @@ -29,3 +29,8 @@ output: rtttl: output: rtttl_output + on_finished_playback: + - then: + - logger.log: "Playback finished 1" + - then: + - logger.log: "Playback finished 2" diff --git a/tests/components/zigbee/test.nrf52-mcumgr.yaml b/tests/components/zigbee/test.nrf52-mcumgr.yaml index bf3cb9cdd92..a81feea0691 100644 --- a/tests/components/zigbee/test.nrf52-mcumgr.yaml +++ b/tests/components/zigbee/test.nrf52-mcumgr.yaml @@ -1 +1,4 @@ <<: !include common_nrf52.yaml + +zigbee: + router: true diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index daff3845158..1a62cfda904 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1744,6 +1744,64 @@ def test_proc_on_exit_skips_when_already_closed() -> None: handler.close.assert_not_called() +@pytest.mark.asyncio +async def test_esphome_logs_handler_appends_no_states_when_set() -> None: + """Test --no-states is appended when no_states is truthy in the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + json_message = { + "configuration": "device.yaml", + "port": "OTA", + "no_states": True, + } + cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) + + assert cmd == [ + "esphome", + "logs", + "device.yaml", + "--device", + "OTA", + "--no-states", + ] + handler.build_device_command.assert_awaited_once_with(["logs"], json_message) + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: + """Test --no-states is not added when no_states is absent from the message.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, {"configuration": "device.yaml", "port": "OTA"} + ) + + assert "--no-states" not in cmd + assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] + + +@pytest.mark.asyncio +async def test_esphome_logs_handler_omits_no_states_when_false() -> None: + """Test --no-states is not added when no_states is explicitly False.""" + handler = Mock(spec=web_server.EsphomeLogsHandler) + handler.build_device_command = AsyncMock( + return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] + ) + + cmd = await web_server.EsphomeLogsHandler.build_command( + handler, + {"configuration": "device.yaml", "port": "OTA", "no_states": False}, + ) + + assert "--no-states" not in cmd + + def _make_auth_handler(auth_header: str | None = None) -> Mock: """Create a mock handler with the given Authorization header.""" handler = Mock() diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml index b657a6fd21b..cc5a59e242a 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server.yaml @@ -86,9 +86,9 @@ modbus: uart_id: virtual_uart_dev role: server -modbus_controller: +modbus_server: - address: 1 - server_registers: + registers: - address: 0x03 value_type: U_WORD read_lambda: |- diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml index f0f2c56a36d..1e5f5a3389e 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -33,7 +33,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -56,10 +56,11 @@ modbus_controller: update_interval: 1s id: modbus_controller_1 +modbus_server: - address: 1 modbus_id: virtual_modbus_server id: modbus_server_1 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 99; diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml index 7ec67b03db4..e68edd22715 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -36,7 +36,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_server_2 baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -48,7 +48,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -81,15 +81,16 @@ modbus_controller: update_interval: 1s id: modbus_controller_2 +modbus_server: - address: 1 modbus_id: virtual_modbus_server - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 919; - address: 2 modbus_id: virtual_modbus_server_2 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return 929; diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml index 3edcc73f07f..94890e90de3 100644 --- a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -33,7 +33,7 @@ uart_mock: data: !lambda return data; - id: virtual_uart_controller baud_rate: 9600 - auto_start: true # See comment on virtual_uart_server above + auto_start: true # See comment on virtual_uart_server above debug: on_tx: - then: @@ -94,10 +94,11 @@ modbus_controller: update_interval: 2s id: modbus_controller_1 +modbus_server: - address: 1 modbus_id: virtual_modbus_server id: modbus_server_1 - server_registers: + registers: - address: 0x01 value_type: U_WORD read_lambda: return id(stored_u_word); diff --git a/tests/script/test_check_import_time.py b/tests/script/test_check_import_time.py new file mode 100644 index 00000000000..223c58002c9 --- /dev/null +++ b/tests/script/test_check_import_time.py @@ -0,0 +1,191 @@ +"""Unit tests for script/check_import_time.py.""" + +from __future__ import annotations + +import importlib.util +import json +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +# Load the script-under-test as `check_import_time` (it's a hyphenated path +# inside `script/` that mirrors the existing `determine_jobs` pattern). +script_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "script") +) +sys.path.insert(0, script_dir) +spec = importlib.util.spec_from_file_location( + "check_import_time", os.path.join(script_dir, "check_import_time.py") +) +check_import_time = importlib.util.module_from_spec(spec) +spec.loader.exec_module(check_import_time) + + +def _entry(name: str, self_us: int, cumulative_us: int) -> dict: + """Build a minimal HAR entry matching `importtime_waterfall --har`.""" + return { + "request": {"url": name}, + "time": cumulative_us, + "timings": {"receive": self_us, "wait": cumulative_us - self_us}, + } + + +def _har(*entries: dict) -> dict: + return {"log": {"entries": list(entries)}} + + +def test_root_cumulative_us_returns_time_for_root_module() -> None: + har = _har( + _entry("dep_a", 500, 500), + _entry("dep_b", 300, 300), + _entry("esphome.__main__", 100, 1000), + ) + assert check_import_time.root_cumulative_us(har, "esphome.__main__") == 1000 + + +def test_root_cumulative_us_missing_module_raises() -> None: + har = _har(_entry("something.else", 100, 100)) + with pytest.raises(RuntimeError, match="No HAR entry for 'esphome.__main__'"): + check_import_time.root_cumulative_us(har, "esphome.__main__") + + +def test_top_offenders_ranks_by_self_time_descending() -> None: + har = _har( + _entry("small", 100, 100), + _entry("big", 5000, 5000), + _entry("medium", 2000, 2500), + ) + result = check_import_time.top_offenders(har, n=10) + assert [name for name, _, _ in result] == ["big", "medium", "small"] + assert result[0] == ("big", 5000, 5000) + + +def test_top_offenders_respects_n_limit() -> None: + har = _har(*[_entry(f"m{i}", i * 100, i * 100) for i in range(1, 20)]) + assert len(check_import_time.top_offenders(har, n=5)) == 5 + + +def test_top_offenders_dedupes_repeat_names_keeping_first() -> None: + har = _har( + _entry("pkg", 5000, 5000), + _entry("pkg", 100, 100), # reimport later in trace + _entry("other", 1000, 1000), + ) + result = check_import_time.top_offenders(har, n=10) + assert [name for name, _, _ in result] == ["pkg", "other"] + # First occurrence wins + assert ("pkg", 5000, 5000) in result + + +def test_format_us_switches_to_ms_at_threshold() -> None: + assert check_import_time._format_us(500) == "500us" + assert check_import_time._format_us(999) == "999us" + assert check_import_time._format_us(1000) == "1.0ms" + assert check_import_time._format_us(12345) == "12.3ms" + + +def test_read_write_budget_roundtrip(tmp_path: Path) -> None: + budget_path = tmp_path / "budget.json" + with patch.object(check_import_time, "BUDGET_PATH", budget_path): + assert check_import_time.read_budget() == {} + check_import_time.write_budget(cumulative_us=12345, margin_pct=20) + loaded = check_import_time.read_budget() + assert loaded["cumulative_us"] == 12345 + assert loaded["margin_pct"] == 20 + assert loaded["target_module"] == check_import_time.TARGET_MODULE + + +def test_cmd_check_passes_when_measured_within_ceiling( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, # 100ms + } + ) + ) + # Measured 90ms: inside 100ms + 15% = 115ms ceiling + har = _har(_entry(check_import_time.TARGET_MODULE, 1000, 90000)) + args = type("A", (), {"har": None})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "measure", return_value=har), + ): + rc = check_import_time.cmd_check(args) + assert rc == 0 + out = capsys.readouterr().out + assert "measured esphome.__main__:" in out + assert "budget 100.0ms" in out + + +def test_cmd_check_fails_when_measured_exceeds_ceiling( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, + } + ) + ) + # Measured 120ms: over 100ms + 15% = 115ms ceiling + har = _har( + _entry("offender_a", 10000, 10000), + _entry(check_import_time.TARGET_MODULE, 1000, 120000), + ) + args = type("A", (), {"har": None})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "measure", return_value=har), + ): + rc = check_import_time.cmd_check(args) + assert rc == 1 + err = capsys.readouterr().err + assert "REGRESSION" in err + assert "120.0ms" in err + assert "offender_a" in err # top offender table + + +def test_cmd_check_returns_2_when_budget_missing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + budget_path = tmp_path / "nonexistent.json" + args = type("A", (), {"har": None})() + with patch.object(check_import_time, "BUDGET_PATH", budget_path): + rc = check_import_time.cmd_check(args) + assert rc == 2 + assert "missing" in capsys.readouterr().err + + +def test_cmd_check_writes_har_when_path_given(tmp_path: Path) -> None: + budget_path = tmp_path / "budget.json" + budget_path.write_text( + json.dumps( + { + "target_module": check_import_time.TARGET_MODULE, + "margin_pct": 15, + "cumulative_us": 100000, + } + ) + ) + har_path = tmp_path / "out.har" + har_text = json.dumps(_har(_entry(check_import_time.TARGET_MODULE, 1000, 80000))) + args = type("A", (), {"har": str(har_path)})() + with ( + patch.object(check_import_time, "BUDGET_PATH", budget_path), + patch.object(check_import_time, "run_waterfall", return_value=har_text), + ): + rc = check_import_time.cmd_check(args) + assert rc == 0 + assert har_path.exists() + assert json.loads(har_path.read_text()) == json.loads(har_text) diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index de239ee0b55..44c110b6892 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -56,6 +56,13 @@ def mock_should_run_python_linters() -> Generator[Mock, None, None]: yield mock +@pytest.fixture +def mock_should_run_import_time() -> Generator[Mock, None, None]: + """Mock should_run_import_time from determine_jobs.""" + with patch.object(determine_jobs, "should_run_import_time") as mock: + yield mock + + @pytest.fixture def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]: """Mock determine_cpp_unit_tests from helpers.""" @@ -91,6 +98,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -104,6 +112,7 @@ def test_main_all_tests_should_run( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = True mock_should_run_python_linters.return_value = True + mock_should_run_import_time.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -158,6 +167,7 @@ def test_main_all_tests_should_run( assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is True assert output["python_linters"] is True + assert output["import_time"] is True assert output["changed_components"] == ["wifi", "api", "sensor"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -189,6 +199,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -202,6 +213,7 @@ def test_main_no_tests_should_run( mock_should_run_clang_tidy.return_value = False mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = False + mock_should_run_import_time.return_value = False mock_determine_cpp_unit_tests.return_value = (False, []) # Mock changed_files to return no component files @@ -241,6 +253,7 @@ def test_main_no_tests_should_run( assert output["clang_tidy_mode"] == "disabled" assert output["clang_format"] is False assert output["python_linters"] is False + assert output["import_time"] is False assert output["changed_components"] == [] assert output["changed_components_with_tests"] == [] assert output["component_test_count"] == 0 @@ -261,6 +274,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy: Mock, mock_should_run_clang_format: Mock, mock_should_run_python_linters: Mock, + mock_should_run_import_time: Mock, mock_changed_files: Mock, mock_determine_cpp_unit_tests: Mock, capsys: pytest.CaptureFixture[str], @@ -274,6 +288,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy.return_value = True mock_should_run_clang_format.return_value = False mock_should_run_python_linters.return_value = True + mock_should_run_import_time.return_value = True mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"]) # Mock changed_files to return non-component files (to avoid memory impact) @@ -310,6 +325,7 @@ def test_main_with_branch_argument( mock_should_run_clang_tidy.assert_called_once_with("main") mock_should_run_clang_format.assert_called_once_with("main") mock_should_run_python_linters.assert_called_once_with("main") + mock_should_run_import_time.assert_called_once_with("main") # Check output captured = capsys.readouterr() @@ -322,6 +338,7 @@ def test_main_with_branch_argument( assert output["clang_tidy_mode"] in ["nosplit", "split"] assert output["clang_format"] is False assert output["python_linters"] is True + assert output["import_time"] is True assert output["changed_components"] == ["mqtt"] # changed_components_with_tests will only include components that actually have test files assert "changed_components_with_tests" in output @@ -597,6 +614,50 @@ def test_should_run_python_linters_with_branch() -> None: mock_changed.assert_called_once_with("release") +@pytest.mark.parametrize( + ("changed_files", "expected_result"), + [ + # esphome Python files trigger the check + (["esphome/__main__.py"], True), + (["esphome/components/wifi/__init__.py"], True), + (["esphome/core/config.py"], True), + (["esphome/types.pyi"], True), + # Dependency declarations and the check's own files trigger + (["requirements.txt"], True), + (["requirements_dev.txt"], True), + (["requirements_test.txt"], True), + (["pyproject.toml"], True), + (["script/check_import_time.py"], True), + (["script/import_time_budget.json"], True), + # Mixed: any triggering file is enough + (["docs/README.md", "esphome/config.py"], True), + # Python files outside esphome/ don't trigger + (["script/some_other_script.py"], False), + (["tests/script/test_determine_jobs.py"], False), + # Non-Python changes don't trigger + (["esphome/core/component.cpp"], False), + (["tests/components/wifi/test.esp32-idf.yaml"], False), + (["README.md"], False), + ([], False), + ], +) +def test_should_run_import_time( + changed_files: list[str], expected_result: bool +) -> None: + """Test should_run_import_time function.""" + with patch.object(determine_jobs, "changed_files", return_value=changed_files): + result = determine_jobs.should_run_import_time() + assert result == expected_result + + +def test_should_run_import_time_with_branch() -> None: + """Test should_run_import_time with branch argument.""" + with patch.object(determine_jobs, "changed_files") as mock_changed: + mock_changed.return_value = [] + determine_jobs.should_run_import_time("release") + mock_changed.assert_called_once_with("release") + + @pytest.mark.parametrize( ("changed_files", "expected_result"), [ @@ -1842,6 +1903,22 @@ def test_should_run_benchmarks_core_header_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_should_run_benchmarks_host_platform_change() -> None: + """Test benchmarks trigger on host platform changes. + + Benchmarks build and run on the host platform, so changes to its + millis()/micros()/etc. implementations affect every benchmark. + """ + for host_file in [ + "esphome/components/host/core.cpp", + "esphome/components/host/__init__.py", + ]: + with patch.object(determine_jobs, "changed_files", return_value=[host_file]): + assert determine_jobs.should_run_benchmarks() is True, ( + f"Expected benchmarks to run for {host_file}" + ) + + def test_should_run_benchmarks_benchmark_infra_change() -> None: """Test benchmarks trigger on benchmark infrastructure changes.""" for infra_file in [ diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index a76ea21c23b..e389b56adac 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -34,8 +34,9 @@ async def test_register_component(monkeypatch): actual = await ch.register_component(var, {}) assert actual is var - assert add_mock.call_count == 2 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 1 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] @@ -77,8 +78,9 @@ async def test_register_component__with_setup_priority(monkeypatch): assert actual is var add_mock.assert_called() - assert add_mock.call_count == 4 - app_mock.register_component_.assert_called_with(var) + assert add_mock.call_count == 3 + app_mock.register_component_.assert_called_once() + assert app_mock.register_component_.call_args.args[0] is var assert core_mock.component_ids == [] diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index a319fae83d4..f4d268abe03 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -1,5 +1,6 @@ """Tests for external_files.py functions.""" +import os from pathlib import Path import time from unittest.mock import MagicMock, patch @@ -9,7 +10,54 @@ import requests from esphome import external_files from esphome.config_validation import Invalid -from esphome.core import CORE, TimePeriod +from esphome.core import CORE, EsphomeError, TimePeriod + + +def _seed_etag(cache_file: Path, etag: str) -> Path: + """Write an ETag sidecar with its mtime synced to the cache file's mtime, + matching the invariant that `_write_etag` enforces in production. + """ + sidecar = external_files._etag_sidecar_path(cache_file) + sidecar.write_text(etag) + file_mtime = int(cache_file.stat().st_mtime) + os.utime(sidecar, (file_mtime, file_mtime)) + return sidecar + + +@pytest.fixture +def mock_requests_head() -> MagicMock: + """Patch `external_files.requests.head` so the conditional HEAD-request + validator can be tested without doing real HTTP. + """ + with patch("esphome.external_files.requests.head") as m: + yield m + + +@pytest.fixture +def mock_requests_get() -> MagicMock: + """Patch `external_files.requests.get` so the download path can be + tested without doing real HTTP. + """ + with patch("esphome.external_files.requests.get") as m: + yield m + + +@pytest.fixture +def mock_has_remote_file_changed() -> MagicMock: + """Patch `external_files.has_remote_file_changed` so download tests can + control the conditional check independently from the GET path. + """ + with patch("esphome.external_files.has_remote_file_changed") as m: + yield m + + +@pytest.fixture +def mock_write_file() -> MagicMock: + """Patch `external_files.write_file` so atomic-write failures can be + injected without involving the real filesystem helper. + """ + with patch("esphome.external_files.write_file") as m: + yield m def test_compute_local_file_dir(setup_core: Path) -> None: @@ -88,9 +136,8 @@ def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: assert result is False -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_not_modified( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns False when file not modified.""" test_file = setup_core / "cached.txt" @@ -98,23 +145,23 @@ def test_has_remote_file_changed_not_modified( mock_response = MagicMock() mock_response.status_code = 304 - mock_head.return_value = mock_response + mock_response.headers = {} + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) assert result is False - mock_head.assert_called_once() + mock_requests_head.assert_called_once() - call_args = mock_head.call_args + call_args = mock_requests_head.call_args headers = call_args[1]["headers"] assert external_files.IF_MODIFIED_SINCE in headers assert external_files.CACHE_CONTROL in headers -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_modified( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns True when file modified.""" test_file = setup_core / "cached.txt" @@ -122,7 +169,8 @@ def test_has_remote_file_changed_modified( mock_response = MagicMock() mock_response.status_code = 200 - mock_head.return_value = mock_response + mock_response.headers = {} + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) @@ -140,15 +188,16 @@ def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None: assert result is True -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_network_error( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed returns False on network error when file is cached.""" test_file = setup_core / "cached.txt" test_file.write_text("cached content") - mock_head.side_effect = requests.exceptions.RequestException("Network error") + mock_requests_head.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" result = external_files.has_remote_file_changed(url, test_file) @@ -156,9 +205,8 @@ def test_has_remote_file_changed_network_error( assert result is False -@patch("esphome.external_files.requests.head") def test_has_remote_file_changed_timeout( - mock_head: MagicMock, setup_core: Path + mock_requests_head: MagicMock, setup_core: Path ) -> None: """Test has_remote_file_changed respects timeout.""" test_file = setup_core / "cached.txt" @@ -166,15 +214,176 @@ def test_has_remote_file_changed_timeout( mock_response = MagicMock() mock_response.status_code = 304 - mock_head.return_value = mock_response + mock_response.headers = {} + mock_requests_head.return_value = mock_response url = "https://example.com/file.txt" external_files.has_remote_file_changed(url, test_file) - call_args = mock_head.call_args + call_args = mock_requests_head.call_args assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT +def test_has_remote_file_changed_uses_etag( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed sends If-None-Match when ETag is cached.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + _seed_etag(test_file, '"abc123"') + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_requests_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, test_file) + + assert result is False + headers = mock_requests_head.call_args[1]["headers"] + assert headers[external_files.IF_NONE_MATCH] == '"abc123"' + + +def test_has_remote_file_changed_no_etag_no_if_none_match( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed omits If-None-Match when no ETag is cached.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_requests_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, test_file) + + headers = mock_requests_head.call_args[1]["headers"] + assert external_files.IF_NONE_MATCH not in headers + + +def test_has_remote_file_changed_refreshes_etag_on_304( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed updates the cached ETag when the 304 sends a new one.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + _seed_etag(test_file, '"old"') + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {external_files.ETAG: '"new"'} + mock_requests_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, test_file) + + assert external_files._etag_sidecar_path(test_file).read_text() == '"new"' + + +def test_has_remote_file_changed_ignores_etag_when_mtime_diverges( + mock_requests_head: MagicMock, setup_core: Path +) -> None: + """If the cache file was edited out-of-band (mtime no longer matches the + sidecar's), the cached ETag must not be used -- it no longer describes the + bytes on disk. + """ + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + sidecar = _seed_etag(test_file, '"abc123"') + + # Simulate an out-of-band edit to the cache file -- mtime advances by a + # full second (so it diverges at whole-second resolution) but the sidecar + # is left untouched, so the recorded ETag is now stale. + file_stat = test_file.stat() + os.utime(test_file, (file_stat.st_atime, file_stat.st_mtime + 1)) + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_response.headers = {} + mock_requests_head.return_value = mock_response + + external_files.has_remote_file_changed("https://example.com/file.txt", test_file) + + headers = mock_requests_head.call_args[1]["headers"] + assert external_files.IF_NONE_MATCH not in headers + # Stale sidecar should be removed so future calls don't keep paying the + # mtime-comparison cost on a known-bad sidecar. + assert not sidecar.exists() + + +def test_download_content_pins_etag_mtime_to_file_mtime( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """After a successful download, the sidecar's mtime must equal the cache + file's mtime so `_read_etag` accepts it on the next call. + """ + test_file = setup_core / "fresh.txt" + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {external_files.ETAG: '"deadbeef"'} + mock_response.raise_for_status = MagicMock() + mock_requests_get.return_value = mock_response + + external_files.download_content("https://example.com/file.txt", test_file) + + sidecar = external_files._etag_sidecar_path(test_file) + assert int(sidecar.stat().st_mtime) == int(test_file.stat().st_mtime) + + +def test_write_etag_swallows_write_file_failure( + mock_write_file: MagicMock, setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """If `write_file` raises, _write_etag must not propagate -- ETag + persistence is best-effort and a failure here must not abort the + surrounding download. + """ + cache_file = setup_core / "cached.txt" + cache_file.write_text("cached content") + mock_write_file.side_effect = EsphomeError("disk full") + + with caplog.at_level("DEBUG", logger="esphome.external_files"): + external_files._write_etag(cache_file, '"abc123"') + + assert "Could not save ETag" in caplog.text + # Sidecar wasn't created, since write_file was mocked to fail before + # reaching the os.utime step. + assert not external_files._etag_sidecar_path(cache_file).exists() + + +def test_write_etag_swallows_utime_failure( + setup_core: Path, caplog: pytest.LogCaptureFixture +) -> None: + """If `os.utime` raises while pinning the sidecar's mtime, _write_etag + must not propagate. The sidecar is still written; if its mtime later + fails to match the cache file, `_read_etag` will discard it on next + read. + """ + cache_file = setup_core / "cached.txt" + cache_file.write_text("cached content") + + with ( + patch( + "esphome.external_files.os.utime", + side_effect=PermissionError("nope"), + ), + caplog.at_level("DEBUG", logger="esphome.external_files"), + ): + external_files._write_etag(cache_file, '"abc123"') + + assert "Could not sync ETag sidecar mtime" in caplog.text + # write_file succeeded, so the sidecar exists with the new value even + # though we couldn't pin its mtime. + sidecar = external_files._etag_sidecar_path(cache_file) + assert sidecar.exists() + assert sidecar.read_text() == '"abc123"' + + def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: """Test compute_local_file_dir creates parent directories.""" domain = "level1/level2/level3/level4" @@ -200,10 +409,10 @@ def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None: assert result is True -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_with_network_error_uses_cache( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content uses cached file when network fails.""" test_file = setup_core / "cached.txt" @@ -211,8 +420,10 @@ def test_download_content_with_network_error_uses_cache( test_file.write_bytes(cached_content) # Simulate file has changed, so it tries to download - mock_has_changed.return_value = True - mock_get.side_effect = requests.exceptions.RequestException("Network error") + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" result = external_files.download_content(url, test_file) @@ -220,19 +431,123 @@ def test_download_content_with_network_error_uses_cache( assert result == cached_content -@patch("esphome.external_files.requests.get") -@patch("esphome.external_files.has_remote_file_changed") def test_download_content_with_network_error_no_cache_fails( - mock_has_changed: MagicMock, mock_get: MagicMock, setup_core: Path + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, ) -> None: """Test download_content raises error when network fails and no cache exists.""" test_file = setup_core / "nonexistent.txt" # Simulate file has changed (doesn't exist), so it tries to download - mock_has_changed.return_value = True - mock_get.side_effect = requests.exceptions.RequestException("Network error") + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException( + "Network error" + ) url = "https://example.com/file.txt" with pytest.raises(Invalid, match="Could not download from.*Network error"): external_files.download_content(url, test_file) + + +def test_download_content_skip_external_update_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content skips network checks when CORE.skip_external_update is set.""" + test_file = setup_core / "cached.txt" + cached_content = b"cached content" + test_file.write_bytes(cached_content) + + CORE.skip_external_update = True + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + + assert result == cached_content + mock_has_remote_file_changed.assert_not_called() + mock_requests_get.assert_not_called() + + +def test_download_content_skip_external_update_downloads_when_missing( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content still downloads when file is missing, even with skip_external_update.""" + test_file = setup_core / "missing.txt" + new_content = b"fresh content" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.headers = {} + mock_response.raise_for_status = MagicMock() + mock_requests_get.return_value = mock_response + + CORE.skip_external_update = True + url = "https://example.com/file.txt" + result = external_files.download_content(url, test_file) + + assert result == new_content + assert test_file.read_bytes() == new_content + + +def test_download_content_saves_etag( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Test download_content writes the ETag sidecar after a successful download.""" + test_file = setup_core / "fresh.txt" + new_content = b"fresh content" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = new_content + mock_response.headers = {external_files.ETAG: '"deadbeef"'} + mock_response.raise_for_status = MagicMock() + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + + assert external_files._etag_sidecar_path(test_file).read_text() == '"deadbeef"' + + +def test_download_content_atomic_write_no_partial_on_failure( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + mock_write_file: MagicMock, + setup_core: Path, +) -> None: + """If `write_file` (the atomic-write helper) fails, the existing cache + file must remain untouched and no temp files may be left behind. Patching + `write_file` directly exercises the atomic-rename path -- a failure inside + `write_file` is the only reason the rename wouldn't have happened. + """ + from esphome.core import EsphomeError + + test_file = setup_core / "cached.txt" + original_content = b"original content" + test_file.write_bytes(original_content) + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"new content" + mock_response.headers = {} + mock_response.raise_for_status = MagicMock() + mock_requests_get.return_value = mock_response + + mock_write_file.side_effect = EsphomeError("disk full") + + with pytest.raises(EsphomeError, match="disk full"): + external_files.download_content("https://example.com/file.txt", test_file) + + # Original file is untouched -- write_file aborted before its rename step. + assert test_file.read_bytes() == original_content + # write_file is responsible for cleaning its own temp files; nothing leaks + # into the cache directory either way. + leftover_tmps = list(setup_core.glob("tmp*")) + assert leftover_tmps == [] diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 745dfad487e..eab6bfc2cb4 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -236,6 +236,35 @@ def test_clone_or_update_with_never_refresh( assert revert is None +def test_clone_or_update_skips_when_core_skip_external_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """CORE.skip_external_update short-circuits the refresh for existing repos.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = None + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + repo_dir.mkdir(parents=True) + git_dir = repo_dir / ".git" + git_dir.mkdir() + (git_dir / "FETCH_HEAD").write_text("test") + + CORE.skip_external_update = True + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=TimePeriodSeconds(days=1), + domain=domain, + ) + + mock_run_git_command.assert_not_called() + assert result_dir == repo_dir + assert revert is None + + def test_clone_or_update_with_refresh_updates_old_repo( tmp_path: Path, mock_run_git_command: Mock ) -> None: @@ -782,3 +811,193 @@ def test_clone_or_update_stale_clone_is_retried_after_cleanup( assert repo_dir.exists() assert call_count["clone"] == 2 assert call_count["fetch"] == 2 + + +def test_clone_with_ref_uses_shallow_fetch( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Clone with a ref should use --depth=1 on both clone and fetch.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "pull/123/head" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update(url=url, ref=ref, refresh=None, domain=domain) + + call_list = mock_run_git_command.call_args_list + + clone_calls = [c for c in call_list if "clone" in c[0][0]] + assert len(clone_calls) == 1 + assert "--depth=1" in clone_calls[0][0][0] + + fetch_calls = [c for c in call_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + assert "--depth=1" in fetch_calls[0][0][0] + # Ref must still be passed so the requested commit/branch is fetched. + assert ref in fetch_calls[0][0][0] + + +def test_clone_with_submodules_uses_shallow_submodule_update( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Submodule init on a fresh clone should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + if _get_git_command_type(cmd) == "clone": + repo_dir.mkdir(parents=True, exist_ok=True) + (repo_dir / ".git").mkdir(exist_ok=True) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + git.clone_or_update( + url=url, + ref=None, + refresh=None, + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + # The `--` terminator must precede the submodule paths so a path + # beginning with `-` cannot be parsed as an option. + assert cmd.index("--") < cmd.index("components/foo") + + +def test_refresh_fetch_is_shallow(tmp_path: Path, mock_run_git_command: Mock) -> None: + """The refresh-path fetch should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + _setup_old_repo(repo_dir) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + fetch_calls = [c for c in mock_run_git_command.call_args_list if "fetch" in c[0][0]] + assert len(fetch_calls) == 1 + cmd = fetch_calls[0][0][0] + assert "--depth=1" in cmd + # Ref must still be in the refresh fetch so the right tip is updated. + assert cmd[-1] == ref + + +def test_refresh_submodule_update_is_shallow( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """The refresh-path submodule update should use --depth=1.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + + _setup_old_repo(repo_dir) + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=None, + refresh=TimePeriodSeconds(days=1), + domain=domain, + submodules=["components/foo"], + ) + + submodule_calls = [ + c for c in mock_run_git_command.call_args_list if "submodule" in c[0][0] + ] + assert len(submodule_calls) == 1 + cmd = submodule_calls[0][0][0] + assert "--depth=1" in cmd + assert "components/foo" in cmd + assert cmd.index("--") < cmd.index("components/foo") + + +def test_refresh_picks_up_new_remote_commits( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Shallow fetch must still pull new commits when the remote tip moves. + + Simulates a stale local repo at SHA "old" while the remote has advanced + to SHA "new". The refresh path must run fetch (with --depth=1) followed + by reset --hard FETCH_HEAD so the working tree advances to the new tip. + """ + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + _setup_old_repo(repo_dir) + + # rev-parse is called once before fetch to record the pre-update SHA. + rev_parse_calls = {"count": 0} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type == "rev-parse": + rev_parse_calls["count"] += 1 + return "old_sha" + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + _, revert = git.clone_or_update( + url=url, ref=ref, refresh=TimePeriodSeconds(days=1), domain=domain + ) + + # Verify the refresh sequence: rev-parse -> stash -> fetch (depth=1) -> reset + call_list = mock_run_git_command.call_args_list + cmd_sequence = [_get_git_command_type(c[0][0]) for c in call_list] + assert cmd_sequence == ["rev-parse", "stash", "fetch", "reset"] + + fetch_cmd = call_list[2][0][0] + assert "--depth=1" in fetch_cmd + assert fetch_cmd[-1] == ref + + reset_cmd = call_list[3][0][0] + assert reset_cmd[-1] == "FETCH_HEAD" + + # revert callback should reset back to the recorded pre-update SHA. + assert revert is not None + revert() + assert mock_run_git_command.call_args_list[-1][0][0] == [ + "git", + "reset", + "--hard", + "old_sha", + ] diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index a42cc5cca73..3fb0eca4a06 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None: # Verify the correct number of resources assert len(resources) == 3 # test.cpp, test.h, common.cpp + + +# --------------------------------------------------------------------------- +# recursive_sources — used only by the core "esphome" manifest so that files +# in esphome/core//*.cpp (e.g. esphome/core/wake/wake_host.cpp) are +# discovered without promoting / to a Python subpackage. +# --------------------------------------------------------------------------- + + +def _mock_file(filename: str) -> MagicMock: + m = MagicMock() + m.name = filename + m.is_file.return_value = True + m.is_dir.return_value = False + return m + + +def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock: + """Mock a directory entry with an iterdir() and joinpath('__init__.py').""" + d = MagicMock() + d.name = dirname + d.is_file.return_value = False + d.is_dir.return_value = True + d.iterdir.return_value = children + init_marker = MagicMock() + init_marker.is_file.return_value = has_init + d.joinpath.return_value = init_marker + return d + + +def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: + """Default (recursive_sources=False) does not descend into subdirectories.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.test_component" + # No FILTER_SOURCE_FILES. + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module) # recursive_sources defaults to False + + top_level = [ + _mock_file("top.cpp"), + _mock_dir("subdir", [_mock_file("nested.cpp")]), + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["top.cpp"] + + +def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None: + """With recursive_sources=True, a subdir without __init__.py is walked.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), + _mock_file("wake_host.h"), + _mock_file("README.md"), # wrong suffix, excluded + ], + has_init=False, + ) + top_level = [ + _mock_file("wake.h"), + wake_dir, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = sorted(r.resource for r in manifest.resources) + + assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"] + + +def test_component_manifest_resources_recursive_skips_subpackages() -> None: + """Subdirectories that ARE Python subpackages (contain __init__.py) are + skipped even with recursive_sources=True — those load as their own + ComponentManifest and would otherwise be double-counted.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.haier" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + button_pkg = _mock_dir( + "button", + [_mock_file("self_cleaning.cpp")], + has_init=True, # Python subpackage — must be skipped. + ) + top_level = [ + _mock_file("haier.cpp"), + button_pkg, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["haier.cpp"] + + +def test_component_manifest_resources_recursive_skips_pycache() -> None: + """__pycache__ inside a recursive walk must never be descended into.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + # __pycache__ is_dir=True but must be skipped without checking __init__.py + # or calling iterdir (would yield compiled artifacts). + pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")]) + top_level = [ + _mock_file("wake.h"), + pycache, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake.h"] + + +def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> ( + None +): + """FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files + inside a recursively-walked subdir.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"] + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), # excluded + _mock_file("wake_freertos.cpp"), # kept + ], + ) + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = [wake_dir] + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake/wake_freertos.cpp"] diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 215ec291f9e..cf6d4adbf51 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -654,7 +654,7 @@ def test_resolve_package_max_depth_exceeded(tmp_path: Path) -> None: package_config = yaml_util.IncludeFile( parent, "test.yaml", None, always_returns_include ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises( cv.Invalid, match=f"Maximum include nesting depth \\({MAX_INCLUDE_DEPTH}\\) exceeded", @@ -776,7 +776,7 @@ def test_resolve_package_undefined_var_in_include_filename(tmp_path: Path) -> No package_config = yaml_util.IncludeFile( parent, "${undefined_var}.yaml", None, loader ) - processor = _PackageProcessor({}, None, False) + processor = _PackageProcessor({}, None) with pytest.raises(cv.Invalid, match="unresolved substitutions"): processor.resolve_package(package_config, substitutions.ContextVars(), []) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 940a394c080..e76769e6a83 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -7,6 +7,7 @@ from datetime import datetime import json import os from pathlib import Path +import re import stat from typing import Any from unittest.mock import MagicMock, patch @@ -32,6 +33,7 @@ from esphome.writer import ( clean_build, clean_cmake_cache, copy_src_tree, + generate_build_info_data_cpp, generate_build_info_data_h, get_build_info, storage_should_clean, @@ -1615,49 +1617,62 @@ def test_get_build_info_build_time_str_format( def test_generate_build_info_data_h_format() -> None: """Test generate_build_info_data_h produces correct header content.""" - config_hash = 0x12345678 - build_time = 1700000000 - build_time_str = "2023-11-14 22:13:20 +0000" - comment = "Test comment" - - result = generate_build_info_data_h( - config_hash, build_time, build_time_str, comment - ) + result = generate_build_info_data_h() assert "#pragma once" in result - assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result - assert "#define ESPHOME_BUILD_TIME 1700000000" in result - assert "#define ESPHOME_COMMENT_SIZE 13" in result # len("Test comment") + 1 - assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result - assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in result + assert "extern const time_t ESPHOME_BUILD_TIME;" in result + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in result + assert "extern const char ESPHOME_BUILD_TIME_STR[]" in result + assert "extern const char ESPHOME_COMMENT_STR[]" in result def test_generate_build_info_data_h_esp8266_progmem() -> None: """Test generate_build_info_data_h includes PROGMEM for ESP8266.""" - result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test", "comment") + result = generate_build_info_data_h() # Should have ESP8266 PROGMEM conditional assert "#ifdef USE_ESP8266" in result assert "#include " in result assert "PROGMEM" in result - # Both build time and comment should have PROGMEM versions + + +def test_generate_build_info_data_cpp_format() -> None: + """Test generate_build_info_data_cpp produces correct data definitions.""" + result = generate_build_info_data_cpp( + 0x12345678, 1700000000, "2023-11-14 22:13:20 +0000", "Test comment" + ) + + assert '#include "esphome/core/build_info_data.h"' in result + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x12345678U;" in result + assert "const time_t ESPHOME_BUILD_TIME = 1700000000;" in result + assert "const size_t ESPHOME_COMMENT_SIZE = 13;" in result + assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result + assert 'ESPHOME_COMMENT_STR[] = "Test comment"' in result + + +def test_generate_build_info_data_cpp_esp8266_progmem() -> None: + """Test generate_build_info_data_cpp includes PROGMEM definitions.""" + result = generate_build_info_data_cpp(0xABCDEF01, 1700000000, "test", "comment") + + assert "#ifdef USE_ESP8266" in result assert 'ESPHOME_BUILD_TIME_STR[] PROGMEM = "test"' in result assert 'ESPHOME_COMMENT_STR[] PROGMEM = "comment"' in result -def test_generate_build_info_data_h_hash_formatting() -> None: - """Test generate_build_info_data_h formats hash with leading zeros.""" +def test_generate_build_info_data_cpp_hash_formatting() -> None: + """Test generate_build_info_data_cpp formats hash with leading zeros.""" # Test with small hash value that needs leading zeros - result = generate_build_info_data_h(0x00000001, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result + result = generate_build_info_data_cpp(0x00000001, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0x00000001U;" in result # Test with larger hash value - result = generate_build_info_data_h(0xFFFFFFFF, 0, "test", "") - assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result + result = generate_build_info_data_cpp(0xFFFFFFFF, 0, "test", "") + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xffffffffU;" in result -def test_generate_build_info_data_h_comment_escaping() -> None: - r"""Test generate_build_info_data_h properly escapes special characters in comment. +def test_generate_build_info_data_cpp_comment_escaping() -> None: + r"""Test generate_build_info_data_cpp properly escapes special characters in comment. Uses cpp_string_escape which outputs octal escapes for special characters: - backslash (ASCII 92) -> \134 @@ -1665,26 +1680,52 @@ def test_generate_build_info_data_h_comment_escaping() -> None: - newline (ASCII 10) -> \012 """ # Test backslash escaping (ASCII 92 = octal 134) - result = generate_build_info_data_h(0, 0, "test", "backslash\\here") + result = generate_build_info_data_cpp(0, 0, "test", "backslash\\here") assert 'ESPHOME_COMMENT_STR[] = "backslash\\134here"' in result # Test quote escaping (ASCII 34 = octal 042) - result = generate_build_info_data_h(0, 0, "test", 'has "quotes"') + result = generate_build_info_data_cpp(0, 0, "test", 'has "quotes"') assert 'ESPHOME_COMMENT_STR[] = "has \\042quotes\\042"' in result # Test newline escaping (ASCII 10 = octal 012) - result = generate_build_info_data_h(0, 0, "test", "line1\nline2") + result = generate_build_info_data_cpp(0, 0, "test", "line1\nline2") assert 'ESPHOME_COMMENT_STR[] = "line1\\012line2"' in result -def test_generate_build_info_data_h_empty_comment() -> None: - """Test generate_build_info_data_h handles empty comment.""" - result = generate_build_info_data_h(0, 0, "test", "") +def test_generate_build_info_data_cpp_empty_comment() -> None: + """Test generate_build_info_data_cpp handles empty comment.""" + result = generate_build_info_data_cpp(0, 0, "test", "") - assert "#define ESPHOME_COMMENT_SIZE 1" in result # Just null terminator + assert "const size_t ESPHOME_COMMENT_SIZE = 1;" in result # Just null terminator assert 'ESPHOME_COMMENT_STR[] = ""' in result +def test_generate_build_info_data_cpp_comment_size_counts_utf8_bytes() -> None: + """Comment size is in encoded UTF-8 bytes, not characters.""" + # "héllo" = 6 UTF-8 bytes + NUL. + result = generate_build_info_data_cpp(0, 0, "test", "héllo") + assert "const size_t ESPHOME_COMMENT_SIZE = 7;" in result + + +def test_generate_build_info_data_cpp_comment_clamped_to_buffer() -> None: + """Generator clamps at byte level and never truncates mid-codepoint.""" + # 100 thermometer-with-VS-16 sequences = 700 bytes, past the 256 buffer. + result = generate_build_info_data_cpp(0, 0, "test", "🌡️" * 100) + + match = re.search(r"ESPHOME_COMMENT_SIZE = (\d+);", result) + assert match is not None + size = int(match.group(1)) + assert 1 < size <= 256 + + lit_match = re.search(r'ESPHOME_COMMENT_STR\[\] = "([^"]*)"', result) + assert lit_match is not None + raw = re.sub( + r"\\([0-7]{3})", lambda m: chr(int(m.group(1), 8)), lit_match.group(1) + ).encode("latin-1") + raw.decode("utf-8") # raises if truncation left a partial UTF-8 sequence + assert len(raw) == size - 1 + + @patch("esphome.writer.CORE") @patch("esphome.writer.iter_components") @patch("esphome.writer.walk_files") @@ -1758,15 +1799,21 @@ def test_copy_src_tree_writes_build_info_files( ): copy_src_tree() - # Verify build_info_data.h was written + # Verify build_info_data.h declarations and build_info_data.cpp values were written build_info_h_path = esphome_core_path / "build_info_data.h" assert build_info_h_path.exists() build_info_h_content = build_info_h_path.read_text() - assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content - assert "#define ESPHOME_BUILD_TIME" in build_info_h_content + assert "extern const uint32_t ESPHOME_CONFIG_HASH;" in build_info_h_content assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content - assert "#define ESPHOME_COMMENT_SIZE" in build_info_h_content + assert "extern const size_t ESPHOME_COMMENT_SIZE;" in build_info_h_content assert "ESPHOME_COMMENT_STR" in build_info_h_content + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + build_info_cpp_content = build_info_cpp_path.read_text() + assert "const uint32_t ESPHOME_CONFIG_HASH = 0xdeadbeefU;" in build_info_cpp_content + assert "const time_t ESPHOME_BUILD_TIME" in build_info_cpp_content + assert "const size_t ESPHOME_COMMENT_SIZE" in build_info_cpp_content + assert "ESPHOME_COMMENT_STR" in build_info_cpp_content # Verify build_info.json was written build_info_json_path = build_path / "build_info.json" @@ -1833,7 +1880,9 @@ def test_copy_src_tree_detects_config_hash_change( # Verify build_info files were updated due to config_hash change assert build_info_h_path.exists() - new_content = build_info_h_path.read_text() + build_info_cpp_path = esphome_core_path / "build_info_data.cpp" + assert build_info_cpp_path.exists() + new_content = build_info_cpp_path.read_text() assert "0xdeadbeef" in new_content.lower() new_json = json.loads(build_info_json_path.read_text()) diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index e3aa2a16f56..3815ac1d752 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -11,7 +11,13 @@ from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv from esphome.core import DocumentLocation, DocumentRange, EsphomeError from esphome.util import OrderedDict -from esphome.yaml_util import ESPHomeDataBase, format_path, make_data_base +from esphome.yaml_util import ( + ESPHomeDataBase, + ESPLiteralValue, + format_path, + make_data_base, + make_literal, +) @pytest.fixture(autouse=True) @@ -891,3 +897,57 @@ def test_format_path_empty_path_with_located_current_obj(): obj = _located("${var}", "main.yaml", 0, 0) result = format_path([], obj) assert result == "In: in main.yaml 1:1" + + +def test_make_literal_wraps_dict() -> None: + """A dict is wrapped so it becomes an ESPLiteralValue instance.""" + value = {"key": "${var}"} + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, dict) + assert result == {"key": "${var}"} + + +def test_make_literal_wraps_list() -> None: + """A list is wrapped so it becomes an ESPLiteralValue instance.""" + value = ["${var}", "plain"] + result = make_literal(value) + assert isinstance(result, ESPLiteralValue) + assert isinstance(result, list) + assert result == ["${var}", "plain"] + + +def test_make_literal_wraps_string() -> None: + """A string is wrapped so it becomes an ESPLiteralValue instance.""" + result = make_literal("${var}") + assert isinstance(result, ESPLiteralValue) + assert result == "${var}" + + +def test_make_literal_returns_already_wrapped_value_unchanged() -> None: + """Wrapping a value that is already an ESPLiteralValue returns it as-is.""" + value = make_literal({"key": "value"}) + assert isinstance(value, ESPLiteralValue) + result = make_literal(value) + assert result is value + + +def test_make_literal_returns_none_unchanged() -> None: + """Values whose class cannot be augmented (e.g. ``None``) are returned as-is.""" + result = make_literal(None) + assert result is None + + +def test_make_literal_blocks_substitution() -> None: + """A value wrapped with make_literal is skipped by the substitution pass.""" + value = make_literal({"pin": "${PIN}"}) + result = substitutions.substitute( + value, + path=[], + parent_context=substitutions.ContextVars(), + strict_undefined=False, + ) + # The literal block must remain untouched, even though the variable is + # undefined in the context. + assert result == {"pin": "${PIN}"} + assert isinstance(result, ESPLiteralValue)